요약
musl 기반 리눅스의 OpenSSH가 2016년부터 키 전용 계정에 아주 긴 아무 비밀번호로나 로그인을 허용해 왔습니다.
한 보안 연구자가 sshole이라는 이름으로 공개한 취약점입니다. 알파인 리눅스처럼 musl libc(가벼운 C 표준 라이브러리)를 쓰는 시스템에서 PAM(리눅스 인증 모듈)을 쓰지 않는 sshd가 대상입니다. OpenSSH 10.6p2에서 고쳐졌습니다.
왜 중요한가
- 알파인 공식 AWS·애저 이미지는 기본 사용자 alpine에 비밀번호 없는 doas(sudo 대체 도구)를 줘서 곧바로 root 권한까지 넘어갑니다.
- root, LDAP 사용자, 배포용 계정처럼 일부러 비밀번호를 잠가 둔 계정이 오히려 뚫리는 구조입니다.
- 1980년 BSD 관례, 2012년 musl 변경, 2016년 OpenSSH 보안 수정처럼 따로 내린 결정이 맞물려 생긴 구멍입니다.
핵심 내용
- musl의 crypt()는 실패하면 NULL 대신
*를 돌려주고, SHA-512 방식은 256바이트를 넘는 비밀번호를 실패로 처리합니다. - OpenSSH는 응답 시간으로 계정 존재를 캐내는 공격을 막으려고
*계정에도 다른 계정의 솔트(해시용 무작위 값)를 씁니다. - 그래서 257~1,024자 비밀번호로 crypt()가 실패해 나온
*가 섀도 파일의*와 같아져 로그인이 통과됩니다. - 작성자는 빌드 조합별로 로그인 약 1만 7,600건을 시험해 musl에 PAM이 없는 조합에서만 결과가 어긋난다는 것을 확인했습니다.
- glibc, PAM, Dropbear, OpenBSD는 영향이 없습니다. 당장 업데이트할 수 없으면 비밀번호 로그인을 끄라고 권했습니다.
HN 반응
- 일부 이용자는 명세를 어기고 오류 대신
*를 돌려준 musl을 탓했고, 해시가 짧다는 걸 알아채고도 잊어버리는 OpenSSH 코드가 문제라는 지적도 나왔습니다. - crypt() API가 낡았다는 데는 공감이 컸지만, Result 같은 타입이 막았을 것이라는 주장과 하위 호환을 지키려면 Rust에서도 같은 실수가 나온다는 반론이 맞섰습니다.
좋습니다, 이 설정에서 무슨 일이 벌어지는지 이해했습니다.
공격자 입장에서 목표는 OpenSSH의 sshd가 호출할 때
$ret = crypt($pw, $hash); $ret == $hash가 참이 되게 만드는 것입니다. sshd는 이 비교로 로그인하려는 사용자가 계정 비밀번호를 알고 있는지 확인합니다.$hash는 crypt 호출에 넘기는 "설정값"으로, 비밀번호가 설정된 사용자라면 보통 "$6$--알아볼 수 없는 문자열" 꼴이고, 로그인이 되지 않아야 하는 사용자라면 "*"나 "!" 꼴일 수 있습니다.이 버그는 아래 요소들이 겹쳐서 생깁니다.
musl은 오류가 났을 때 반환값을 NULL로 두지 않고 "*"를 반환할 수 있습니다. 다른 libc도 비슷한 방식을 택했지만 "*0"이라는 다른 결과를 쓰는 듯합니다.
musl은 오류 시 "+"를 반환할 수도 있는데,
$hash가 "*"로 시작할 때만 그렇게 합니다. 오류 때문에$ret == $hash가 참이 되는 일을 막으려는 것인데,crypt()를 호출하는 애플리케이션이 바로 뒤에 이어질$ret == $hash비교에 맞는$pw와$hash값을 넘긴다고 가정한 설계입니다.OpenSSH는 사용자 목록을 알아내는 타이밍 공격을 피하려고,
$hash가 "*"나 "!"일 때 다른 사용자의 해시를 대신 넘기도록 되어 있습니다. 그래서$pw와$hash가 서로 다른 사용자의 것으로 설정됩니다. 이때$hash는 진짜 "$6$--알아볼 수 없는 문자열" 꼴의 해시이고, OpenSSH는crypt()가 NULL이나 해시 문자열을 반환하리라 기대합니다. 그런데 이제 OpenSSH가 쓰는 해시가 둘이 됩니다. 실제로는$ret = crypt($pw, $hash2); $ret == $hash1을 실행하는 셈입니다.보통은 OpenSSH가 해싱 오류를 일으키지 않을 유효한
$pw와$hash2를 넘기므로 문제가 없어야 합니다. musl이 만들어 내는$ret은 진짜 해시일 것이고, 로그인이 막힌 사용자의 "*"나 "!"인$hash1과 같을 수 없습니다. 하지만 musl은 DDoS 완화책으로 SHA-2 기반 해시(기본값인 듯합니다)에서 비밀번호가 너무 길면 오류를 반환합니다.이 오류는 NULL이 아닌 ""나 "+"라는 결과로 나옵니다. OpenSSH가 진짜 해시(
$hash2)를 넘겼으므로 musl은 ""를 반환하고, 이 값이 로그인이 막힌 사용자의 실제 해시$hash1과 같다고 판정됩니다.OpenSSL 쪽 수정은 crypt()가 어떻게든 성공하더라도, 사용자를 로그인시키기 전에 해시의 최소 길이를 보장하게 합니다.
여기서 끌어낼 수 있는 결론은 crypt()의 동작이 달 보고 울부짖는 수준으로 미쳤다는 거고, 이 세기에 만드는 시스템이라면 더 이상 쓰면 안 된다는 거예요. 이런 엣지 케이스를 전부 바로잡고 정규화하는 대대적인 래퍼를 씌우지 않는 한은요.
결론은 타입 안전성이 중요하다는 것입니다.
이 경우에는 타입 안전합니다. C 표준에서 NULL은 어떤 객체도 가리키지 않습니다. 즉 C의 모든 포인터는 사실상 옵션 타입이고, 포인터를 반환하는 함수가 "결과 없음"을 뜻하려고 NULL을 반환할 수 있다는 뜻입니다. crypt()에는 그것으로 충분합니다.
안전하지 않은 쪽은 반대 경우입니다. 반환값이 NULL일 수 없는데도 포인터를 반환할 때요. C++가 참조를 도입한 것도 이 문제를 고쳐 보려는 시도였습니다.
C는 안전성보다 성능을 우선하는 경향이 있어서 NULL 포인터 역참조는 정의되지 않은 동작으로 취급되고, 컴파일러는 코드가 더 빨라진다면 콧구멍에서 악마를 날려도 됩니다. 하지만 적절한 옵션을 주면 검사를 켜서 다른 언어라면 패닉 핸들러라고 부를 것을 호출하게 할 수 있고, 유닉스에서는 그게 SIGSEGV 시그널입니다. 그러면 디버거를 붙이거나 코어 파일을 읽어서 무엇이 잘못됐는지 볼 수 있습니다.
참고로 문자열용 "char *"를 말하는 게 아닙니다. 그건 안전하지 않지만 NULL은 그렇지 않습니다. NULL은 옵션 타입, 결과 타입, 오류 유니온의 특수한 경우일 뿐입니다. 최신 언어들은 이 개념을 포인터가 아닌 객체로, 그리고 오류 종류가 둘 이상인 경우로 넓혔을 뿐입니다.
문제는 바로 그 암묵성입니다. 이 취약점을 낳은 우회책은 사람들이 crypt()가 절대 null을 반환하지 않으리라고 가정했기 때문에 만들어진 겁니다.
대단한 결과 타입도 필요 없습니다. 단순한 Option<String>만 있었어도 이 함수를 쓰는 쪽이 실패 경우를 고려할 수밖에 없었을 것이고, 애초에 이런 상황을 피할 수 있었을 겁니다.
.unwrap()
그래서요?
첫째, 프로덕션 코드에 unwrap이 있으면 안 되고 CI나 빌드 과정 같은 데서 그걸 검사할 수 있습니다. 둘째, 그래도 이제는 SSH 서버가 죽는 겁니다. 뜬금없이 root로 로그인되는 것보다는 훨씬 낫다고 할 수 있죠.
이론상으로는 뉴턴과 파운드힘을
float로 표현하는 것도 타입 안전합니다. 하지만 실제로는 사람의 실수 때문에 수백만 달러짜리 NASA 우주선이 망가졌습니다.NASA가
pub struct Newtons(f32); pub struct PoundsForce(f32);같은 걸 썼다면, 그러니까 Newton과 PoundsForce 타입을 서로 바꿔 쓸 수 없게 했다면 그런 일은 없었을 겁니다[1].[1] 그 이야기가 이보다 좀 더 복잡하다는 건 압니다. CSV 파일이 얽혀 있었고(이것도 타입 안전하지 않죠), 그 상황에서는 포맷, 업체, 코드베이스를 가로질러 타입 안전성을 지키려면 더 많은 조치가 필요했을 겁니다. 그래도 제 요지는 변함없습니다.
말씀하신 단서와 관련 있는 글이 있습니다.
https://lexi-lambda.github.io/blog/2020/11/01/names-are-not-type-safety/
맞습니다. C로 프로그래밍하는 사람이라면 포인터를 반환하는 모든 함수가 (문서에 없더라도) NULL을 반환할 수 있고, 그래서 모든 포인터를 검사해야 한다는 걸 압니다.
다만 저라면 이런 API가 더 낫다고 보고 이렇게 썼을 겁니다.
그러면 함수 내부에서 메모리를 할당할 필요도 없습니다. 저는 주로 임베디드 프로그래머라서 그런 방식은 정말 싫습니다. 게다가 오류 enum을 반환하면 errno를 뒤져 볼 필요 없이 한눈에 분명해집니다.
하지만 POSIX API는 잘 설계된 물건이 아닙니다. 80년대로 거슬러 올라가니까요. 아마 오늘날 만든다면 완전히 다르게 썼을 겁니다.
제 생각과 똑같네요.
"유효하지 않은 상태는 표현할 수 없게 만들라"는 격언은 기본적인 시스템을 설계할 때도 아주 유용한데, 서버를 지키는 관문이라면 더 말할 것도 없죠.
제 결론은, 오류를 처리하지 않는 버그 많은 코드를 봐주려고 crypt가 NULL을 반환하지 않는 오랜 관행을 만든 게 나쁜 생각이었다는 것입니다.
crypt()를 잘못 호출하는 코드를 위해 우회책을 제공하려는 발상이 저는 무섭습니다. printf()를 잘못 쓰는 코드의 길을 닦아 주는 거라면야 얼마든지 하세요. 하지만 crypt()를 조금 틀리게 써도 괜찮다고 할 만한 경우는 상상하기 어렵습니다.
이것도 결국 부실한 타입 시스템의 결과입니다.
그렇게 생각하지 않습니다. Rust처럼 좋은 타입 시스템을 가진 언어에서도 누군가 이런 API를 만들 수 있었을 겁니다.
그러다 나중 버전에서
crypt가 실패할 수 있어야 하니 오류를 어떻게든 알려야 합니다. 그런데 하위 호환성을 정말 지키고 싶어서 출력 타입을Result<..., ...>로 바꿀 수 없습니다. 그래서 libcrypt에서 한 것과 똑같은 짓을 하게 되죠. 오류 값을 출력하되,crypt의 출력이setting문자열과만 비교되리라는 잘못된 가정 아래 그 값이setting과 같지 않도록 꼼수를 부리는 겁니다. 그리고 누군가 똑같은 버그를 만들어 냅니다.제가 틀렸을 수도 있지만, 제가 보기에 이 버그에 관해서는 C든 Rust든 일을 망칠 동기와 수단이 똑같이 있습니다.
제 생각에 타입 안전성은 언어 기능과 함께, 타입 안전하게 유지하려고 노력하는 마음가짐이 있어야 합니다. 말씀하신 예에는 앞쪽만 있고요.
예를 들어 Rust 웹 앱에서 인코딩된 URL, 인코딩되지 않은 URL 조각, 이스케이프된 HTML, 이스케이프되지 않은 HTML, 사용자 이름 등에 전부 String을 쓰기로 할 수도 있습니다. 아니면 뉴타입을 쓰기로 하고, 모든 API가 올바른 뉴타입만 받고 반환하도록 신경 써서 설계할 수도 있고요.
방향을 잘못 보고 계십니다.
문제는 Rust에서 나쁜 코드를 쓸 수 있다는 게 아닙니다. 문제는 이런 사용 패턴을 C에서는 제대로 코딩할 방법이 없다는 겁니다. 이건 사용자에게 알려 주고 특정 속성을 강제해야 하는 공개 API입니다. Result 타입은 정확히 그 일을 하고, 그 타입만으로 잘못된 걸 억지로 통과시키려면 한참 무리해야 합니다.
그렇게 생각하지 않습니다. Rust든 C든 해법은 같습니다. 하위 호환성을 지키려 애쓰는 대신 함수가 실패할 수 있다고 명시하는 겁니다. C라면 아마 이런 모양이겠죠. 성공하면 0, 실패하면 음수를 반환하고요.
이런 시그니처를 "미쳤다"고 부르셔도 상관없지만, 지금 논의하는 버그는 이걸로 막을 수 있었을 겁니다.
말씀하신 것도 타입 안전성의 한 형태예요. C에도 타입 안전성의 정도 차이는 있고, 이 버그는 하위 호환성이 타입 안전성보다 중요하다고 판단한 데서 나왔다는 점에는 우리가 동의하는 것 같네요.
그리고 C는 지난 세월 동안 나쁜 API를 사실상 폐기해 왔죠. 이것만 빼고요.
아주 멀쩡한 시그니처입니다. 오류를 일반적으로 반환 채널로 알려 주는 API 표면과 함께라면 특히요.
이것도 좋지 않습니다. Crypt는 임의의 이유로 실패할 수 없는 순수한 계산입니다. 예전 구현은 salt가 유효하지 않으면 실패할 수 있었는데, 이건 보장할 수 있는 부분이었습니다. 그런데 이제는 비밀번호가 유효하지 않아도 실패합니다. 새로운 동작이고, 어떻게 포장하든 호환되지 않을 가능성이 있습니다. man 페이지에 최대 길이 권장 사항을 추가하고, 호출하는 소프트웨어가 업데이트될 때까지는 DoS 공격이 계속되게 두었어야지, crypt 라이브러리를 고쳐서는 안 됐습니다.
OP는 이 함수가 원래부터 실패할 수 있었고, 호출하는 쪽이 null을 역참조하는 일을 줄이려고 sentinel로 바꾼 것이라고 합니다. Option/Result였다면 그런 일이 없었을 텐데요. https://git.musl-libc.org/cgit/musl/commit/?id=b3c4cc12
하지만 당신이 든 버전에서는 하위 호환성을 지킬 수 없습니다. 실패하지 않던 함수가 실패할 수 있게 바꾸는 건 호환성을 깨는 변경입니다. libc에 견줄 만한 것이라면 그렇게 하고 싶을 수도 있지만(예를 들어 Rust std에는 아직 mem::uninitialized가 있습니다), 보안 문제가 있어도 새 함수를 만들고 기존 것은 deprecated로 두는 쪽(아니면 패닉. 이것대로 문제가 있긴 하지만)을 더 선호할 것 같습니다.
좋은 타입 시스템이 있으면 앞으로의 호환성도 더 잘 고려해서 쓰게 되고, 나중에 안전하게 바꿀 수도 있습니다.
crypt에 해당하는 Rust 코드는 아마 아예 존재하지 않을 겁니다. 저수준 라이브러리는 해시 함수만 제공하고 DoS 완화 등은 호출하는 쪽에 맡길 것이고, 더 넓은 라이브러리라면 동등 비교와 직렬화가 가능한 불투명 핸들을 반환할 텐데 그걸 비밀번호 없는 사용자와 비교해 볼 수는 없습니다. 고수준 라이브러리는 계정 저장소를 직접 파싱할 거고요. 이 중 호환되지 않는 방식으로 바뀌어야 하는 건 없습니다. 각자 자기 추상화 수준에서 기능과 수정을 추가할 정보를 다 갖고 있으니까요.결론은, 또다시!, C는 안전한 소프트웨어를 만들기엔 낡은 도구라는 겁니다.
정말 해결책이, "*"를 반환한다는 문서화된 문제를 풀려고 만든 래퍼 때문에 생긴 버그를 고치겠다고, "참"을 반환한다는 문서화된 문제를 풀려는 래퍼를 또 만드는 건가요?
단서를 달자면:
musl은 사실 이런 사용에 대해서는 안전합니다. OpenSSL이 이상한 코드 경로를 타면서 $hash가 너무 짧다는 걸 감지하고, crypt($pw, $other_string)을 호출한 다음, 해시가 너무 짧다고 감지했던 사실을 잊어버리고 $hash == 반환값을 검사하는 겁니다.
제 다른 댓글을 보세요.
musl이라면 여기서 보통은 문제없이 동작한다는 건 저도 적어 뒀습니다. 다만 제 분석은 큰 흐름을 이해한 시점에서 사실상 멈췄기 때문에, 코드에서 실제로 무슨 일이 일어나는지를 훨씬 자세히 다룬 다른 댓글은 고맙게 봤습니다.
다만 musl이 제대로 동작했다면 OpenSSH는 아무 문제 없이 동작했을 겁니다. 그런데 musl이 명세를 무시하고 잘못 짜인 프로그램을 우회하려다가, 올바르게 짜인 프로그램에 보안 구멍을 만들어 버렸죠!
링크된 페이지보다 훨씬 명확한 설명이네요. 감사합니다!
네, 링크된 페이지는 AI가 쓴 느낌은 안 드는데(아직 제가 말투를 잘 모르는 grok이나 muse 모델일 수도 있겠네요), 각각은 말이 되는 사실을 잔뜩 늘어놓기만 하고 위 댓글처럼 하나의 취약점으로 맞아떨어지게 설명하지는 않아요.