Alpine Linux SSH 버그: 10년간 숨은 별표 하나
Alpine Linux SSH 버그로 257자 이상의 아무 비밀번호나 10년간 잠긴 계정에 통했습니다. musl crypt()의 동작이 원인입니다.

여기 신경 쓰여야 할 숫자가 하나 있습니다: 257. 약 10년 동안 잘못된 종류의 Linux 시스템에서 SSH 프롬프트에 257자 이상의 비밀번호를 입력하면 거부되는 게 아니라 로그인이 됐습니다. 아무 비밀번호나요. 아무렇게나 두드린 키보드 입력, 휴가 사진을 base64로 덤프한 문자열, 실수로 붙여넣은 Slack 메시지 반쪽까지, 길이만 충분하면 문이 열렸습니다. "sshole"이라는 이름으로 공개된 이 Alpine Linux SSH 버그는 조건을 확인하기 전까지는 장난처럼 보이지만, 확인하는 순간 사고 보고서처럼 읽힙니다.
요약하면 이렇습니다. musl libc에 맞춰 빌드된 OpenSSH에서는 (Alpine, OpenWrt, Buildroot, Yocto, 그리고 인터넷에 있는 엄청난 수의 Docker 베이스 이미지가 여기에 해당합니다) 원래 로그인이 수학적으로 불가능해야 할 계정이 257자에서 1,024자 사이의 아무 비밀번호나 받아들였습니다. root, 키 인증 전용으로 만든 배포 사용자, 그리고 Alpine 공식 AWS·Azure 이미지에 기본으로 들어 있고 비밀번호 없이 doas가 가능한 alpine 사용자까지 포함됩니다. 수정은 OpenSSH 10.6p2에 들어갔습니다. 하지만 이 문제가 어떻게 생겼는지는 1980년까지 거슬러 올라가는, 각각 합리적이었던 작은 결정들의 연쇄입니다. 그리고 그 연결고리 하나하나가 당시에는 전부 옳아 보였습니다.
Alpine Linux SSH 버그의 실제 작동 원리
이 충돌을 이해하려면 세 가지 조각이 필요합니다. 각각 다른 코드베이스와 다른 10년대에 속해 있습니다. 첫째, Linux가 비밀번호를 저장하는 방식입니다. 비밀번호를 설정하면 해시는 계정당 한 줄씩 /etc/shadow에 저장됩니다. 일반 계정의 줄은 $6$salt$hash처럼 생겼는데, 여기서 $6$은 sha512crypt를 뜻합니다. 하지만 데몬, 키 인증 전용 사용자, 잠긴 root처럼 절대 비밀번호를 받으면 안 되는 계정에는 대신 자리표시자가 들어갑니다:
alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::
로그인할 때 sshd는 입력받은 값을 저장된 salt로 해시하고, 결과를 저장된 필드와 문자열 비교합니다. 같은 문자열이면 로그인됩니다. 별표는 센티넬입니다. 어떤 것의 해시도 아니기 때문에 어떤 비밀번호도 여기에 맞을 수 없습니다. 이것이 초기 Unix 시절부터 키 인증 전용 계정을 잠가 온 방식입니다. 비교가 절대 성공하지 않으니 잘 작동합니다. 다만 해시 함수 자체가 별표를 만들어낼 수 있다면 예외입니다. 그것이 두 번째 조각으로 이어집니다.
두 번째 조각은 musl의 에러 처리입니다. 전통적으로 crypt()는 제 역할을 못 할 때 NULL을 반환합니다. 예를 들어 salt가 시스템에 없는 알고리즘을 요구하는 경우입니다. 문제는 수십 년 동안 NULL을 확인하지 않고 그대로 strcmp에 넘겨 세그폴트를 내는 소프트웨어가 많았다는 점입니다. 그래서 2012년에 musl은 의도적인 설계 선택을 했습니다. 자사의 crypt()는 절대 NULL을 반환하지 않고, 실패하면 어떤 실제 비밀번호 해시와도 일치하지 않는 문자열을 반환하도록 했습니다. 잘못 작성된 호출자가 크래시 대신 잘못된 비밀번호 응답을 보게 하려는 것이었습니다. 실패 문자열로 고른 값이 바로 *입니다. 3주 후 musl은 256바이트 입력 제한이 있는 sha512crypt를 추가했습니다. 해시 비용은 비밀번호 길이에 비례하므로, 아주 긴 비밀번호는 서비스 거부 공격 벡터가 되기 때문입니다. 그보다 긴 입력은 거부하고, 거부는 실패이므로 *를 반환합니다. 이를 설명하는 주석은 그동안 내내 소스 코드에 있었습니다.
세 번째 조각은 sshd의 '예의'입니다. 2016년 CVE-2016-6210은 응답 시간을 재서 유효한 사용자 이름을 열거할 수 있음을 보여 주었습니다. 존재하지 않는 사용자는 해시를 계산할 대상이 없어서 즉시 거부됐기 때문입니다. 해결책은 우아했습니다. 사용 가능한 해시가 없는 계정이라면 sshd가 다른 계정의 salt를 빌려와서, 입력된 비밀번호를 시간만 맞추기 위해 어쨌든 해시합니다. 느린 거부인 셈입니다. 문제는 이 경로가 존재하지 않는 사용자에게만 작동하지 않는다는 점입니다. shadow 필드가 *인 실제 사용자에게도 작동합니다. 별표는 사용 가능한 해시가 아니기 때문입니다. 그래서 이제 sshd는 crypt(your_password, borrowed_salt)를 계산하고 그 결과를 *와 비교합니다.
/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user); /* "*" for locked accounts */
const char *salt = pick_salt(user); /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt); /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login(); /* "*" == "*" → welcome in */
모든 요소가 맞아떨어져야 합니다. 저장된 필드는 *이고, crypt는 실패하며, 실패 값도 *입니다. glibc에서는 실패한 crypt가 NULL을 반환하므로 비교가 안전하게 실패합니다. libxcrypt 시스템에서는 실패 문자열이 *0입니다. 글자 하나 차이이고, 그 한 글자가 전부입니다. *0은 절대 *와 같지 않기 때문입니다. musl에서는 에러 값과 잠금 값이 같은 문자이고, strcmp는 에러와 일치를 구별할 방법이 없습니다. 하나의 신호에 두 가지 의미가 있고, 받는 쪽은 둘을 구분할 수 없습니다.
10년간의 합리적인 결정들
이 버그가 연구할 가치가 있는 이유는 메커니즘이 아니라, 관련된 누구도 명백하게 어리석은 일을 하지 않았다는 점입니다. 1980년으로 가 봅시다. 비밀번호 해시가 누구나 읽을 수 있는 /etc/passwd에 있던 시절, Bill Joy는 아무도 로그인하면 안 되는 daemon이라는 계정을 만들고 비밀번호 해시 자리에 *를 넣었습니다. 수십 년 앞선 깔끔한 센티넬이었습니다. (실명 필드에는 "the devil himself"라고 적혀 있었다고 전해집니다.) Unix는 지금도 계정을 정확히 이 방식으로 잠급니다.
musl의 2012년 결정도 어느 각도에서 봐도 방어할 만합니다. 호출자가 크래시를 내는 것은 실제 문제이고, 수십 년간의 허술한 애플리케이션 코드에 NULL로 맞서는 C 라이브러리는 적을 많이 만듭니다. 절대 일치하지 않는 문자열을 반환하는 것은 우아한 성능 저하입니다. 해시 기반 서비스 거부를 막으려고 비밀번호 길이를 제한한 것도 마찬가지입니다. sshd의 타이밍 수정도 마찬가지입니다. 해시할 수 없는 사용자에게 응답 시간을 맞춰 채우는 것은 표준적인 관행입니다. Alpine이 그다음에 한 일도 그렇습니다. 2019년 CVE-2019-5021은 Alpine Docker 이미지가 root 비밀번호 필드를 비운 채 배포했음을 드러냈습니다. 일부 설정에서는 비밀번호 자체가 없었습니다. Alpine은 root를 !로 잠갔습니다. 그런데 PAM이 없으면 sshd는 !를 완전한 잠금으로 취급하고, 그러면 SSH 키 로그인까지 막힙니다. 클라우드 이미지는 키 로그인으로 살고 죽기 때문에 이는 문제였습니다. 그래서 2022년 Alpine 3.16은 root를 *로 옮겼습니다. 여전히 잠겨 있고, 키는 작동하고, 모두가 만족했습니다. 다만 *는 바로 musl의 crypt가 출력하도록 속을 수 있는 그 문자열이었습니다.
모든 단계는 실제 문제에 대한 방어 가능한 대응이었습니다. 그래서 불편합니다. 각 결정의 자리에 있었다면 여러분도 고개를 끄덕였을 겁니다.
아슬아슬하게 빗나간 사례들은 오히려 더 나쁩니다. 어떤 Debian 개발자는 glibc가 musl의 정확한 * 동작을 채택하도록 요청했습니다. 6년 뒤 Debian은 libxcrypt로 옮겨 갔고, 그 *0 덕분에 우연히 구멍이 막혔습니다. BusyBox 스레드에서는 257자 비밀번호에서 passwd가 막힌다고 불평했습니다. 같은 별표, 같은 제한인데 그 스레드의 누구도 "sshd"라고 말하지 않았습니다. 한 보안 검토자는 sshd의 pick_salt() 함수를 패치하면서, 실제 버그가 그 바로 네 줄 아래에 있었는데 4일 뒤에야 누군가 찾아냈습니다. 이 문제는 사람들이 거의 정면으로 바라보면서도 반복해서 지나쳤습니다.

musl OpenSSH 버그가 여러분의 인프라에 영향을 주나요?
sshole의 다행스러운 점은 피해를 주려면 모든 조건이 갖춰져야 한다는 것입니다. 하나라도 빠지면 안전합니다. 다음이 모두 참이면 노출된 것입니다:
- sshd가 musl로 빌드되어 있습니다: Alpine, OpenWrt, Buildroot, Yocto, 또는
openssh-server가 설치된alpine기반 Docker 이미지. - sshd가 PAM을 거치지 않습니다. Alpine의 openssh-server는 기본적으로 그렇고, 또는
UsePAM no로 설정했을 수 있습니다. PAM 코드 경로는*계정에 대해 crypt를 호출하지 않습니다. - 비밀번호 인증이 활성화되어 있습니다. 기본값이 그렇습니다.
- 최소 하나의 계정이
/etc/shadow에 맨*를 가지고 있습니다: 키 인증 전용 사용자, LDAP 기반 계정, Alpine의 root. - 최소 하나의 다른 계정이 실제 비밀번호 해시를 가지고 있어서, 빌려 쓸 salt가 있습니다.
다섯 가지 조건을 모두 충족하면 그 장비의 모든 * 계정이 257자에서 1,024자 사이의 아무 비밀번호나 받아들입니다. 최악의 설정은 Alpine 공식 AWS·Azure 이미지입니다. 기본 alpine 사용자는 키 인증 전용(즉 *)이고 비밀번호 없이 doas가 됩니다. 거기서 긴 비밀번호로 로그인하는 것은 권한 상승이 아니라 root로 바로 통하는 정문입니다. 이 문제가 그렇게 오래 조용했던 데에는 공을 인정받을 만한 완화 요인이 하나 있습니다. OpenSSH의 PermitRootLogin은 2015년부터 root의 비밀번호 로그인을 거부해 왔습니다. 그래서 가장 뻔한 표적은 기본값으로 어느 정도 보호받았습니다. 배포 사용자와 서비스 계정을 막아 주는 것은 아무것도 없었습니다.
Debian, Ubuntu, Fedora 등 glibc 기반이라면 괜찮습니다. PAM을 거치는 시스템도 괜찮습니다. Dropbear도 괜찮습니다. OpenBSD도 괜찮습니다. 회의적인 분들을 위한 주의사항 하나: 초기에 커뮤니티가 재현을 시도했을 때 결과가 일관되지 않았습니다. 다섯 가지 조건은 하나씩 놓치기 쉽기 때문입니다. 누군가 공식 Alpine 이미지를 시험해 보고, root 필드가 *가 아니라 !라는 것을 깨닫지 못해 실패하고 넘어갑니다. "내 노트북에서는 재현이 안 됐다"는 말은 프로덕션에 대한 증거가 전혀 아닙니다. 분위기가 아니라 조건을 확인하세요.
진짜 근본 원인: 대역 내 시그널링과 매직 문자열
sshole을 "이상한 libc 엣지 케이스"로 분류하고 넘어가고 싶은 유혹이 듭니다. 하지만 실패 모드는 그보다 더 오래되고 일반적입니다. 바로 대역 내 시그널링(in-band signaling)입니다. * 문자는 두 가지 역할을 맡았습니다. /etc/shadow에서는 "이 계정에 비밀번호가 없다"는 뜻이고, musl의 crypt에서는 "해싱이 실패했다"는 뜻입니다. 하나의 채널을 공유하는 두 메시지인데, 받는 쪽은 둘을 구별할 방법이 없습니다. 일반 데이터에 박힌 센티넬 값은 시스템이 스스로를 공격하게 만드는 방식입니다. NULL은 받아들일 만한 에러 신호였습니다(크래시를 잘 내는 호출자는 제쳐 두고요). 예외가 더 낫고, 호출자가 실패를 명시적으로 처리하도록 강제하는 Result<T, E>가 최선입니다. 모든 시스템 프로그래머의 몸에 새기고 싶은 실용적 규칙은 이것입니다. 매직 문자열은 쓰지 말 것. 꼭 필요하다면 문제 도메인의 어떤 값과도 충돌할 수 없는 것을 고르세요. libxcrypt의 *0은 musl의 *보다 정확히 1바이트 낫고, 그 1바이트가 단순한 특이 사항과 CVE의 차이입니다.
여기에는 조용한 호환성 셔밍에 관한 두 번째 교훈도 있습니다. musl의 우아한 * 결정은 깨진 소프트웨어가 크래시하는 것을 막기 위한 것인데, 이는 곧 깨진 소프트웨어가 깨졌다는 사실이 드러나지 않게 막는다는 뜻입니다. 세그폴트는 보기 흉하지만 신호이기도 합니다. 누군가 버그를 신고하고, 누군가 호출자를 고칩니다. 에러를 삼키면 실수는 10년 동안 조용히 복리로 불어납니다. 점점 확신하게 되는데, 이것이 시스템 설계에서 가장 깊은 분기점입니다. 재난에 저항하도록 설계된 엔지니어링은 빠르고 크게 실패하는 경향이 있고, 매끄러움을 최적화한 엔지니어링은 숨은 부채를 쌓다가 청구서가 한꺼번에 날아옵니다.
그리고 이것은 한 가지 위안이 되는 신화도 무너뜨려야 합니다. 오래되고 널리 배포된 코드는 실전 검증을 거쳐 더 믿을 만하다는 생각입니다. 나이는 검토를 고르게 나눠 주지 않습니다. 핫 패스에 검토를 집중시키고 콜드 패스는 화석이 되도록 내버려 둡니다. 모두가 암호 코드는 감사합니다. 하지만 sshd가 비밀번호에 관해 libc와 대화하는 지루한 접착 코드는 거의 아무도 감사하지 않습니다. "아주 오래 잘 돌아갔으니까"라는 이유에서입니다. 두 가지 소수 구성, 즉 musl 시스템과 그중에서도 PAM을 쓰지 않는 시스템이 겹치는 지점은, 버그가 10년 동안 아무도 건드리지 않고 살아남기에 딱 맞는 곳입니다.
sshole 버그 수정 방법과 로그 확인하기
패치는 쉬운 부분입니다. OpenSSH 10.6p2가 구멍을 막습니다. Alpine에서는 명령 한 줄이면 됩니다. 오늘 바로 업데이트할 수 없다면 완화책도 똑같이 단순하고 완전히 효과적입니다. 비밀번호 인증을 끄세요. 비밀번호 검사가 없으면 버그도 없습니다. 솔직히 둘 다 하는 게 좋습니다. 키 인증 전용은 이 버그가 생기기 전에도 맞는 답이었고, 모두가 패치한 뒤에도 그럴 것입니다. 이는 다른 패치 대 완화 논쟁에서 본 교훈과 통합니다. 완화책이 저렴하고 완벽하다면, 괜히 영리하게 굴지 마세요.
# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart

그다음 더 어려운 질문입니다. 누군가 먼저 도달했을까요? 10년은 긴 잠복 기간입니다. 찾아야 할 것은 원래 비밀번호가 없던 계정에 대한 비밀번호 인증 성공 기록입니다. 키 인증 전용 배포 사용자, 서비스 계정, LDAP 신원, Alpine 장비의 root가 여기에 해당합니다. 이런 계정이 password 방식으로 성공적으로 로그인한 기록은 극히 드물어야 하고, 한 건이라도 나오면 최고 경보 상황입니다. 불편한 진실은 sshd가 기록하는 것은 성공 여부이지 비밀번호 길이가 아니라서, 직접적인 증거의 상당수가 기록 시점에 사라진다는 점입니다. 그리고 이전에 다뤘듯이 클라우드 인증 로깅에는 좋은 날에도 사각지대가 있으며, 수명이 짧은 VM의 인스턴스 수준 sshd 로그가 바로 그런 곳에 있습니다. 가진 로그를 전부 grep하세요. 불완전하다고 가정하고요.
10년의 조용한 세월이 가르쳐 주는 것
발견 과정 자체도 버그만큼 주목받을 만합니다. sshole을 찾아낸 연구자는 crypto를 들여다본 것이 아니라, OpenSSH가 libc와 대화하는 지루한 경계를 들여다봤습니다. 떠올릴 수 있는 모든 구성(glibc, musl, PAM, PAM 없음, shadow, shadow 없음)으로 sshd를 빌드하고, 약 17,600건의 잘못 구성된 로그인 시도를 그 매트릭스에 퍼부었습니다. 이상한 해시, 깨진 계정, 지나치게 긴 비밀번호였습니다. 모든 빌드가 모든 로그인에 대해 같은 답을 냈고, 딱 하나만 예외였습니다. PAM 없는 musl입니다. 그 빌드만 하지 말아야 할 것들에 "예"라고 답했습니다. 이건 운 좋은 발견이 아니라 커버리지입니다. 설정 매트릭스에 걸친 차등 테스트는 교차점에 숨은 버그를 찾는 방법이며, 바로 에이전트 군집이 놀라울 만큼 잘하는 종류의 철저하고 지치지 않는 작업입니다. 다음 sshole은 10년의 조용한 세월을 얻지 못할 것입니다.
운영자를 밤새 깨어 있게 만들어야 할 열린 질문은, 10년 동안 아무도 눈치채지 못했는지, 아니면 엉뚱한 사람들이 눈치챘는지입니다. 우발적인 경우만 생각해 봐도 그렇습니다. 비밀번호 프롬프트에 잘못된 클립보드 내용을 붙여 넣으면, Slack 대화 반쪽이든 SSH 개인 키 전체든, 257자를 넘는 무엇이든 취약한 장비에서는 잠긴 계정이 그냥 열립니다. 비밀번호 입력란에 붙여넣기를 잘못 해 본 적이 없다면, 제가 함께 일해 본 누구보다도 신중한 사람입니다. 누군가 어딘가에서, 들어가려던 게 아닌 장비에 로그인했다가 조용히 터미널을 닫았을 것입니다. 어쩌면 여러 명일지도 모릅니다.
그러니 패치하세요. 비밀번호 인증은 반드시 끄세요. 그리고 한 시간을 내어 여러분의 시스템에서 두 가지 역할을 하고 있는 문자들을 찾아보세요. 센티넬 값, 조용한 폴백, "이런 일은 절대 일어날 수 없다"는 분기, 누가 썼는지 기억도 못 하는 호환성 셔밍 같은 것들입니다. 성숙한 코드베이스에는 어디에나 있습니다. 이것들이 눈에 띄지 않는 이유는 누구도 볼 수 있는 문제를 한 번도 일으키지 않았기 때문입니다. 지금 여러분의 스택 어딘가에는 두 가지 뜻을 동시에 조용히 품은 별표가 있습니다. 아주 긴 비밀번호를 든 누군가가 찾기 전에 먼저 찾아내세요.


