
이것은 CVE-2024-6387을 위해 제가 작성한 POC입니다.
Qualys Security Advisory
regreSSHion: OpenSSH 서버에서의 RCE, glibc 기반 Linux 시스템에서 (CVE-2024-6387)
요약 SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, 2005)
필요한 것은 도약에 대한 믿음뿐
-- The Interrupters, "Leap of Faith"
예비 참고: OpenSSH는 세계에서 가장 안전한 소프트웨어 중 하나입니다. 이 취약점은 거의 완벽한 구현에서 발생한 한 번의 실수입니다. 그 심층 방어 설계와 코드는 모범이자 영감이며, OpenSSH 개발자들의 모범적인 작업에 감사드립니다.
우리는 OpenSSH 서버(sshd)에서 취약점(시그널 핸들러 레이스 조건)을 발견했습니다. 클라이언트가 LoginGraceTime 초(기본 120초, 구버전 OpenSSH에서는 600초) 내에 인증하지 않으면, sshd의 SIGALRM 핸들러가 비동기적으로 호출됩니다. 그러나 이 시그널 핸들러는 async-signal-safe 하지 않은 다양한 함수(예: syslog())를 호출합니다. 이 레이스 조건은 sshd의 기본 구성에 영향을 미칩니다.
조사 결과, 이 취약점은 실제로 CVE-2006-5051 ("OpenSSH 4.4 이전 버전의 시그널 핸들러 레이스 조건으로 원격 공격자가 서비스 거부(충돌)를 유발하고 잠재적으로 임의 코드를 실행할 수 있음")의 회귀(regression)라는 것을 알게 되었습니다. 이는 2006년 Mark Dowd에 의해 보고되었습니다.
이 회귀는 2020년 10월(OpenSSH 8.5p1) 커밋 752250c ("OpenSSH를 위한 개정된 로그 인프라")에 의해 도입되었으며, 이 커밋이 실수로 sigdie()에서 "#ifdef DO_LOG_SAFE_IN_SIGHAND"를 제거했습니다. sigdie()는 sshd의 SIGALRM 핸들러에 의해 직접 호출되는 함수입니다. 다시 말해:
OpenSSH < 4.4p1은 CVE-2006-5051에 대한 백포트 패치가 적용되지 않았거나, CVE-2006-5051에 대한 잘못된 수정이었던 CVE-2008-4109에 대한 패치가 적용되지 않은 경우 이 시그널 핸들러 레이스 조건에 취약합니다.
4.4p1 <= OpenSSH < 8.5p1은 이 시그널 핸들러 레이스 조건에 취약하지 않습니다. (CVE-2006-5051 패치가 sigdie()에 추가한 "#ifdef DO_LOG_SAFE_IN_SIGHAND"가 이 안전하지 않은 함수를 안전한 _exit(1) 호출로 변환했기 때문입니다.)
8.5p1 <= OpenSSH < 9.8p1은 이 시그널 핸들러 레이스 조건에 다시 취약합니다. ("#ifdef DO_LOG_SAFE_IN_SIGHAND"가 sigdie()에서 실수로 제거되었기 때문입니다.)
이 취약점은 glibc 기반 Linux 시스템에서 원격으로 익스플로잇 가능하며, syslog() 자체가 async-signal-unsafe 함수(예: malloc() 및 free())를 호출합니다. 이는 루트 권한으로 인증되지 않은 원격 코드 실행으로 이어집니다. sshd의 권한 있는 코드에 영향을 미치며, 이 코드는 샌드박스 처리되지 않고 전체 권한으로 실행됩니다. 우리는 다른 libc나 운영체제는 조사하지 않았습니다. 하지만 OpenBSD는 분명히 취약하지 않습니다. 그 이유는 OpenBSD의 SIGALRM 핸들러가 syslog_r()을 호출하는데, 이는 2001년 OpenBSD가 고안한 async-signal-safer 버전의 syslog()이기 때문입니다.
이 취약점을 원격으로 익스플로잇하기 위해 (우리가 아는 한, CVE-2006-5051은 이전에 성공적으로 익스플로잇된 적이 없습니다), 우리는 2001년 Michal Zalewski가 발표한 선구적인 논문 "Delivering Signals for Fun and Profit"에서 영감을 얻었습니다.
그럼에도 불구하고, 우리는 즉시 세 가지 주요 문제에 직면했습니다:
이론적 관점에서, SIGALRM에 의해 적절한 시점에 중단되었을 때 sshd를 불일치 상태로 만드는 유용한 코드 경로를 찾아야 하며, 그런 다음 이 불일치 상태를 SIGALRM 핸들러 내에서 익스플로잇해야 합니다.
실제적 관점에서, 우리는 sshd에서 이 유용한 코드 경로에 도달할 방법을 찾고, 적절한 시점에 중단시킬 가능성을 최대화해야 합니다.
타이밍 관점에서, 우리는 이 유용한 코드 경로를 적절한 시점에 중단시킬 가능성을 더욱 높일 방법을 원격으로 찾아야 합니다.
최신 운영체제 보호 기능(특히 ASLR 및 NX)에 즉시 대처하지 않고 이 세 가지 문제에 집중하기 위해, 우리는 먼저 i386의 구버전 OpenSSH를 익스플로잇하고, 이를 바탕으로 최신 버전을 익스플로잇하기로 결정했습니다:
첫째, "debian-30r6-dvd-i386-binary-1_NONUS.iso"의 "SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3": 이는 권한 분리(privilege separation)가 기본적으로 활성화되어 있고 그 시대의 모든 중요한 취약점(특히 CVE-2003-0693 및 CVE-2002-0640)에 대해 패치된 최초의 데비안 버전입니다.
이 버전을 원격으로 익스플로잇하기 위해, free() 호출을 SIGALRM으로 중단시키고(sshd의 공개 키 파싱 코드 내에서), 힙을 불일치 상태로 만든 다음, SIGALRM 핸들러 내에서 다른 free() 호출 중 이 불일치 상태를 악용합니다.
실험에서 이 레이스 조건을 이기기 위해 평균 약 10,000번의 시도가 필요했습니다. 즉, 600초(LoginGraceTime)당 10개의 연결(MaxStartups)이 허용되는 경우, 원격 루트 쉘을 얻는 데 평균 약 1주일이 걸립니다.
둘째, "ubuntu-6.06.1-server-i386.iso"의 "SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3": 이는 CVE-2006-5051 ("OpenSSH 4.4 이전 버전의 시그널 핸들러 레이스 조건")에 여전히 취약한 마지막 우분투 버전입니다.
이 버전을 원격으로 익스플로잇하기 위해, pam_start() 호출을 SIGALRM으로 중단시키고, PAM 구조 중 하나를 불일치 상태로 만든 다음, SIGALRM 핸들러 내에서 pam_end() 호출 중 이 불일치 상태를 악용합니다.
실험에서 이 레이스 조건을 이기기 위해 평균 약 10,000번의 시도가 필요했습니다. 즉, 120초(LoginGraceTime)당 10개의 연결(MaxStartups)이 허용되는 경우, 원격 루트 쉘을 얻는 데 평균 약 1-2일이 걸립니다.
마지막으로, "debian-12.5.0-i386-DVD-1.iso"의 "SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2": 이는 현재 데비안 안정 버전이며, CVE-2006-5051의 회귀에 취약합니다.
이 버전을 원격으로 익스플로잇하기 위해, malloc() 호출을 SIGALRM으로 중단시키고(sshd의 공개 키 파싱 코드 내에서), 힙을 불일치 상태로 만든 다음, SIGALRM 핸들러 내에서 다른 malloc() 호출 중 이 불일치 상태를 악용합니다(보다 정확히는 syslog() 내부).
실험에서 이 레이스 조건을 이기기 위해 평균 약 10,000번의 시도가 필요하므로, 120초(LoginGraceTime)당 100개의 연결(MaxStartups)이 허용되는 경우 약 3-4시간이 걸립니다. 궁극적으로 원격 루트 쉘을 얻는 데 평균 약 6-8시간이 걸리는데, 그 이유는 ASLR로 인해 glibc의 주소를 절반 정도만 올바르게 추측할 수 있기 때문입니다.
이 연구는 아직 진행 중입니다:
우리는 가상 머신만 대상으로 했으며, 베어메탈 서버는 대상으로 하지 않았습니다. 네트워크 링크는 대부분 안정적이었습니다(약 10ms 패킷 지터).
우리는 익스플로잇의 다양한 측면이 크게 개선될 수 있다고 확신합니다.
amd64 익스플로잇 작업을 시작했습니다. 이는 더 강력한 ASLR 때문에 훨씬 어렵습니다.
amd64 작업을 시작한 지 며칠 후, OpenSSH의 공개 Bugzilla에서 sshd의 SIGALRM 핸들러 교착 상태에 관한 다음 버그 보고서를 발견했습니다:
따라서 우리는 즉시 OpenSSH 개발자에게 이 교착 상태가 익스플로잇 가능한 취약점으로 인해 발생한다는 사실을 알리기로 결정했으며, amd64 작업을 보류하고 이 권고를 작성하기 시작했습니다.
하지만 그건 나답지 않아, 나는 벗어나고 있어
-- The Interrupters, "Haven't Seen the Last of Me"
이 OpenSSH 버전의 SIGALRM 핸들러는 packet_close()를 호출하고, 이는 buffer_free()를 호출하며, 다시 xfree()와 free()를 호출합니다. free()는 async-signal-safe하지 않습니다:
따라서 우리는 이 데비안의 glibc(2.2.5)의 malloc 코드를 읽기 시작했습니다. 첫 번째 free() 호출이 SIGALRM에 의해 중단될 수 있고, SIGALRM 핸들러 내의 두 번째 free() 호출(위의 341-344줄) 중에 악용될 수 있는지 알아보기 위해서였습니다. 이 glibc의 malloc은 2000년 Solar Designer가 개척한 unlink() 기술에 대해 강화되지 않았기 때문에, 우리는 chunk_free()(free()에 의해 내부적으로 호출됨)에서 흥미로운 코드 경로를 빠르게 발견했습니다:
이 코드 경로를 악용하기 위해, sshd의 힙이 다음과 같은 배치를 갖도록 조정합니다(chunk_X, chunk_Y, chunk_Z는 malloc()으로 할당된 메모리 청크이고, p, s, f, b는 각각 prev_size, size, fd, bk 필드입니다):
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| 사용자 데이터
먼저, free(chunk_Y) 호출이 3246줄 이후에, 그러나 3251줄 이전에 SIGALRM에 의해 중단되면, chunk_Y는 이미 비어 있는 것으로 표시됩니다(3246줄에서 chunk_Z의 PREV_INUSE 비트가 지워졌기 때문). 하지만 아직 이중 연결 리스트에 연결되지는 않았습니다(3251줄). 즉, chunk_Y의 fd와 bk 포인터는 여전히 사용자 데이터(공격자 제어 데이터)를 포함합니다.
둘째, (SIGALRM 핸들러 내에서) packet_close()가 free(chunk_X)를 호출하면, 코드 블록 3230-3244줄이 입력되고(chunk_Y가 비어 있는 것으로 표시되었기 때문에), chunk_Y가 unlink()됩니다(3241줄). 이는 소위 aa4bmo 기본 요소(거의 임의 4바이트 미러 덮어쓰기)입니다. chunk_Y의 fd와 bk 포인터가 여전히 공격자 제어이기 때문입니다. unlink() 기술과 aa4bmo 기본 요소에 대한 자세한 내용은 다음을 참조하십시오:
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
마지막으로, 이 aa4bmo 기본 요소를 사용하여 glibc의 __free_hook 함수 포인터(이 구버전 데비안에는 ASLR이나 NX가 없음)를 힙에 있는 쉘코드의 주소로 덮어씁니다. 따라서 packet_close()에서 다음 free() 호출 시 원격 코드 실행이 이루어집니다.
이제 그들이 장악하고 있고 완전히 통제하고 있어
-- The Interrupters, "Liberty"
sshd에 대해 이 공격을 수행하기 위해, 우리는 sshd의 DSA 공개 키 파싱 코드 내에서 free() 호출(즉, 아래의 144줄이 우리의 free(chunk_Y))을 중단시키고, packet_close()의 free() 호출 중 하나(즉, 위의 341-344줄 중 하나가 우리의 free(chunk_X))에서 이를 악용합니다:
하지만 처음에는 이 레이스 조건(즉, 144줄의 free() 호출을 적절한 시점에 중단시키는 것)을 절대 이길 수 없었습니다. 결국, 우리는 이 레이스에서 이길 가능성을 크게 향상시킬 수 있다는 것을 깨달았습니다. DSA 공개 키 파싱 코드는 free()를 네 번 호출할 수 있게 해줍니다(아래 704-707줄). 게다가 sshd는 사용자 인증을 여섯 번 시도할 수 있게 해줍니다(AUTH_FAIL_MAX). 이 24개의 free() 호출 중 하나라도 적절한 시점에 중단되면, 이후 SIGALRM 핸들러 내에서 원격 코드 실행을 달성합니다.
이 개선으로 우리는 약 1개월 만에 마침내 레이스 조건을 이겼습니다. 기뻤고(루트 쉘 댄스를 추었습니다), 하지만 여전히 개선의 여지가 있다고 느꼈습니다.
걱정 마, 기다리면 보일 거야
-- The Interrupters, "Haven't Seen the Last of Me"
따라서 우리는 다음과 같은 세 가지 타이밍 전략을 구현했습니다:
DSA 공개 키 패킷(상당히 큼)을 sshd에 보내는 마지막 순간까지 기다리지 않습니다. 대신 LoginGraceTime보다 훨씬 전에 마지막 바이트 하나를 제외한 전체 패킷을 보내고, 마지막 바이트는 마지막 순간에 보내 네트워크 지연의 영향을 최소화합니다. (그리고 Nagle 알고리즘을 비활성화합니다.)
중간 왕복 시간을 추적하고(sshd로부터 응답을 생성하는 패킷을 정기적으로 보냄), 연결이 sshd에 의해 닫힐 것으로 예상되는 시점(기본적으로 sshd 배너의 첫 번째 바이트를 받은 시간 + LoginGraceTime)과 연결이 실제로 sshd에 의해 닫힌 시점 간의 차이를 추적하여 그에 따라 타이밍을 조정합니다(즉, DSA 패킷의 마지막 바이트를 보내는 시점).
이러한 시간 차이를 통해 시간에 따라 예측 가능한 패턴을 보이는 시계 오차와 네트워크 지연을 추적할 수 있습니다. 선형 및 스플라인 회귀를 실험해 보았지만, 결국 가장 최근 측정값을 재사용하는 것보다 더 잘 작동하는 방법은 없었습니다. 아마도 딥 러닝이 더 나은 결과를 낼 수 있을 것입니다. 이는 관심 있는 독자에게 연습 문제로 남겨둡니다.
더 중요한 것은, sshd의 비자발적 피드백을 통해 타이밍을 천천히 조정하여 이 레이스 조건에서 이길 가능성을 더욱 높인다는 점입니다:
DSA 공개 키 패킷에 대한 응답(SSH2_MSG_USERAUTH_FAILURE)을 받으면, 너무 일찍 보낸 것입니다(sshd가 권한이 없는 자식 프로세스에서 패킷을 수신하고, 파싱하고, 권한 있는 자식 프로세스로 보내고, 그곳에서 파싱하고, 응답을 다시 보낼 시간이 있었습니다).
DSA 패킷의 마지막 바이트조차 보낼 수 없다면, 너무 오래 기다린 것입니다(sshd가 이미 SIGALRM을 수신하고 연결을 종료했습니다).
DSA 패킷의 마지막 바이트를 보낼 수 있고, sshd가 연결을 종료하기 전에 응답을 받지 못했다면, 타이밍이 합리적으로 정확했던 것입니다.
이 피드백을 통해 우리는 소위 "큰" 레이스 윈도우를 목표로 할 수 있습니다. 이를 맞추는 것이 레이스 조건에서 이긴다는 보장은 없지만, 이 큰 윈도우 안에는 24개의 "작은" 레이스 윈도우(24개의 free() 호출 내)가 있으며, 이를 맞추면 레이스 조건에서 이길 것이 보장됩니다.
이러한 개선으로 이 레이스 조건을 이기기 위해 평균 약 10,000번의 시도가 필요합니다. 즉, 600초(LoginGraceTime)당 10개의 연결(MaxStartups)이 허용되는 경우, 원격 루트 쉘을 얻는 데 평균 약 1주일이 걸립니다.
해가 뜨기 시작할 때 잠이 들어
-- The Interrupters, "Alien"
이 OpenSSH 버전의 SIGALRM 핸들러는 더 이상 packet_close()를 호출하지 않습니다. 게다가 이 우분투의 glibc(2.3.6)는 malloc 함수군에 진입할 때 항상 필수 잠금을 취합니다(sshd와 같은 단일 스레드에서도). 이는 malloc 함수 중 하나에 대한 호출을 중단시키고 나중에 이 함수들에 대한 다른 호출 중에 악용하는 것을 방지합니다(항상 교착 상태가 됩니다). 다른 해결책을 찾아야 합니다.
CVE-2006-5051은 GSSAPI에서의 이중 해제(double-free)를 언급하지만, GSSAPI(또는 Kerberos)는 기본적으로 활성화되어 있지 않으므로 매력적으로 보이지 않습니다. 반면 PAM은 기본적으로 활성화되어 있으며, pam_end()는 sshd의 SIGALRM 핸들러에 의해 호출됩니다(물론 async-signal-safe하지 않습니다). 따라서 우리는 SIGALRM에 의해 적절한 시점에 중단되면 PAM의 내부 구조를 불일치 상태로 만들어 SIGALRM 핸들러의 pam_end()에서 악용할 수 있는 PAM 함수를 찾았습니다. 우리는 pam_set_data()를 찾았습니다:
33 int pam_set_data( 34 pam_handle_t *pamh, .. 37 void (*cleanup)(pam_handle_t *pamh, void *data, int error_status)) 38 { 39 struct pam_data *data_entry; .. 57 } else if ((data_entry = malloc(sizeof(*data_entry)))) { .. 65 data_entry->next = pamh->data; 66 pamh->data = data_entry; .. 74 data_entry->cleanup = cleanup; ------------------------------------------------------------------------If this function is interrupted by SIGALRM after line 66 but before line 74, then data_entry is already linked into PAM's structures (pamh), but its cleanup field (a function pointer) is not yet initialized (since the malloc() at line 57 does not initialize its memory). If we are able to control cleanup (through leftovers from previous heap allocations), then we can execute arbitrary code when pam_end() (inside the SIGALRM handler) calls _pam_free_data() (at line 118):
This would have been an extremely simple exploit; unfortunately, we completely overlooked that pam_set_data() can only be called from PAM modules: if we interrupt it with SIGALRM, then pamh->caller_is is still _PAM_CALLED_FROM_MODULE, in which case pam_end() returns immediately, without ever calling _pam_free_data(). Back to the drawing board.
Not giving up, it's not what we do
-- The Interrupters, "Title Holder"
We noticed that, at line 601 below, sshd passes a pointer to its global sshpam_handle pointer directly to pam_start() (which is called once per connection):
We therefore decided to look into pam_start() itself: if interrupted by SIGALRM, it might leave the structure pointed to by sshpam_handle in an inconsistent state, which could then be exploited inside the SIGALRM handler, when "pam_end(sshpam_handle, sshpam_err)" is called.
At line 32, pam_start() immediately sets sshd's sshpam_handle to a calloc()ated chunk of memory; this is safe, because calloc() initializes this memory to zero. On the other hand, if _pam_add_handler() (which is called multiple times by pam_start()) is interrupted by SIGALRM after line 874 but before line 886, then a malloc()ated structure is linked into pamh, but its next field is not yet initialized. If we are able to control next (through leftovers from previous heap allocations), then we can pass an arbitrary pointer to free() during the call to pam_end() (inside the SIGALRM handler), at line 1020 (and line 1017) below:
Because the malloc of this Ubuntu's glibc is already hardened against the old unlink() technique, we decided to transform our arbitrary free() into the Malloc Maleficarum's House of Mind (fastbin version): we free() our own NON_MAIN_ARENA chunk, point our fake arena to sshd's .got.plt (this Ubuntu's sshd has ASLR but not PIE), and overwrite _exit()'s entry with the address of our shellcode in the heap (this Ubuntu's heap is still executable by default). For more information on the Malloc Maleficarum:
I learned everything the hard way
-- The Interrupters, "The Hard Way"
To mount this attack against sshd, we initially faced three problems:
The House of Mind requires us to store the pointer to our fake arena at address 0x08100000 in the heap; but are we able to store attacker- controlled data at such a high address? Because sshd calls pam_start() at the very beginning of the user authentication, we do not control anything except the user name itself; luckily, a user name of length ~128KB (shorter than DEFAULT_MMAP_THRESHOLD) allows us to store our own data at address 0x08100000.
The size field of our fake NON_MAIN_ARENA chunk must not be too large (to pass free()'s security checks); i.e., it must contain null bytes. But our long user name is a null-terminated string that cannot contain null bytes; luckily we remembered that _pam_free_handlers_aux() zeroes the structures that it free()s (line 1019 above): we therefore "patch" the size field of our fake chunk with such a memset(0), and only then free() it.
We must survive several calls to free() (at lines 1017 and 1020 above) before the free() of our fake NON_MAIN_ARENA chunk. We transform these free()s into no-ops by pointing them to fake IS_MMAPPED chunks: free() calls munmap_chunk(), which calls munmap(), which fails because these fake IS_MMAPPED chunks are misaligned; effectively a no-op, because assert()ion failures are not enforced in this Ubuntu's glibc.
Finally, our long user name also allows us to control the potentially uninitialized next field of 20 different structures (through leftovers from temporary copies of our long user name), because pam_start() calls _pam_add_handler() multiple times; i.e., our large race window contains 20 small race windows.
Same tricks they used before
-- The Interrupters, "Divide Us"
For this attack against Ubuntu 6.06.1, we simply re-used the timing strategy that we used against Debian 3.0r6: it takes ~10,000 tries on average to win the race condition, and with 10 connections (MaxStartups) accepted per 120 seconds (LoginGraceTime), it takes ~1-2 days on average to obtain a remote root shell.
Note: because this Ubuntu's glibc always takes a mandatory lock when entering the functions of the malloc family, an unlucky attacker might deadlock all 10 MaxStartups connections before obtaining a root shell; we have not tried to work around this problem because our ultimate goal was to exploit a modern OpenSSH version anyway.
Now you're ready, take the demons head on
-- The Interrupters, "Be Gone"
The SIGALRM handler of this OpenSSH version does not call packet_close() nor pam_end(); in fact it calls only one interesting function, syslog():
Our two key questions, then, are: Does the syslog() of this Debian's glibc (2.36) call async-signal-unsafe functions such as malloc() and free()? And if yes, does this glibc still take a mandatory lock when entering the functions of the malloc family?
Note: because we do not control anything about these malloc()ations (not their order, not their sizes, not their contents), we took the "rce" at line 166 as a much-needed good omen.
And luckily for us, the answer to our second question is no; since October 2017, the glibc's malloc functions do not take any lock anymore, when single-threaded (like sshd):
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
Moreover, this Debian version suffers from the ASLR weakness described in the following great blog posts (by Justin Miller and Mathias Krause, respectively):
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
Concretely, in the case of sshd on i386, every memory mapping is randomized normally (sshd's PIE, the heap, most libraries, the stack), but the glibc itself is always mapped either at address 0xb7200000 or at address 0xb7400000; in other words, we can correctly guess the glibc's address half of the time (a small price to pay for defeating ASLR). In our exploit we assume that the glibc is mapped at address 0xb7400000, because it is slightly more common than 0xb7200000.
Our next question is: which code paths inside the glibc's malloc functions, if interrupted by SIGALRM at the right time, leave the heap in an inconsistent state, exploitable during one of the malloc() calls inside the SIGALRM handler?
We found several interesting (and surprising!) code paths, but the one we chose involves only relative sizes, not absolute addresses (unlike various code paths inside unlink_chunk(), for example); this difference might prove crucial for a future amd64 exploit. This code path, inside malloc(), splits a large free chunk (victim) into two smaller chunks; the first chunk is returned to malloc()'s caller (at line 4345) and the second chunk (remainder) is linked into an unsorted list of free chunks (at lines 4324-4327):
If this code path is interrupted by SIGALRM after line 4327 but before line 4339, then the remainder chunk of this split is already linked into the unsorted list of free chunks (lines 4324-4327), but its size field (mchunk_size) is not yet initialized (line 4339).
If we are able to control its size field (through leftovers from previous heap allocations), then we can make this remainder chunk larger and overlap with other heap chunks, and therefore corrupt heap memory when this enlarged, overlapping remainder chunk is eventually malloc()ated and written to (inside the SIGALRM handler).
Our last question, then, is: given that we do not control anything about the malloc() calls inside the SIGALRM handler, what can we overwrite in the heap to achieve arbitrary code execution before sshd calls _exit() (in sshsigdie())?
Because __tzfile_read() (inside the SIGALRM handler) malloc()ates a FILE structure in the heap (at line 166 above), and because FILE structures have a long history of abuse for arbitrary code execution, we decided to aim our heap corruption at this FILE structure. This is, however, easier said than done: our heap corruption is very limited, and FILE structures have been significantly hardened over the years (by IO_validate_vtable() and PTR_DEMANGLE(), for example).
Eventually, we devised the following technique (which seems to be specific to the i386 glibc -- the amd64 glibc does not seem to use _vtable_offset at all):
with our limited heap corruption, we overwrite the _vtable_offset field (a single signed char) of __tzfile_read()'s FILE structure;
the glibc's libio functions will therefore look for this FILE structure's vtable pointer (a pointer to an array of function pointers) at a non-zero offset (our overwritten _vtable_offset), instead of the default zero offset;
we (attackers) can easily control this fake vtable pointer (through leftovers from previous heap allocations), because the FILE structure around this offset is not explicitly initialized by fopen();
to pass the glibc's security checks, our fake vtable pointer must point somewhere into the __libc_IO_vtables section: we decided to point it to the vtable for wide-character streams, _IO_wfile_jumps (i.e., to 0xb761b740, since we assume that the glibc is mapped at address 0xb7400000);
as a result, __fread_unlocked() (at line 186 above) calls _IO_wfile_underflow() (instead of _IO_file_underflow()), which calls a function pointer (__fct) that basically comes from a structure whose pointer (_codecvt) is yet another field of the FILE structure;
we (attackers) can easily control this _codecvt pointer (through leftovers from previous heap allocations, because this field of the FILE structure is not explicitly initialized by fopen()), which also allows us to control the __fct function pointer.
In summary, by overwriting a single byte (_vtable_offset) of the FILE structure malloc()ated by fopen(), we can call our own __fct function pointer and execute arbitrary code during __fread_unlocked().
I wanted it perfect, no wrinkles in it
-- The Interrupters, "In the Mirror"
To mount this attack against sshd's privileged child, let us first imagine the following heap layout (the "XXX"s are "barrier" chunks that allow us to make holes in the heap; for example, small memory-leaked chunks):
---|----------------------------------------------|---|------------|---
| XXX | large hole | XXX | small hole | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | large allocated chunk | free remainder chunk | XXX | small hole | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
but if this malloc() is interrupted by SIGALRM after line 4327 but before line 4339, then the remainder chunk of this split is already linked into the unsorted list of free chunks, but its size field is under our control (through leftovers from previous heap allocations), and this artificially enlarged remainder chunk overlaps with the following small hole:---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | real remainder chunk |XXX| small hole |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| artificially enlarged remainder chunk
SIGALRM 핸들러가 syslog()와 __tzfile_read()를 호출할 때, fopen()은 FILE 구조체를 위해 small hole을 malloc()으로 할당하고, __fread_unlocked()는 4KB 읽기 버퍼를 malloc()으로 할당하여, 확대된 remainder chunk를 둘로 나눕니다 (4KB 읽기 버퍼와 작은 remainder chunk):
---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<------->| 4KB read buffer remainder
따라서 우리는 FILE 구조체의 일부를 이 작은 remainder chunk의 내부 헤더로 덮어씁니다: 더 정확히 말하면, FILE의 _vtable_offset을 이 헤더의 bk 필드의 세 번째 바이트로 덮어쓰는데, 이 bk 필드는 free chunk의 unsorted list를 가리키는 포인터(0xb761d7f8)입니다 (즉, _vtable_offset을 0x61로 덮어씁니다);
그런 다음 "Theory" 하위 섹션에서 설명한 대로, __fread_unlocked()는 _IO_file_underflow() 대신 _IO_wfile_underflow()를 호출하고, 이 함수는 우리의 __fct 함수 포인터를 (우리의 _codecvt 포인터를 통해) 호출하여 임의 코드를 실행합니다.
참고: 우리는 아직 제어된 _codecvt 포인터에서 제어된 __fct 함수 포인터로 안정적으로 도달하는 방법을 설명하지 않았습니다. 그렇게 할 것이지만, 먼저 더 시급한 문제를 해결해야 합니다.
실제로, 이전 OpenSSH 버전에 대한 연구에서 우리는 큰 경합 윈도우에 작은 경합 윈도우가 하나만 있으면 이 시그널 핸들러 경합 조건에서 절대 이길 수 없다는 것을 배웠습니다. 따라서 우리는 다음과 같은 힙 레이아웃을 기반으로 한 전략을 구현했습니다:
---|------------|---|------------|---|------------|---|------------|---
| XXX | large hole 1 | XXX | small hole 1 | XXX | large hole 2 | XXX | small hole 2 | ... |
|---|---|---|---|---|---|---|---|---|
| ~8KB | 320B | ~8KB | 320B |
sshd에 보내는 마지막 패킷 (SIGALRM 전달 직전)은 sshd가 다음과 같은 malloc() 호출 시퀀스를 수행하도록 강제합니다: malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), 등.
1/ 첫 번째 malloc(~4KB)이 large hole 1을 둘로 나눕니다:
이 첫 번째 분할이 적절한 시점에 SIGALRM에 의해 중단되면, SIGALRM 핸들러 내부의 fopen()이 FILE 구조체를 위해 small hole 1을 malloc()으로 할당하고, 위에서 설명한 대로 임의 코드 실행을 달성합니다;
그렇지 않으면, 첫 번째 malloc(304)로 small hole 1을 직접 할당하고:
2/ 두 번째 malloc(~4KB)이 large hole 2를 둘로 나눕니다:
이 두 번째 분할이 적절한 시점에 SIGALRM에 의해 중단되면, SIGALRM 핸들러 내부의 fopen()이 FILE 구조체를 위해 small hole 2를 malloc()으로 할당하고, 위에서 설명한 대로 임의 코드 실행을 달성합니다;
그렇지 않으면, 두 번째 malloc(304)로 small hole 2를 직접 할당하는 식입니다.
우리는 sshd의 힙에 27쌍의 큰 구멍과 작은 구멍을 만들 수 있었습니다 (28쌍이면 PACKET_MAX_SIZE인 256KB를 초과합니다): 이제 우리의 큰 경합 윈도우는 27개의 작은 경합 윈도우를 포함합니다! 이 복잡한 힙 레이아웃을 달성하는 것은 매우 고통스럽고 시간이 많이 걸렸지만, 두 가지 주요 요점은 다음과 같습니다:
이 힙 레이아웃을 안정적으로 달성하기 위해, 우리는 sshd에 다섯 가지 다른 공개 키 패킷을 보냅니다 (패킷 a/부터 d/까지는 SIGALRM보다 훨씬 전에 보낼 수 있습니다; 패킷 e/의 대부분도 SIGALRM보다 훨씬 전에 보낼 수 있지만, 맨 마지막 바이트는 마지막 순간에 보내야 합니다):
a/ 다양한 tcache 청크를 malloc()으로 할당하고 free()하여, 우리가 제어하지 않는 힙 할당이 이러한 tcache 청크에 들어가서 우리의 세심한 힙 레이아웃을 방해하지 않도록 합니다.
b/ 다양한 크기의 청크를 malloc()으로 할당하고 free()하여, 27쌍의 큰 구멍과 작은 구멍 (및 해당 "barrier" 청크)을 만듭니다.
c/ ~4KB 청크와 320B 청크를 malloc()으로 할당하고 free()하여:
잠재적으로 확대된 remainder chunk의 가짜 헤더 (큰 크기 필드)를 큰 구멍 중간에 씁니다;
잠재적으로 확대된 remainder chunk의 가짜 푸터를 작은 구멍 끝에 써서 (glibc의 보안 검사를 통과시킵니다);
가짜 vtable 및 _codecvt 포인터를 작은 구멍 (잠재적인 FILE 구조체)에 씁니다.
d/ 매우 큰 문자열 (거의 256KB) 하나를 malloc()으로 할당하고 free()하여, 우리의 큰 구멍과 작은 구멍이 unsorted free chunk 리스트에서 제거되고 각각의 malloc 빈에 배치되도록 합니다.
e/ sshd가 최종 malloc() 호출 시퀀스 (malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), 등)를 수행하도록 강제하여, 27개의 작은 경합 윈도우를 엽니다.
세심한 독자라면 우리가 아직 (문자 그대로 그리고 비유적으로) _codecvt의 문제를 해결하지 않았음을 알아차렸을 것입니다. 실제로 _codecvt는 구조체 (_IO_codecvt)에 대한 포인터이며, 이 구조체는 __fct 함수 포인터를 포함하는 구조체 (__gconv_step)에 대한 포인터를 포함합니다. _codecvt를 통해 __fct를 안정적으로 제어하기 위해, 우리는 _codecvt를 glibc의 malloc 빈 중 하나를 가리키도록 합니다. 이 빈은 편리하게도 힙에 있는 우리의 free 청크 중 하나에 대한 포인터를 포함하고 있으며, 이 청크에는 임의의 glibc 코드에 대한 우리의 __fct 함수 포인터가 포함되어 있습니다 (이러한 모든 glibc 주소는 glibc가 주소 0xb7400000에 매핑된다고 가정하기 때문에 우리에게 알려져 있습니다).
We're running out of time
-- The Interrupters, "As We Live"
세 번째 익스플로잇을 구현하면서, 이전 두 개의 OpenSSH 버전에 사용했던 타이밍 전략을 단순히 재사용할 수 없다는 것이 분명해졌습니다. 우리는 이 새로운 경합 조건에서 절대 이기지 못했습니다. 결국, 그 이유를 이해하게 되었습니다:
sshd가 다섯 번째이자 마지막 공개 키 (위의 패킷 e/)를 구문 분석하는 데 오랜 시간 (~10ms)이 걸립니다. 즉, 우리의 큰 경합 윈도우가 너무 큽니다 (27개의 작은 경합 윈도우는 바늘더미 속의 바늘과 같습니다).
최근 (OpenSSH 7.8p1) 도입된 user_specific_delay()는 마지막 공개 키 패킷에 대한 sshd의 응답을 최대 ~9ms 지연시키므로, 피드백 기반 타이밍 전략을 무너뜨립니다.
결과적으로, 우리는 완전히 다른 타이밍 전략을 개발했습니다:
때때로, 우리는 공개 키를 구문 분석하는 sshkey_from_blob() 호출 직전에 오류 응답을 생성하는 (아래 라인 138-142) 약간의 실수가 있는 마지막 공개 키 패킷을 보냅니다;
때때로, 우리는 공개 키를 구문 분석하는 sshkey_from_blob() 호출 직후에 오류 응답을 생성하는 (아래 라인 151-155) 또 다른 약간의 실수가 있는 마지막 공개 키 패킷을 보냅니다;
이 두 응답 시간의 차이는 sshd가 마지막 공개 키를 구문 분석하는 데 걸리는 시간이며, 이를 통해 마지막 패킷의 전송 시간을 정밀하게 조정할 수 있습니다 (sshd가 권한 없는 자식 프로세스에서 공개 키를 구문 분석하고, 이를 권한 있는 자식 프로세스로 보내고, SIGALRM이 전달되기 전에 그곳에서 구문 분석을 시작할 시간을 확보하기 위해).
이 전략 변경으로 경합 조건을 이기기 위해 평균 약 10,000번의 시도가 필요합니다. 즉, 120초(LoginGraceTime)당 100개의 연결(MaxStartups)이 허용되는 경우, 경합 조건을 이기기 위해 평균 약 3-4시간이 걸리고, ASLR로 인해 원격 루트 셸을 얻는 데 약 6-8시간이 걸립니다.
What's your plan for tomorrow?
-- The Interrupters, "Take Back the Power"
우리는 Rocky Linux 9 (Red Hat Enterprise Linux 9 파생)을 대상으로 하기로 결정했습니다. "Rocky-9.4-x86_64-minimal.iso"에서 가져온 이유는 두 가지입니다:
이 배포판의 OpenSSH 버전(8.7p1)은 이 시그널 핸들러 경합 조건에 취약하며, glibc가 항상 2MB의 배수에 매핑됩니다 (이전 "Theory" 하위 섹션에서 논의된 ASLR 약점 때문에), 따라서 부분 포인터 덮어쓰기가 훨씬 더 강력해집니다;
이 glibc 버전(2.34)의 syslog() 함수 (비동기-시그널-안전하지 않지만 sshd의 SIGALRM 핸들러에 의해 호출됨)는 내부적으로 __open_memstream() 를 호출하며, 이 함수는 힙에 FILE 구조체를 malloc()으로 할당하고, calloc(), realloc(), free()도 호출합니다 (이는 우리에게 매우 필요한 자유를 줍니다).
힙 손상을 기본 요소로, 힙에 malloc()으로 할당된 두 개의 FILE 구조체, 그리고 glibc 주소의 21개의 고정 비트를 고려할 때, 우리는 이 시그널 핸들러 경합 조건이 amd64에서 익스플로잇 가능하다고 믿습니다 (아마도 6-8시간은 아니더라도, 일주일 이내에 가능하길 바랍니다). 시간이 말해줄 것입니다.
참고: Ubuntu 24.04는 sshd 자식 프로세스의 ASLR을 다시 무작위화하지 않는다는 것을 발견했습니다 (부팅 시에만 한 번 무작위화됩니다). 이를 아래 패치로 추적했으며, 이 패치는 sshd의 rexec_flag를 비활성화합니다. 일반적으로 이것은 좋지 않은 생각이지만, 이 시그널 핸들러 경합 조건의 특정 경우에는 sshd가 익스플로잇 가능하지 않도록 만듭니다: SIGALRM 핸들러 내부의 syslog()는 malloc 함수를 전혀 호출하지 않는데, 이는 syslog()에 대한 첫 번째 호출이 절대 아니기 때문입니다.
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
The storm has come and gone
-- The Interrupters, "Good Things"
2024년 6월 6일, 이 시그널 핸들러 경합 조건은 커밋 81c1099 ("Add a facility to sshd(8) to penalise particular problematic client behaviours") 에 의해 수정되었습니다. 이 커밋은 sshd의 SIGALRM 핸들러에서 비동기-시그널-안전하지 않은 코드를 sshd의 리스너 프로세스로 이동시켜 동기적으로 처리할 수 있게 했습니다:
이 수정 사항은 대규모 커밋(81c1099)의 일부이며, 더 큰 심층 방어 커밋(03e3de4, "Start the process of splitting sshd into separate binaries") 위에 있기 때문에 백포트가 어려울 수 있습니다. 그런 경우, 시그널 핸들러 경합 조건 자체는 sshsigdie() 함수에서 비동기-시그널-안전하지 않은 코드를 제거하거나 주석 처리하여 수정할 수 있습니다. 예를 들어:
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
마지막으로, sshd를 업데이트하거나 재컴파일할 수 없는 경우, 이 시그널 핸들러 경합 조건은 설정 파일에서 LoginGraceTime을 0으로 설정하여 수정할 수 있습니다. 이렇게 하면 sshd가 서비스 거부 (모든 MaxStartups 연결의 소진)에 취약해지지만, 이 권고에서 제시된 원격 코드 실행으로부터 안전해집니다.
OpenSSH 개발자들의 뛰어난 작업과 이번 릴리스에 대한 긴밀한 협력에 감사드립니다. 또한 distros@openwall에도 감사드립니다. 마지막으로, 이 권고를 Sophia d'Antoine에게 바칩니다.
2024-05-19: OpenSSH 개발자에게 연락했습니다. 패치와 패치 검토의 연속적인 반복이 이어졌습니다.
2024-06-20: distros@openwall에 연락했습니다.
2024-07-01: 조정된 릴리스 날짜.