
CVE-2022-3602에 대한 기술적 심층 분석 및 안티-POC, OpenSSL 3.0.x의 punycode 버퍼 오버플로우, 재현 스크립트, 스택 분석 및 컴파일러 완화 평가 포함.
이 문서와 저장소는 OpenSSL의 punycode 버퍼 오버플로 문제인 CVE−2022-3602에 대한 분석입니다. '안티-POC'(이 문제는 악용 가능성이 없는 것으로 보입니다)로, 자체 OpenSSL 빌드를 유지 관리하는 사람들과 컴파일러 유지 관리자를 위한 것입니다.
동일 릴리스에는 별도의 CVE인 CVE-2022-3786이 있으며, 이 역시 버퍼 오버플로를 유발하지만 공격자가 그 내용을 제어할 수 없습니다. 여기에는 해당 문제에 대한 재현이 없지만, 해당 문제는 충돌로 인한 서비스 거부(Denial of Service)를 유발할 수 있습니다.
크래시와 버퍼 오버플로는 결코 좋지 않으며, OpenSSL 3.0.x를 사용 중이라면 가능한 한 빨리 업데이트하는 것이 현명합니다.
오류나 누락 사항이 있으면 GitHub 이슈나 풀 리퀘스트를 통해 알려주시기 바랍니다.
ossl_punycode_decode가 punycode 디코딩을 처리하는 방식에 1차이(off-by-one) 문제가 있어 4바이트 오버플로가 발생합니다. 이 문제는 OpenSSL이 인증서 체인을 처리할 때만 발생하며 두 가지 조건이 필요합니다. 첫째, 체인 내의 CA 또는 중간 인증서에 punycode를 사용하는 name-constraint 필드가 포함되어야 합니다.
nameConstraints = permitted;email:xn-maccrthaigh-n7a.com
둘째, 리프 인증서에 SmtpUTF8Mailbox 문자열을 지정하는 SubjectAlternateName (SAN) otherName 필드가 포함되어야 합니다.
otherName = 1.3.6.1.5.5.7.8.9;UTF8:[email protected]
트리거되면 nameConstraints 필드의 punycode(다른 Name 필드의 punycode가 아님)가 취약한 OpenSSL punycode 파싱에 의해 처리됩니다.
David Benjamin과 Matt Caswell은 nameConstraint 검사가 일반적인 인증서 체인 검증 및 서명 확인 이후에 발생한다고 확인했습니다. 대부분의 애플리케이션에서 이는 자체 서명 인증서나 유효하지 않은 체인으로는 문제를 트리거할 수 없음을 의미합니다.
openssl의 s_client 및 s_server 애플리케이션은 디버깅용으로 설계되었으며 체인이 유효하지 않아도 처리를 중단하지 않습니다.
신뢰할 수 있는 CA 또는 중간 인증서에 악성 페이로드가 포함되어야 하며, 문제를 트리거하는 리프 인증서에 서명해야 합니다.
신뢰할 수 없는 당사자가 CA 또는 중간자 역할을 하는 환경도 있을 수 있습니다. 예를 들어 고객이 제공한 프라이빗 CA를 지원하는 호스팅 서비스가 있지만, 흔하지 않습니다.
많은 애플리케이션에서 답은 '아니오'입니다. 컴파일러가 스택을 배치하는 방식과 스택 카나리/스택 쿠키, 패딩, PIE, FORTIFY_SOURCE와 같은 다른 보호 기능이 있기 때문입니다.
이 문제는 스택에서 32비트 오버플로를 유발합니다. 이는 셸 코드를 직접 실행하기에는 충분하지 않지만, 애플리케이션의 제어 흐름을 변경하기에는 충분할 수 있습니다. 예를 들어 X509 인증서 체인에 포함된 셸 코드로 점프하는 것이 가능할 수 있습니다. 이 데이터가 스택의 실행 가능한 위치에 복사된다면 말입니다.
제가 테스트한 모든 Linux 플랫폼에서 오버플로는 패딩에서 발생하며 무해했습니다. 이론적으로 컴파일러는 변수를 배치하여 오버플로가 ossl_a2ulabel 함수의 다른 변수 중 하나에서 발생하도록 할 수 있습니다. 인라인화에 따라 존재하는 전체 변수 목록은 다음과 같습니다:
outptr, inptr, size, result, tmpptr, delta, seed, utfsize
그리고 그 중 어떤 것도 권한 상승이나 흥미로운 제어를 위한 명확한 경로를 제공하지 않는 것으로 보입니다.
네 바이트 모두에 대해 가능한 한 많은 제어를 할 수 있는 재현 및 오버플로를 생성하는 도구가 포함된 tarball을 첨부했습니다. 참조 재현 문자열(xn--ww90271...aaaa)은 0xFF 0x0F 0x0F 0x0F 값으로 네 바이트를 오버플로합니다. 그것이 애플리케이션을 충돌시키지 않는다면, 그 애플리케이션은 취약하지 않을 가능성(높음?)이 있습니다.
셸 스크립트 run-poc을 사용하여 악성 인증서 체인을 생성할 수 있습니다. ca.cnf에서 악성 CA 인증서가 생성되고, leaf.cnf에서 트리거하는 리프 인증서가 생성됩니다.
CA 인증서는 다음 참조 페이로드를 사용합니다:
xn--ww902716aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Python 스크립트를 사용하여 다른 페이로드에 대한 다른 punycode 문자열을 생성할 수 있습니다.
실행되면 run-poc은 openssl 클라이언트와 서버를 실행하고 문제를 열 번 악용하려고 시도합니다.
취약한 OpenSSL은 충돌할 가능성이 높습니다. 이것이 해당 OpenSSL 버전이 RCE에 취약하다는 것을 의미하지는 않습니다. 스택 카나리와 스택 쿠키 보호도 일반적으로 (더 안전한) 애플리케이션 충돌을 유발하기 때문입니다. 또한 이는 동일 릴리스의 다른 CVE의 심각도를 변경하지 않는다는 점에 유의하세요.
네 개의 오버플로 바이트를 거의 완전히 제어하는 것은 놀라울 정도로 미묘하며, 비표준/유효하지 않은 punycode로 OpenSSL의 punycode 디코더를 악용해야 합니다. 첨부된 tarball에는 이러한 미묘함을 처리하는 문자열을 구성할 수 있는 스크립트가 포함되어 있습니다. 다음은 작동 방식에 대한 설명입니다.
보안 문제는 ossl_punycode_decode()에 있습니다.
int ossl_punycode_decode(const char *pEncoded, const size_t enc_len,
unsigned int *pDecoded, unsigned int *pout_length)
ossl_punycode_decode는 ossl_a2ulabel에서 호출됩니다. pEncoded 버퍼는 X509 인증서 체인에서 오는 대략 임의 크기의 버퍼입니다. nameConstraint 필드에서 'xn--' 뒤에 오는 부분입니다. 이러한 인증서 체인을 재현하는 방법은 [reproduction]을 참조하세요.
pDecoded는 unsigned int의 LABEL_BUF_SIZE 크기 배열입니다. LABEL_BUF_SIZE는 512이고, 대부분의 플랫폼에서 unsigned int는 4바이트입니다. 따라서 대부분의 플랫폼에서 pDecoded의 길이는 2048바이트입니다.
ossl_punycode_decode() 내부에서 문제의 핵심은 이 잘못된 길이 검사입니다:
if (written_out > max_out)
max_out은 항상 512인 *pout_length에 해당합니다. 그리고 written_out은 pDecoded에 쓰여진 unsigned int의 수를 추적합니다. written_out은 쓰기 후에만 증가하기 때문에, 이 잘못된 검사로 인해 513개의 unsigned int가 pDecoded에 쓰여질 수 있습니다. 최종 결과는 다음과 같습니다...
pDecoded = [ ... , 'X , 'Y' , 'Z' ] 'P'
// Indices 509 510 511
여기서 C의 관례에 따라 인덱스는 0부터 시작하므로 슬롯 번호 511은 배열의 512번째 요소입니다. 'P'는 ossl_a2ulabel()의 buf 버퍼(pDecoded가 가리키는)를 위해 스택에 할당된 공간을 벗어난 4바이트 페이로드입니다.
4바이트는 작은 오버플로이며, nop sled를 전달하거나 셸 코드를 직접 실행하기에는 충분하지 않지만 애플리케이션의 제어 흐름을 변경하기에는 충분합니다. 예를 들어, X509 인증서 체인에 포함된 셸 코드로 점프하는 것이 가능할 수 있습니다. 이 데이터(또는 이 데이터의 복사된 조각)가 저장되는 방식과 해당 메모리가 실행 가능한지 여부에 따라 다릅니다. 그러나 공격자에게는 여전히 더 많은 어려움이 있습니다.
첫째, 컴파일러의 스택 패딩 및 정렬, 또는 스택 카나리와 같은 방어 기능으로 인해 어떠한 악용도 완전히 불가능할 수 있습니다.
둘째, ossl_punycode_decode()로의 경로는 하나뿐이며 이 경로는 스택의 버퍼를 사용합니다. 따라서 이 문제가 다른 메모리 위치에서 동시에 4바이트 오버플로를 일으키는 데 사용될 가능성은 낮습니다.
Punycode 문자열은 기본적으로 두 가지 형태가 있습니다. 하나는 xn--c1yn36f (點看)이고 다른 하나는 xn--maccrthaigh-n7a (maccárthaigh)입니다. 마지막 - 구분자 뒤에 오는 부분은 기본 일반 ASCII가 아닌 유니코드 코드 포인트와 삽입할 문자열 위치를 36진 부트스트링 인코딩한 것입니다.
지금 중요한 것은 ossl_punycode_decode()의 디코딩 과정에서 두 값이 생성된다는 것입니다. 하나는 삽입할 unsigned int 코드 포인트 값인 'n'이고, 다른 하나는 삽입할 버퍼 위치인 'i'입니다.
쓰기는 두 가지 방식으로 발생할 수 있습니다. i가 문자열 중간에 있으면 먼저 모든 것을 오른쪽으로 한 슬롯씩 복사하여 '공간을 만드는' memmove()가 있습니다:
memmove(pDecoded + i + 1, pDecoded + i,
(written_out - i) * sizeof *pDecoded);
그런 다음 방금 만든 공간에 n을 씁니다:
pDecoded[i] = n;
만약 i가 문자열 끝에 있다면, memmove()는 최종 매개변수가 0이므로 효과가 없습니다. 다른 줄은 단순한 추가가 됩니다.
이제 페이로드 'P'를 오버플로 위치로 가져오는 세 가지 방법과 제약 조건이 발생하는 이유를 살펴보겠습니다.
오버플로를 트리거하는 가장 간단한 방법은 511개의 ASCII 문자와 두 개의 비ASCII 문자를 포함하는 punycode 문자열을 만드는 것입니다. 'ÁÁAAAAAAAA...AAA'와 같은 513자 길이 문자열의 punycode 인코딩이면 됩니다. 이 경우 written_out이 510일 때 버퍼가 다음과 같이 배치됩니다:
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A', ]
// Indices 0 1 ... 509 510 511
이것은 복사된 기본 ASCII 문자들입니다. 그런 다음 punycode 부트스트링을 파싱하고 위치 0에 'Á'를 삽입합니다. 위치는 0부터 511 사이의 어떤 값이든 될 수 있습니다.
pDecoded = [ 'Á' , 'A' , ... , 'A' , 'A', 'A' ]
// Indices 0 1 ... 509 510 511
그런 다음 이것을 반복합니다:
pDecoded = [ 'Á' , 'Á' , ... , 'A' , 'A', 'A' ] 'A'
// Indices 0 1 ... 509 510 511 512
이로 인해 일반 ASCII 'A'가 '이동'되면서 오버플로됩니다. 이 경우 4바이트 페이로드는 0x00 0x00 0x00 0x41이 됩니다. 앞서 보았듯이 punycode가 작동하는 방식 때문에 ASCII 범위의 마지막 바이트를 가진 값을 표현할 수 있는 유일한 방법입니다.
기본 문자 수에 대한 올바른 경계 검사가 있으므로 두 개의 비ASCII 문자를 사용해야 하며, 이는 512보다 작아야 합니다.
마지막 바이트 값이 46이 될 수 없는 추가 제약 조건이 있습니다. ossl_punycode_decode()는 리터럴 . 문자 앞의 문자열 부분에서 호출되기 때문입니다. Punycode는 도메인 레이블을 위한 것이며, 도메인 레이블에는 점이 포함될 수 없습니다.
오버플로를 트리거하는 다음으로 간단한 방법은 끝에 비ASCII 문자가 있는 513자 문자열을 만드는 것입니다. 'AAAAAAAAAA...AAÁ'와 같은 것입니다. 이 경우 마지막 두 단계에서 다음과 같이 됩니다:
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] // Indices 0 1 ... 510 511
그리고
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] 'Á'
// Indices 0 1 ... 510 511
비ASCII 문자가 오버플로 위치에 직접 들어갑니다. OpenSSL punycode 파서는 여기서 오버플로 값이 실제로 유효한 유니코드 문자인지 강제하지 않습니다. 그것은 다소 이진 디코딩 과정입니다. 그러나 punycode 디코딩의 미묘함은 방법 2가 처음 보이는 것만큼 유연하지 않음을 의미합니다.
Punycode에서 값 n과 i는 모두 단일 가변 길이 정수로 인코딩된 후 base36을 사용하여 ASCII 인코딩됩니다. 두 개의 관련 없는 숫자를 단일 정수로 인코딩하는 것이 불가능해 보일 수 있지만, punycode의 영리한 트릭은 문자열의 길이(현재까지)를 숨겨진 필드로 사용하는 것입니다.
예를 들어, 4개의 기본 문자와 하나의 비기본 문자(예: AAÁAA)가 있는 punycode 문자열이 있다고 가정합니다. 먼저 기본 문자 AAAA로만 표현됩니다. 'Á'의 유니코드 값은 225이고 문자열 내 위치는 2입니다. 트릭은 값에 길이+1을 곱한 다음 위치를 더하는 것입니다. 따라서 ((225 * (4+1)) + 2) = 1127이 되며, 이것이 (가변 길이 base 36으로) 인코딩되는 방식입니다.
디코딩하려면 반대로 합니다. 1127 / 5 = 225이고 1127 % 5 = 2입니다. 이것이 하나의 숫자에서 두 숫자를 복구하는 방법입니다. 그러나 문자열이 길어질수록 값이 가질 수 있는 크기가 더 제한된다는 점에 유의하세요. 그렇지 않으면 배수가 unsigned int에 맞지 않습니다. 일반적으로 문자열 길이가 M이면 값에서 log M 비트의 폭을 잃게 됩니다.
512번째 정수를 처리할 때쯤이면 9비트의 폭을 잃게 됩니다. 방법 2를 사용하면 겉보기에 32비트 페이로드의 최대값은 실제로 2^23입니다. 3바이트도 안 됩니다. 방법 2는 차선책입니다.
4바이트 제어권을 되찾기 위한 가장 효율적인 방법은 페이로드 문자를 계속 반복하는 것입니다. 지금까지 punycode가 처리되는 방식에 대한 두 가지 다른 관련 세부 사항을 생략했습니다.
첫 번째 세부 사항은 비ASCII 문자가 문자열 순서로 인코딩되지 않고 값의 오름차순으로 인코딩된다는 것입니다. 문자열 'ÉÁ'는 'Á at position 1, É at position 0'으로 인코딩됩니다. Á의 값(225)이 É(233)보다 낮기 때문입니다.
두 번째 세부 사항은 비ASCII 문자가 리터럴 값으로 인코딩되지 않고 가장 최근에 디코딩된 값에 대한 델타로 인코딩된다는 것입니다. 첫 번째 값은 상대할 이전 값이 없기 때문에 하드코딩된 시작점 128이 있습니다.
이러한 작은 미묘함은 punycode를 매우 공간 효율적으로 만듭니다. 그러나 비ASCII 문자가 128보다 낮은 값으로 디코딩될 수 없음을 의미하기도 합니다. 가장 작은 델타는 0이며, 음수 델타를 표현할 방법이 없습니다. 따라서 128보다 작은 숫자를 원한다면 방법 1을 사용해야 합니다.
또한 페이로드를 최대한 제어하기 위한 최선의 전략은 페이로드를 전체 문자열의 유일한 값으로 만드는 것입니다. 그러면 인코딩의 0번째 위치에서 전체 폭을 활용할 수 있기 때문입니다. 인코딩하는 문자열은 다음과 같습니다:
[ 'P', 'P', ... 'P', 'P', 'P' ]
0 1 510 511 512
OpenSSL에 의해 다음과 같이 디코딩됩니다:
pDecoded = [ 'P', 'P', ... 'P', 'P' ] 'P'
0 1 510 511 512
오버플로 위치에 P가 있으며, 128과 (2^32 - 1) 사이의 모든 값을 나타낼 수 있습니다.
이 모든 것은 비표준 punycode 인코더가 필요하며, 필요에 따라 방법 1 또는 방법 3을 사용하여 페이로드를 제작할 수 있는 스크립트를 포함했습니다.
OpenSSL 업데이트 외에 다른 완화 방법이 있나요?
인증서 체인은 대부분의 환경에서 일반 텍스트로 전달되며, SubjectAlternateName OtherName 필드에 DER 인코딩된 1.3.6.1.5.5.7.8.9 NID가 포함된 TCP 연결을 거부함으로써 악성 체인을 차단할 수 있습니다.
안타깝게도 이 필드는 두 개 이상의 패킷으로 임의로 분할될 수 있으므로 차단하려면 일종의 상태 저장 패턴 매처가 필요합니다. 인증서는 압축될 수도 있지만, OpenSSL 3.0.x는 현재 인증서 압축을 지원하지 않습니다.
또한 TLS 1.3에서는 클라이언트 인증서 체인이 전선에서 암호화되며, 이전 버전의 TLS는 기존 연결을 재협상할 때 암호화된 인증서 체인을 지원합니다. 이는 서버 시작 인증서 인증을 위해 때때로 수행됩니다. 이러한 경우 네트워크 필터는 효과적이지 않습니다.
정적으로 링크된 바이너리에서 OpenSSL 3을 사용하고 있는지 어떻게 알 수 있나요?
readelf -a [binary] | grep -i ossl_punycode_decode
정적으로 링크된 바이너리에서 취약한 함수를 검색합니다. OpenSSL 3.0 이상만 이 함수를 포함합니다.