Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
regreSSHion — 이것은 CVE-2024-6387을 위해 제가 작성한 POC입니다. | Kitploit
도구/GitHubGitHub/teamos-hub/regresshion
Vulnerability AnalysisExploitationPenetration TestingRed TeamingRemote Access ToolBinary Exploitation
GitHubteamos-hub/regresshion

regreSSHion

이것은 CVE-2024-6387을 위해 제가 작성한 POC입니다.

저장소 보기
122년 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

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)

  • 이론
  • 실제
  • 타이밍 SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3 (Ubuntu 6.06.1, 2006)
  • 이론, 첫 번째 접근
  • 이론, 두 번째 접근
  • 실제
  • 타이밍 SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2 (Debian 12.5.0, 2024)
  • 이론
  • 실제
  • 타이밍 amd64 익스플로잇을 향하여 패치 및 완화 조치 감사의 말 타임라인

======================================================================== 요약

root@kitploit:~
필요한 것은 도약에 대한 믿음뿐
    -- 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"에서 영감을 얻었습니다.

    https://lcamtuf.coredump.cx/signals.txt

    그럼에도 불구하고, 우리는 즉시 세 가지 주요 문제에 직면했습니다:

    • 이론적 관점에서, 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 핸들러 교착 상태에 관한 다음 버그 보고서를 발견했습니다:

    https://bugzilla.mindrot.org/show_bug.cgi?id=3690

    따라서 우리는 즉시 OpenSSH 개발자에게 이 교착 상태가 익스플로잇 가능한 취약점으로 인해 발생한다는 사실을 알리기로 결정했으며, amd64 작업을 보류하고 이 권고를 작성하기 시작했습니다.

    ======================================================================== SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, 2005)


    이론

    root@kitploit:~
    하지만 그건 나답지 않아, 나는 벗어나고 있어
        -- The Interrupters, "Haven't Seen the Last of Me"
    

    이 OpenSSH 버전의 SIGALRM 핸들러는 packet_close()를 호출하고, 이는 buffer_free()를 호출하며, 다시 xfree()와 free()를 호출합니다. free()는 async-signal-safe하지 않습니다:


    302 grace_alarm_handler(int sig) 303 { ... 307 packet_close();

    329 packet_close(void) 330 { ... 341 buffer_free(&input); 342 buffer_free(&output); 343 buffer_free(&outgoing_packet); 344 buffer_free(&incoming_packet);

    35 buffer_free(Buffer *buffer) 36 { 37 memset(buffer->buf, 0, buffer->alloc); 38 xfree(buffer->buf); 39 }

    51 xfree(void *ptr) 52 { 53 if (ptr == NULL) 54 fatal("xfree: NULL pointer given as argument"); 55 free(ptr); 56 }

    따라서 우리는 이 데비안의 glibc(2.2.5)의 malloc 코드를 읽기 시작했습니다. 첫 번째 free() 호출이 SIGALRM에 의해 중단될 수 있고, SIGALRM 핸들러 내의 두 번째 free() 호출(위의 341-344줄) 중에 악용될 수 있는지 알아보기 위해서였습니다. 이 glibc의 malloc은 2000년 Solar Designer가 개척한 unlink() 기술에 대해 강화되지 않았기 때문에, 우리는 chunk_free()(free()에 의해 내부적으로 호출됨)에서 흥미로운 코드 경로를 빠르게 발견했습니다:


    1028 struct malloc_chunk 1029 { 1030 INTERNAL_SIZE_T prev_size; /* 이전 청크의 크기 (비어 있으면). / 1031 INTERNAL_SIZE_T size; / 오버헤드를 포함한 바이트 단위 크기. / 1032 struct malloc_chunk fd; /* 이중 연결 -- 비어 있을 때만 사용. / 1033 struct malloc_chunk bk; 1034 };

    2516 #define unlink(P, BK, FD)
    2517 {
    2518 BK = P->bk;
    2519 FD = P->fd;
    2520 FD->bk = BK;
    2521 BK->fd = FD;
    2522 } \

    3160 chunk_free(arena ar_ptr, mchunkptr p) .... 3164 { 3165 INTERNAL_SIZE_T hd = p->size; / 헤드 필드 / .... 3177 sz = hd & ~PREV_INUSE; 3178 next = chunk_at_offset(p, sz); 3179 nextsz = chunksize(next); .... 3230 if (!(inuse_bit_at_offset(next, nextsz))) / 앞으로 통합 / 3231 { .... 3241 unlink(next, bck, fwd); .... 3244 } 3245 else 3246 set_head(next, nextsz); / inuse 비트 지우기 */ .... 3251 frontlink(ar_ptr, p, sz, idx, bck, fwd);

    이 코드 경로를 악용하기 위해, 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() 호출 시 원격 코드 실행이 이루어집니다.


    실제

    root@kitploit:~
    이제 그들이 장악하고 있고 완전히 통제하고 있어
        -- The Interrupters, "Liberty"
    

    sshd에 대해 이 공격을 수행하기 위해, 우리는 sshd의 DSA 공개 키 파싱 코드 내에서 free() 호출(즉, 아래의 144줄이 우리의 free(chunk_Y))을 중단시키고, packet_close()의 free() 호출 중 하나(즉, 위의 341-344줄 중 하나가 우리의 free(chunk_X))에서 이를 악용합니다:


    136 buffer_get_bignum2(Buffer *buffer, BIGNUM *value) 137 { 138 u_int len; 139 u_char *bin = buffer_get_string(buffer, &len); ... 143 BN_bin2bn(bin, len, value); 144 xfree(bin); 145 }

    하지만 처음에는 이 레이스 조건(즉, 144줄의 free() 호출을 적절한 시점에 중단시키는 것)을 절대 이길 수 없었습니다. 결국, 우리는 이 레이스에서 이길 가능성을 크게 향상시킬 수 있다는 것을 깨달았습니다. DSA 공개 키 파싱 코드는 free()를 네 번 호출할 수 있게 해줍니다(아래 704-707줄). 게다가 sshd는 사용자 인증을 여섯 번 시도할 수 있게 해줍니다(AUTH_FAIL_MAX). 이 24개의 free() 호출 중 하나라도 적절한 시점에 중단되면, 이후 SIGALRM 핸들러 내에서 원격 코드 실행을 달성합니다.


    678 key_from_blob(u_char *blob, int blen) 679 { ... 693 switch (type) { ... 702 case KEY_DSA: 703 key = key_new(type); 704 buffer_get_bignum2(&b, key->dsa->p); 705 buffer_get_bignum2(&b, key->dsa->q); 706 buffer_get_bignum2(&b, key->dsa->g); 707 buffer_get_bignum2(&b, key->dsa->pub_key);

    이 개선으로 우리는 약 1개월 만에 마침내 레이스 조건을 이겼습니다. 기뻤고(루트 쉘 댄스를 추었습니다), 하지만 여전히 개선의 여지가 있다고 느꼈습니다.


    타이밍

    root@kitploit:~
    걱정 마, 기다리면 보일 거야
        -- 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주일이 걸립니다.

    ======================================================================== SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3 (Ubuntu 6.06.1, 2006)


    이론, 첫 번째 접근

    root@kitploit:~
    해가 뜨기 시작할 때 잠이 들어
        -- 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):


    104 void _pam_free_data(pam_handle_t *pamh, int status) 105 { 106 struct pam_data *last; 107 struct pam_data *data; ... 112 data = pamh->data; 113 114 while (data) { 115 last = data; 116 data = data->next; 117 if (last->cleanup) { 118 last->cleanup(pamh, last->data, status);

    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.


    Theory, take two

    root@kitploit:~
    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):


    202 static pam_handle_t *sshpam_handle = NULL;

    584 sshpam_init(Authctxt *authctxt) 585 { ... 600 sshpam_err = 601 pam_start(SSHD_PAM_SERVICE, user, &store_conv, &sshpam_handle);

    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.


    18 int pam_start ( .. 22 pam_handle_t **pamh) 23 { .. 32 if ((*pamh = calloc(1, sizeof(**pamh))) == NULL) { ... 110 if ( _pam_init_handlers(*pamh) != PAM_SUCCESS ) {

    319 int _pam_init_handlers(pam_handle_t *pamh) 320 { ... 398 retval = _pam_parse_conf_file(pamh, f, pamh->service_name, PAM_T_ANY

    66 static int _pam_parse_conf_file(pam_handle_t *pamh, FILE *f .. 73 { ... 252 res = _pam_add_handler(pamh, must_fail, other

    581 int _pam_add_handler(pam_handle_t *pamh ... 585 { ... 755 the_handlers = (other) ? &pamh->handlers.other : &pamh->handlers.conf; ... 767 handler_p = &the_handlers->authenticate; ... 874 if ((*handler_p = malloc(sizeof(struct handler))) == NULL) { ... 886 (*handler_p)->next = NULL;

    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:


    11 int pam_end(pam_handle_t *pamh, int pam_status) 12 { .. 31 if ((ret = _pam_free_handlers(pamh)) != PAM_SUCCESS) {

    925 int _pam_free_handlers(pam_handle_t *pamh) 926 { ... 954 _pam_free_handlers_aux(&(pamh->handlers.conf.authenticate));

    1009 void _pam_free_handlers_aux(struct handler **hp) 1010 { 1011 struct handler *h = *hp; 1012 struct handler last; .... 1015 while (h) { 1016 last = h; 1017 _pam_drop(h->argv); / This is all alocated in a single chunk */ 1018 h = h->next; 1019 memset(last, 0, sizeof(*last)); 1020 free(last); 1021 }

    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:

    https://seclists.org/bugtraq/2005/Oct/118


    Practice

    root@kitploit:~
    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.


    Timing

    root@kitploit:~
    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.

    ======================================================================== SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2 (Debian 12.5.0, from 2024)


    Theory

    root@kitploit:~
    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():


    358 grace_alarm_handler(int sig) 359 { ... 370 sigdie("Timeout before authentication for %s port %d", 371 ssh_remote_ipaddr(the_active_state), 372 ssh_remote_port(the_active_state));

    96 #define sigdie(...) sshsigdie(FILE, func, LINE, 0, SYSLOG_LEVEL_ERROR, NULL, VA_ARGS)

    451 sshsigdie(const char *file, const char *func, int line, int showfunc, 452 LogLevel level, const char *suffix, const char *fmt, ...) 453 { ... 457 sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL, 458 suffix, fmt, args);

    464 sshlogv(const char *file, const char *func, int line, int showfunc, 465 LogLevel level, const char *suffix, const char *fmt, va_list args) 466 { ... 489 do_log(level, forced, suffix, fmt2, args);

    337 do_log(LogLevel level, int force, const char *suffix, const char *fmt, 338 va_list args) 339 { ... 419 syslog(pri, "%.500s", fmtbuf);

    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?

    • Luckily for us attackers, the answer to our first question is yes; if, and only if, the syslog() inside the SIGALRM handler is the very first call to syslog(), then __localtime64_r() (which is called by syslog()) calls malloc(304) to allocate a FILE structure (at line 166) and calls malloc(4096) to allocate an internal read buffer (at line 186):

    28 __localtime64_r (const __time64_t *t, struct tm *tp) 29 { 30 return __tz_convert (*t, 1, tp);

    567 __tz_convert (__time64_t timer, int use_localtime, struct tm *tp) 568 { ... 577 tzset_internal (tp == &_tmbuf && use_localtime);

    367 tzset_internal (int always) 368 { ... 405 __tzfile_read (tz, 0, NULL);

    105 __tzfile_read (const char *file, size_t extra, char **extrap) 106 { ... 109 FILE *f; ... 166 f = fopen (file, "rce"); ... 186 if (__builtin_expect (__fread_unlocked ((void *) &tzhead, sizeof (tzhead), 187 1, f) != 1, 0)

    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):


    1449 #define set_head(p, s) ((p)->mchunk_size = (s))

    3765 _int_malloc (mstate av, size_t bytes) 3766 { .... 3798 nb = checked_request2size (bytes); .... 4295 size = chunksize (victim); .... 4300 remainder_size = size - nb; .... 4316 remainder = chunk_at_offset (victim, nb); .... 4320 bck = unsorted_chunks (av); 4321 fwd = bck->fd; .... 4324 remainder->bk = bck; 4325 remainder->fd = fwd; 4326 bck->fd = remainder; 4327 fwd->bk = remainder; .... 4337 set_head (victim, nb | PREV_INUSE | 4338 (av != &main_arena ? NON_MAIN_ARENA : 0)); 4339 set_head (remainder, remainder_size | PREV_INUSE); .... 4343 void *p = chunk2mem (victim); .... 4345 return p;

    • 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().


    Practice

    root@kitploit:~
    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):

    ---|----------------------------------------------|---|------------|---

    XXXlarge holeXXXsmall holeXXX
    ~8KB320B
    • shortly before sshd receives the SIGALRM, we malloc()ate a ~4KB chunk that splits the large ~8KB hole into two smaller chunks:

    ---|-----------------------|----------------------|---|------------|---

    XXXlarge allocated chunkfree remainder chunkXXXsmall holeXXX
    ~4KB~4KB320B
    • 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 버전에 대한 연구에서 우리는 큰 경합 윈도우에 작은 경합 윈도우가 하나만 있으면 이 시그널 핸들러 경합 조건에서 절대 이길 수 없다는 것을 배웠습니다. 따라서 우리는 다음과 같은 힙 레이아웃을 기반으로 한 전략을 구현했습니다:

    ---|------------|---|------------|---|------------|---|------------|---

    XXXlarge hole 1XXXsmall hole 1XXXlarge hole 2XXXsmall hole 2...
    ~8KB320B~8KB320B

    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의 공개 키 구문 분석 코드를 악용하여 임의의 malloc() 및 free() 호출 시퀀스를 수행합니다 (라인 1805 및 573):

    1754 cert_parse(struct sshbuf *b, struct sshkey *key, struct sshbuf *certbuf) 1755 { .... 1797 while (sshbuf_len(principals) > 0) { .... 1805 if ((ret = sshbuf_get_cstring(principals, &principal, .... 1820 key->cert->principals[key->cert->nprincipals++] = principal; 1821 }

    562 cert_free(struct sshkey_cert *cert) 563 { ... 572 for (i = 0; i < cert->nprincipals; i++) 573 free(cert->principals[i]);

    • 우리는 작은 "barrier" 청크에 대한 메모리 누수를 찾을 수 없었습니다. 대신, tcache 청크 (inuse 비트가 절대 지워지지 않아 실제로 해제되지 않음)를 임시 "barrier" 청크로 사용합니다.

    이 힙 레이아웃을 안정적으로 달성하기 위해, 우리는 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에 매핑된다고 가정하기 때문에 우리에게 알려져 있습니다).


    Timing

    root@kitploit:~
    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이 전달되기 전에 그곳에서 구문 분석을 시작할 시간을 확보하기 위해).


    88 userauth_pubkey(struct ssh *ssh, const char method) 89 { ... 138 if (pktype == KEY_UNSPEC) { 139 / this is perfectly legal */ 140 verbose_f("unsupported public key algorithm: %s", pkalg); 141 goto done; 142 } 143 if ((r = sshkey_from_blob(pkblob, blen, &key)) != 0) { 144 error_fr(r, "parse key"); 145 goto done; 146 } ... 151 if (key->type != pktype) { 152 error_f("type mismatch for decoded key " 153 "(received %d, expected %d)", key->type, pktype); 154 goto done; 155 }

    이 전략 변경으로 경합 조건을 이기기 위해 평균 약 10,000번의 시도가 필요합니다. 즉, 120초(LoginGraceTime)당 100개의 연결(MaxStartups)이 허용되는 경우, 경합 조건을 이기기 위해 평균 약 3-4시간이 걸리고, ASLR로 인해 원격 루트 셸을 얻는 데 약 6-8시간이 걸립니다.

    ======================================================================== amd64 익스플로잇을 향해

    root@kitploit:~
    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

    ======================================================================== 패치 및 완화

    root@kitploit:~
    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의 리스너 프로세스로 이동시켜 동기적으로 처리할 수 있게 했습니다:

    https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29

    이 수정 사항은 대규모 커밋(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;

    root@kitploit:~
        va_start(args, fmt);
        sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
            suffix, fmt, args);
        va_end(args);
    

    #endif _exit(1); }

    마지막으로, sshd를 업데이트하거나 재컴파일할 수 없는 경우, 이 시그널 핸들러 경합 조건은 설정 파일에서 LoginGraceTime을 0으로 설정하여 수정할 수 있습니다. 이렇게 하면 sshd가 서비스 거부 (모든 MaxStartups 연결의 소진)에 취약해지지만, 이 권고에서 제시된 원격 코드 실행으로부터 안전해집니다.

    ======================================================================== 감사의 말

    OpenSSH 개발자들의 뛰어난 작업과 이번 릴리스에 대한 긴밀한 협력에 감사드립니다. 또한 distros@openwall에도 감사드립니다. 마지막으로, 이 권고를 Sophia d'Antoine에게 바칩니다.

    ======================================================================== 타임라인

    2024-05-19: OpenSSH 개발자에게 연락했습니다. 패치와 패치 검토의 연속적인 반복이 이어졌습니다.

    2024-06-20: distros@openwall에 연락했습니다.

    2024-07-01: 조정된 릴리스 날짜.

    도구 다운로드