Alpine 및 musl에서 발생하는 OpenSSH 로그인 우회 취약점 (CVE-2026-123158)

지원 종료(EOL)된 PHP용 php2shell 패치(CVE-2026-109618 및 CVE-2026-109619)가 출시되었습니다. 수요가 많아 평소보다 영업팀의 답변이 늦어지고 있으므로, 패치된 패키지를 가장 빠르게 확보하려면 온라인에서 Runtimes용 ELS를 구매하시기 바랍니다 (새 탭에서 열림). 패치 FAQ 읽어보기

이 블로그 글의 AI 요약으로 바로 가기

  • Perplexity를 사용하여 이 게시물을 요약해 주세요
  • Gemini로 이 게시물을 요약해 주세요

주요 내용

  • 257~1024바이트 길이의 비밀번호를 사용하면, PAM 없이 musl libc에서 sshd를 실행하는 리눅스 시스템(특히 Alpine Linux)의 모든 키 전용 계정에 로그인할 수 있습니다. OpenSSH 10.6p2에는 이 문제에 대한 해결책이 포함되어 있으며, 해당 버그는 CVE-2026-123158로 등록되어 있습니다.
  • 이 버그는 두 프로젝트 사이에 존재합니다. 2016년부터 sshd는 * 계정에 대해 다른 계정의 솔트를 사용해 왔으며, musl의 crypt() 함수는 지나치게 긴 비밀번호에 대해 “*”라는 문자열을 반환하는데, 이는 저장된 *와 일치합니다. OpenSSH 측은 근본 원인이 sshd 외부에 있다고 밝혔으며, musl 유지보수자는 이를 호출 측의 잘못된 사용이라고 지적했습니다.
  • Alpine Linux 3.18에서 이 현상을 재현해 보았습니다. 한 연결에서 두 번째 시도 시 로그인이 성공하고, bcrypt 해시를 사용하면 길이 제한이 사라지며, 수정된 패키지를 적용하면 모든 시도가 거부됩니다. sshd를 업데이트하고 재시작하거나, 비밀번호 로그인을 비활성화하십시오. Alpine 3.18 및 그 이전 버전에서는 sshd_config.d 드롭인 파일만으로는 아무런 효과가 없습니다.
  • Alpine Linux 3.18은 2025년 5월에 지원 종료(EOL) 시점을 맞이했으나, 이번에는 예외적으로 수정 패치를 제공받았습니다. TuxCare의 Endless Lifecycle Support(ELS)는 이를 확실한 약속으로 만들어 줍니다. Alpine 3.18 ELS는 openssh 9.3_p2.tuxcare.els7과 동일한 업스트림 커밋을 적용받습니다.

2026년 11월 5일, OpenSSH 팀은 중대한 보안 문제를 임시로 해결한 버전 10.6p2를 출시했습니다. 일부 리눅스 시스템에서 공격자는 절대 비밀번호를 허용해서는 안 되는 계정에 SSH를 통해 로그인할 수 있습니다. 추측할 비밀번호가 없습니다. 공격자는 해당 계정의 이름만 알면 됩니다. 가장 일반적인 설정에서 공격자는 동일한 연결을 통해 잘못된 비밀번호 하나를 입력한 후, 257~1024바이트 길이의 비밀번호를 입력합니다. 아래에서 보여드리듯이, 일부 설정에서는 짧은 비밀번호도 작동합니다.

이 버그는 CVE-2026-123158 (CVSS 3.1 점수 8.1)로 등록되었습니다. 이 버그는 Wakefield(wakefield.co)의 Özgün Kültekin이 발견했습니다. 이 버그의 원인이 되는 코드 경로는 2016년부터 버전 7.3부터 10.6p1까지의 포터블 OpenSSH에 존재해 왔습니다.

이 버그는 두 프로젝트 사이에 존재한다는 점에서 특이합니다. 이 공격에는 버퍼 오버플로우, 시스템 충돌 또는 메모리 손상이 필요하지 않습니다. 이는 OpenSSH와 musl C 라이브러리가 동일한 시스템에서 함께 실행될 때만 나타나는 논리적 버그이며, 두 프로젝트 측은 이 버그의 책임 소재에 대해 의견이 일치하지 않습니다. OpenSSH 릴리스 노트에는 다음과 같이 설명되어 있습니다.

“이 버그의 근본 원인은 sshd 외부에 있다고 판단되지만, sshd를 통해 악용될 수 있으며 sshd 측에서 이를 방지할 수 있습니다.”

musl의 유지보수 담당자인 리치 펠커는 이를 달리 보고 있다.

“제 생각에는 이것이 musl의 버그가 아니라 호출 측의 잘못된 사용에 불과하다고 봅니다.”

이 글에서는 두 프로젝트의 코드를 살펴보고, 로그인 과정이 어떻게 진행되는지 단계별로 설명하며, 실험실에서 Alpine Linux에 대한 공격을 재현해 보겠습니다.

누가 영향을 받나요?

이 공격은 동일한 서버에서 이러한 조건이 모두 충족될 때만 성공합니다.

  • sshd는 musl libc와 링크되어 있습니다. Alpine Linux가 가장 잘 알려진 사례이며, 이는 많은 Docker 이미지의 기반이기도 하지만, sshd를 실행하는 이미지만이 이 취약점에 노출됩니다. 이 버그는 Alpine에만 국한된 것이 아닙니다. musl로 빌드되고 PAM이 포함되지 않은 sshd를 실행하는 모든 Linux 시스템이나 장치, 즉 커스텀 펌웨어나 직접 빌드한 sshd 바이너리도 이 취약점의 영향을 받을 수 있습니다.
  • sshd는 비밀번호를 확인하는 데 PAM(대부분의 리눅스 배포판에서 로그인에 사용하는 인증 프레임워크)을 사용하지 않습니다. Alpine에서는 기본 openssh-server 패키지가 PAM 없이 빌드됩니다. UsePAM이 no로 설정된 상태로 실행되는 openssh-server-pam 패키지도 마찬가지로 영향을 받습니다.
  • 'PasswordAuthentication yes' 설정으로 비밀번호 로그인이 활성화되어 있으며, 이는 기본값입니다.
  • 대상 계정의 비밀번호는 * 하나뿐입니다. 일반적인 경우는 /etc/shadow에 *가 설정된 키 전용 사용자입니다. PAM이 없는 Alpine에서는 !가 설정된 계정은 SSH 키를 사용해도 로그인할 수 없으므로, 관리자는 키 로그인이 작동하도록 !를 *로 변경합니다. Alpine 3.16 버전경부터 Alpine 기본 이미지의 root 계정도 *를 비밀번호로 사용하지만, sshd는 PermitRootLogin yes가 설정되어 있지 않은 한 root의 비밀번호 로그인을 차단합니다. musl-nscd를 통해 디렉터리 서비스에서 온 사용자는 shadow 항목이 없으므로, 이 경우에도 비밀번호가 *로 설정될 수 있습니다.
  • /etc/passwd 파일의 순서상 해시가 $로 시작하는 첫 번째 계정은 musl이 처리하지 못할 수 있는 해시 유형을 사용해야 합니다. 일반적인 $5$ 또는 $6$ 해시(SHA-256 또는 SHA-512 crypt)의 경우 공격자는 257~1024바이트가 필요하며, Alpine의 passwd는 기본적으로 $6$을 기록합니다. bcrypt 해시($2a$, $2b$ 또는 $2y$)의 경우, 1024바이트 이하의 비어 있지 않은 비밀번호라면 무엇이든 공격에 성공합니다. 구형 $1$ (MD5) 해시, yescrypt $y$ 해시, 또는 해시가 전혀 없는 경우에는 공격이 성공하지 않습니다.

이 시스템들에는 해당 취약점이 존재하지 않습니다.

  • 리눅스가 아닌 시스템.
  • RHEL, AlmaLinux, CloudLinux, Debian 또는 Ubuntu와 같이 glibc를 사용하는 리눅스 시스템.
  • sshd가 PAM을 통해 비밀번호를 확인하는 리눅스 시스템.

* 대신 !로 표시된 계정도 안전하며, PasswordAuthentication이 no로 설정된 서버도 마찬가지입니다. 단계별 설명만 필요하시다면, 마지막 부분 근처의“이제 어떻게 해야 하나요?”로 건너뛰세요.

/etc/shadow 파일에 있는 별표 하나가 의미하는 바

리눅스에서는 비밀번호 해시가 /etc/shadow 파일에 저장됩니다. 해시란 비밀번호를 일방향으로 암호화한 형태입니다. 로그인할 때 시스템은 사용자가 입력한 비밀번호를 동일한 방식으로 해시 처리한 뒤, 두 결과를 비교합니다. 두 결과가 일치하면 로그인이 성공합니다.

일반적인 항목은 다음과 같습니다:

alice:$6$Qx7Lm2Pa$k3J9...long hash...:20000:0:99999:7:::

맨 앞의 $6$은 SHA-512 암호화를 나타내며, 이는 SHA-512를 기반으로 한 비밀번호 해싱 방식입니다. 그 뒤의 Qx7Lm2Pa 부분은 솔트입니다. 솔트란 비밀번호에 혼합되는 짧은 임의의 문자열로, 이를 통해 동일한 비밀번호를 사용하는 두 사용자라도 서로 다른 해시 값을 얻게 됩니다.

1980년경, 해시 값이 아직 /etc/passwd 파일에 저장되던 시절부터 유닉스 관리자들은 특정 계정이 절대로 비밀번호로 로그인해서는 안 될 경우 이 필드에 * 하나만 입력해 왔습니다. 그 원리는 간단합니다. 실제 해시 값은 항상 훨씬 더 길기 때문에, 세상에 어떤 비밀번호도 해시 처리된 결과가 *로만 나오는 일은 없습니다. 따라서 해당 계정은 SSH 키로는 로그인할 수 있지만, 비밀번호로는 절대 로그인할 수 없습니다. 이번 버그의 핵심은 바로 이 원리를 무력화시키는 데 있습니다.

sshd가 PAM 없이 비밀번호를 확인하는 방법

PAM이 비활성화되어 있을 때, sshd는 auth-passwd.c 파일의 sys_auth_passwd() 함수에서 직접 비밀번호를 확인합니다. 다음은 Alpine Linux 3.18에 포함된 버전인 OpenSSH 9.3p2의 수정 전 코드입니다.

/* Just use the supplied fake password if authctxt is invalid */
char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
...
/*
 * Encrypt the candidate password using the proper salt, or pass a
 * NULL and let xcrypt pick one.
 */
if (authctxt->valid && pw_password[0] && pw_password[1])
    salt = pw_password;
encrypted_password = xcrypt(password, salt);

/*
 * Authentication is accepted if the encrypted passwords
 * are identical.
 */
return encrypted_password != NULL &&
    strcmp(encrypted_password, pw_password) == 0;

이것이 가장 일반적인 방법입니다. 저장된 해시를 읽어온 다음, 입력된 비밀번호를 저장된 해시를 솔트로 사용하여 해시 처리한 후, 두 문자열을 비교합니다.

이제 솔트 줄을 살펴보세요. sshd는 저장된 값이 최소 두 글자일 때만 이를 솔트로 사용합니다. *는 한 글자뿐입니다. 따라서 * 계정의 경우 솔트는 NULL로 유지되며, xcrypt()는 솔트를 직접 선택해야 합니다.

2016년의 변경 사항

xcrypt()는 왜 솔트를 선택하는 것일까요? 그 이유는 CVE-2016-6210 때문입니다. 2016년에 한 연구원은 sshd가 존재하지 않는 사용자에 대해 다른 속도로 응답한다는 사실을 밝혀냈습니다. 공격자는 긴 비밀번호를 전송하고 응답 시간을 측정함으로써 실제 사용자 이름 목록을 작성할 수 있었습니다.

이러한 격차를 해소하기 위해 대런 터커(Darren Tucker)는 sshd를 수정하여, 사용 가능한 솔트가 없을 경우 시스템 내 실제 계정의 해시 유형과 솔트를 차용하도록 했습니다. 첫 번째 커밋인 9286875a “유효하지 않은 사용자에 대한 적절한 솔트 결정”에서는 root 계정의 솔트를 차용했습니다. 6일 후, dbf788b4 “유효한 솔트를 가진 사용자 검색” 커밋을 통해 모든 계정을 검색하도록 변경되었습니다. 두 커밋 모두 2016년 8월 OpenSSH 7.3에 포함되었습니다. 이 변경 사항은 존재하지 않는 사용자를 대상으로 한 것이었습니다. 하지만 앞서 언급한 두 글자 검사 방식은 *이 포함된 유효한 계정도 동일한 경로로 처리하므로, 해당 계정도 차용된 솔트를 받게 됩니다.

다음은 openbsd-compat/xcrypt.c 파일에 있는 pick_salt() 함수로, 9.3p2 버전의 모습입니다.

if (salt[0] != '\0')
    return salt;
strlcpy(salt, "xx", sizeof(salt));
setpwent();
while ((pw = getpwent()) != NULL) {
    if ((passwd = shadow_pw(pw)) == NULL)
        continue;
    if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
        typelen = p - passwd + 1;
        strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
        explicit_bzero(passwd, strlen(passwd));
        goto out;
    }
}
out:
endpwent();
return salt;

이 기능은 /etc/passwd에 있는 계정들을 순서대로 훑어보며, 발견한 첫 번째 $…$ 형식의 솔트(예: alice 계정의 $6$Qx7Lm2Pa)를 가져옵니다. 이 결과를 저장해 두기 때문에, 이후 같은 연결에서 시도할 때는 캐시된 솔트를 사용합니다. $ 해시 형식의 솔트를 가진 계정이 없으면, 기존 DES 방식인 xx로 대체합니다.

이 변경 사항이 적용되기 전에는 sshd가 *와 같은 짧은 항목에 “xx”를 사용했습니다. 구형 DES는 긴 비밀번호에서도 오류가 발생하지 않고 단순히 처음 8자만 사용하기 때문에 *를 반환할 수 없었습니다. 변경 후에는 * 계정에 다른 사용자의 솔트가 할당됩니다. 이것이 2016년에 문제가 발생한 원인입니다. 이 변경 사항은 실제 타이밍 문제를 해결하기 위한 적절한 조치였으며, 알려진 바에 따르면 아무도 그 부작용을 눈치채지 못했습니다.

musl의 crypt() 함수가 오류 발생 시 반환하는 값

musl은 Alpine 및 많은 임베디드 시스템에서 사용되는 소형 C 라이브러리입니다. 이 라이브러리의 SHA-512 암호화 코드는 src/crypt/crypt_sha512.c 파일에 있습니다. 이 코드는 시스템이 느리고 CPU 부하가 큰 요청으로부터 보호받을 수 있도록 비밀번호 길이를 제한합니다.

/* key limit is not part of the original design, added for DoS protection.
 * rounds limit has been lowered (versus the reference/spec), also for DoS
 * protection. runtime is O(klen^2 + klen*rounds) */
#define KEY_MAX 256
...
    /* reject large keys */
    for (i = 0; i <= KEY_MAX && key[i]; i++);
    if (i > KEY_MAX)
        return 0;

256바이트보다 긴 비밀번호는 허용되지 않습니다. 이제 crypt()가 호출하는 내부 함수인 __crypt_sha512()가 이러한 경우 어떤 값을 반환하는지 확인해 보십시오.

p = sha512crypt(key, setting, output);
/* self test and stack cleanup */
q = sha512crypt(testkey, testsetting, testbuf);
if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))
    return "*";
return p;

오류가 발생하면 musl의 crypt()는 문자열 “*”를 반환합니다. SHA-256, MD5 및 Blowfish 코드도 마찬가지로 동작하며, 256바이트 제한이 적용되는 것은 SHA-256과 SHA-512뿐입니다. musl은 2012년부터 이렇게 동작해 왔습니다. POSIX는 crypt()가 오류 발생 시 null 포인터를 반환하고 errno를 설정해야 한다고 규정하고 있지만, musl은 의도적으로 문자열을 반환하도록 선택했습니다. 커밋 b3c4cc12에서는 “crypt를 호출하는 프로그램의 상당수가 오류 발생에 대비하지 않았으며 반환 값이 null이 아닌지 확인하지 않는다”고 설명하며, 그 결과를 “비일치 해시(unmatchable hash)”라고 부릅니다.

문제는 *가 /etc/shadow에서 “비밀번호 없이 로그인”을 의미하는 값이기도 하다는 점입니다.

musl은 기존 패턴을 그대로 유지했습니다. crypt_des.c 파일의 DES 코드 마지막 줄을 살펴보세요.

return (setting[0]=='*') ? "x" : "*";

솔트 자체가 *로 시작하는 경우, musl은 대신 x를 반환합니다. 따라서 crypt(password, “*”)는 절대 “*”를 반환하지 않습니다. 이는 프로그램이 저장된 값을 솔트로 사용한 다음, 그 결과를 동일한 저장된 값과 비교하는 기존의 검증 방식을 방어해 줍니다.

하지만 sshd는 저장된 값을 솔트로 사용하지 않았습니다. 대신 앨리스의 솔트를 사용했습니다. musl은 $6$…을 감지했기 때문에, 이 보호 조치는 소용이 없었습니다.

아주 간단한 테스트

저희는 간단한 테스트 프로그램을 통해 C 라이브러리 부분을 직접 확인해 보았습니다. 다음은 이를 간소화한 두 가지 핵심 코드 줄입니다. 이 프로그램은 대문자 ‘A’로만 구성된 비밀번호를 빌려온 $6$ 소금으로 해시 처리한 뒤, 그 결과를 *와 비교합니다.

r = crypt(long_password, "$6$abcdefgh");  /* salt borrowed from another account */
ok = (r != NULL && strcmp(r, "*") == 0);  /* compare with the "*" in /etc/shadow */

Alpine Linux 3.18 (musl)과 Debian 12에서 이 프로그램을 실행해 보았습니다. Debian에서는 대부분의 최신 glibc 시스템과 마찬가지로, crypt() 함수는 libxcrypt라는 별도의 라이브러리에서 제공됩니다.

== musl (Alpine 3.18) ==
len=   8 crypt()=$6$abcdefgh$65STptHV...  match "*": no
len= 256 crypt()=$6$abcdefgh$9SA4XFln...  match "*": no
len= 257 crypt()=*  match "*": YES
len=1024 crypt()=*  match "*": YES
== libxcrypt (Debian 12) ==
len=   8 crypt()=$6$abcdefgh$65STptHV...  match "*": no
len= 256 crypt()=$6$abcdefgh$9SA4XFln...  match "*": no
len= 257 crypt()=$6$abcdefgh$D4Ayo9g5...  match "*": no
len=1024 crypt()=*0  match "*": no

musl에서는 257바이트 이상의 모든 비밀번호가 별표(*)와 일치합니다. libxcrypt의 경우, 257바이트 비밀번호는 정상적으로 해시 처리됩니다. libxcrypt에는 511바이트라는 자체 제한이 있지만, 오류가 발생하면 *0을 반환하고, 솔트가 *0으로 시작할 때는 *1을 반환합니다. 두 글자로 된 오류 문자열은 한 글자인 *와 절대 같을 수 없으므로, 비교는 예상대로 실패합니다.

긴 비밀번호부터 로그인까지의 전체 과정

다음은 Alpine 서버에서 키만 사용하는 사용자를 표적으로 삼는 공격자의 전체 공격 경로입니다. 해당 사용자의 /etc/shadow 파일에는 *가 기록되어 있으며, 시스템에서 발견되는 첫 번째 $ 해시는 다른 사용자의 $6$ 해시입니다.

  1. 공격자는 SSH에 연결한 후, 키만 사용하는 사용자로 비밀번호 로그인을 요청합니다.
  2. 공격자는 300바이트 길이의 비밀번호를 전송합니다. sshd는 최대 1024바이트(MAX_PASSWORD_LEN) 길이의 비밀번호를 허용하므로, 처리를 계속합니다.
  3. sshd는 /etc/shadow 파일에서 *를 읽습니다. 문자가 하나뿐이므로 솔트는 없습니다.
  4. pick_salt()는 다른 사용자의 $6$ 소금을 빌려옵니다.
  5. musl의 SHA-512 코드는 256바이트를 초과하는 데이터를 처리하지 못해 오류가 발생합니다. crypt()는 “*”를 반환합니다.
  6. 첫 번째 시도에서 sshd는 아래에서 설명할 부수적인 이유로 인해 저장된 *를 이미 잃어버렸기 때문에, 비교가 실패합니다. 공격자는 단순히 동일한 연결에서 다시 시도하기만 하면 됩니다.
  7. 두 번째 시도에서 sshd는 “*”를 저장된 “*”와 비교합니다. 둘이 일치합니다. 로그인이 승인됩니다.

이 때문에 차용된 솔트가 $5$ 또는 $6$일 때 공격 가능 범위는 257~1024바이트입니다. 257바이트 미만이면 musl은 평소와 같이 비밀번호를 해시 처리합니다. 1024바이트를 초과하면 sshd는 해시 처리 전에 이를 거부합니다. CVE 설명에는 “256~1024자”라고 명시되어 있지만, 실제 공격 가능 범위는 257바이트부터 시작됩니다.

bcrypt를 사용할 때는 길이가 중요하지 않습니다.

한 가지 더 있는 취약점이 있는데, 이는 Alpine 보안 권고나 CVE에는 언급되어 있지 않습니다. 저희가 코드를 검토하던 중 발견한 것입니다. pick_salt() 함수는 해시의 마지막 $ 기호에서 잘라냅니다. bcrypt 해시는 $2b$10$ 뒤에 53개의 문자가 이어지는 형태이며, 솔트와 해시 사이에는 $ 기호가 없습니다. 따라서 sshd는 $2b$10만 유지하고 솔트 자체는 잃어버립니다. musl은 이러한 설정을 거부하며, 오류가 발생하면 다시 “*”를 반환합니다. 이번에는 비밀번호 길이는 상관없습니다.

업스트림에서는 2026년 10월 30일, 커밋 84f8f466 “bcrypt 해시에 대한 가짜 비밀번호 솔트 생성 문제 수정”을 통해 이 솔트 버그를 수정했습니다.

+            /*
+             * bcrypt ($2*$NN$) has no '$' between its 22 character
+             * salt and the hash, so keep the salt too or crypt(3)
+             * will reject the setting without doing any work.
+             */
+            if (passwd[1] == '2' && strlen(p + 1) >= 22)
+                    typelen += 1 + 22;

커밋 메시지에는 타이밍 문제에 대해 언급되어 있지만, musl에서는 그 영향이 훨씬 더 심각합니다. 이 커밋은 마스터 브랜치에 있으며 10.6p2의 일부가 아닙니다. *가 4자보다 짧기 때문에, 10.6p2의 임시 해결책은 여전히 이 경로를 차단합니다.

Alpine 3.18에서 시스템의 첫 번째 $ 해시로 bcrypt 해시를 설정하여 테스트를 진행했습니다. 수정되기 전 패키지에서는 1~1024바이트 길이의 비어 있지 않은 모든 비밀번호로 * 계정에 두 번째 시도 만에 로그인이 성공했습니다. 하지만 수정된 패키지에서는 모든 시도가 차단되었습니다. Alpine의 passwd는 기본적으로 $6$를 기록하므로, 이 경로에는 관리자나 도구가 직접 bcrypt 해시를 설정해야 합니다.

알프스의 구름 사진

Alpine의 보안 권고문은 3.18.11부터 3.24.3까지의 공식 tiny-cloud 이미지에서 ‘alpine’ 사용자를 지목하고 있습니다. 해당 사용자는 *. 아직 실제 비밀번호 해시가 할당된 계정이 없기 때문에, 새로 생성된 이미지는 노출되지 않습니다. 비밀번호 로그인이 활성화된 상태에서 어떤 계정에 $6$ 해시가 할당되는 즉시 노출됩니다. PasswordAuthentication을 no로 설정하는 cloud-init을 통해 구성된 이미지는 이 문제의 영향을 받지 않습니다. 연구원은 공식 AWS 및 Azure 이미지에서 alpine 사용자가 비밀번호 없이 doas(Alpine의 sudo에 해당하는 명령)를 실행할 수 있으므로, 해당 환경에서 alpine으로 로그인하는 것은 root로 로그인하는 것과 동일하다고 지적했습니다.

우리는 이를 Alpine에서 재현했습니다.

crypt() 테스트는 C 라이브러리 부분을 보여줍니다. 또한 실제 sshd가 우리를 들여보내 주는지 확인하고 싶어서 간단한 실험 환경을 구축했습니다.

우리는 두 장의 이미지를 사용했습니다:

  • openssh-server 9.3_p2-r3이 포함된 Alpine Linux 3.18. 이는 수정 패치가 적용되기 전의 마지막 Alpine 패키지입니다.
  • openssh-server 9.3_p2-r4(수정된 패키지)가 포함된 Alpine Linux 3.18.

두 경우 모두 알파인(Alpine)의 자체 passwd 도구를 사용하여 한 계정에 실제 비밀번호를 설정했습니다. 그런 다음 *로만 구성된 키 전용 계정과 !로 표시된 잠긴 계정을 추가한 뒤, 길이가 각기 다른 비밀번호로 로그인을 시도했습니다. “LOGGED IN”은 동일한 연결에서 두 번째 시도가 성공하여 sshd가 해당 사용자로 명령을 실행했음을 의미합니다.

                                   Alpine 3.18     Alpine 3.18
                                   9.3_p2-r3       9.3_p2-r4
                                   (before fix)    (fixed)
key-only (*), 8 bytes              denied          denied
key-only (*), 256 bytes            denied          denied
key-only (*), 257 bytes            LOGGED IN       denied
key-only (*), 1024 bytes           LOGGED IN       denied
key-only (*), 1025 bytes           denied          denied
locked (!), 300 bytes              denied          denied
root (*), default config           denied          denied
root (*), PermitRootLogin yes      LOGGED IN       denied
key-only (*), 1 byte, with bcrypt  LOGGED IN       denied
real password                      accepted        accepted

고정된 패키지에서는 모든 공격이 실패한 반면, 실제 비밀번호는 여전히 이전과 같이 정상적으로 작동했습니다. 일반적인 SSH 클라이언트는 최대 1023바이트만 전송하므로, 이보다 긴 비밀번호를 입력하면 공격 창 내에서 잘려버립니다. 1024바이트 및 1025바이트 행의 경우, 정확한 길이의 데이터를 전송하는 소형 클라이언트를 사용했습니다. 1025바이트에서는 sshd가 해시 처리하기 전에 비밀번호를 거부했습니다. bcrypt 행의 경우, 시스템에서 생성된 첫 번째 $ 해시는 $6이 아닌 $2b$ 해시였습니다.

왜 모든 공격에 두 번의 시도가 필요했는가

표에 나열된 모든 취약한 실행 사례에서, 첫 번째 비밀번호 시도에는 실패했으나 동일한 연결에서 두 번째 시도는 성공했습니다. 첫 번째 시도는 최대 1024바이트 길이의 임의의 잘못된 비밀번호일 수 있습니다. 우리는 sshd가 strcmp()에 전달하는 내용을 기록하는 간단한 프로브를 통해 그 원인을 확인했습니다. 첫 번째 시도에서 sshd는 “*”를 저장된 *의 주소에 남아 있던 값(우리 실험실 환경에서는 빈 문자열)과 비교했습니다. 두 번째 시도에서는 “*”를 “*”와 비교했습니다.

원인은 sshd의 버퍼 문제입니다. shadow_pw()는 musl의 getspnam()이 매 조회 시 재사용하는 버퍼 내의 포인터를 반환했습니다. 첫 번째 시도에서는 pick_salt()가 실행되는데, 이 함수는 동일한 버퍼에서 다른 계정을 조회한 후 explicit_bzero()를 사용하여 빌려온 해시를 지웁니다. 따라서 sshd가 여전히 가리키고 있던 *는 사라지게 됩니다. 두 번째 시도부터는 솔트가 캐시되어 다른 조회가 발생하지 않으므로 *가 *와 일치하게 됩니다. 10.6p2의 해결 방법에서도 이 문제가 수정되었는데, 이는 해결 방법 섹션에서 설명하고 있습니다.

두 번째 시도은 *가 /etc/shadow에 있고 sshd가 기본 설정으로 실행될 때만 필요합니다. 저희 실험 환경에서는 /etc/passwd에 *가 있고 shadow 항목이 없는 계정도 첫 번째 시도에서 바로 승인되었습니다. 빈 비밀번호를 가진 계정의 로그인을 허용하는 sshd 옵션인 PermitEmptyPasswords가 yes로 설정된 경우, /etc/shadow에 *가 있는 경우에도 첫 번째 비밀번호 시도에서 승인되었습니다.

디렉터리 서비스 사용자들은 /etc/passwd의 경우와 비슷한 양상을 보일 가능성이 높지만, 우리는 이를 테스트하지는 않았다. 이는 두 공개 자료 모두와 일치한다. Alpine의 보안 권고문에는 이 공격이 하나의 연결만으로 이루어지며 재시도가 필요하지 않다고 명시되어 있는 반면, 연구자의 페이지에는 shadow 파일 내의 *에 대해서는 먼저 잘못된 시도가 한 번 필요하다고 설명되어 있다.

sshd는 기본적으로 연결당 6회의 시도(MaxAuthTries)를 허용하므로, 추가 시도가 있다고 해도 공격자를 막을 수는 없습니다. /etc/shadow의 경우, 동일한 연결 포트에서 실패한 시도와 성공한 시도가 하나씩 남게 됩니다.

Failed password for keyonly from 127.0.0.1 port 53734 ssh2
Accepted password for keyonly from 127.0.0.1 port 53734 ssh2

그럼, 이건 OpenSSH의 버그인가요, 아니면 musl의 버그인가요?

OpenSSH 팀은 근본 원인이 sshd 외부에 있다고 밝혔으며, 이에 대한 타당한 근거가 있습니다. 수십 년간 사용되어 온 전통적인 검증 방식은 입력된 비밀번호를 저장된 값을 솔트(salt)로 사용하여 해시 처리한 뒤, 그 결과를 동일한 값과 비교하는 방식입니다. musl은 오류 문자열이 주어진 솔트와 절대 일치하지 않기 때문에 이러한 방식의 안전성을 보장합니다. glibc 시스템에서도 똑같은 sshd 코드는 안전합니다.

반대 입장의 주장도 있습니다. 2016년부터 sshd는 한 글자 길이의 항목을 다른 계정의 솔트와 함께 해시 처리한 뒤, 그 결과를 저장된 값과 비교해 왔으며, 그 과정에서 변경된 C 라이브러리 버퍼를 가리키는 포인터를 계속 유지해 왔습니다. musl의 가드 기능은 이 문제를 해결할 수 없었습니다. 리치 펠커(Rich Felker)는 이를 “호출자의 잘못된 사용”이라고 보고 있습니다. 또한 그는 이 문제의 근본적인 메커니즘이 musl의 “차기 릴리스 주기에서 변경될 가능성이 매우 높다”고 밝혔습니다.

이 버그를 해결하려면 세 가지 요소가 필요했는데, 각각 따로 보면 별문제가 없어 보입니다:

  • * 기호는 비밀번호 없이 로그인할 수 있는 계정을 나타내며, 이는 해시 값이 여전히 /etc/passwd 파일에 저장되던 1980년경의 유닉스 관습에서 유래한 것입니다.
  • 2012년에 발생한 musl의 “*” 오류 문자열과 256바이트 제한.
  • 2016년에 sshd에서 차용한 소금.

이 버그를 단독으로 유발하는 프로젝트는 없으며, 이것이 왜 10년 동안 발견되지 않았는지를 설명해 줍니다. 각 구성 요소가 서로 어떻게 상호작용하는지 살펴봐야 하며, PAM이 적용되지 않은 musl 시스템에서만 이 현상을 확인할 수 있습니다. 연구원은 다양한 C 라이브러리와 PAM 설정을 적용해 sshd를 빌드하고, 수천 건의 비정상적인 로그인 시도를 테스트한 끝에 이 버그를 발견했습니다.

OpenSSH의 수정 사항

데미안 밀러가 이 해결 방법을 작성했습니다. V_10_6 브랜치에서는 커밋 90f9c2f7 “avoid password authn bypass”에 해당하며, 마스터 브랜치에서는 동일한 변경 사항이 커밋 4353fdc4에 포함되어 있습니다. 주요 변경 내용은 매우 적습니다.

+#define MIN_PASSWORD_HASH_LEN    4
 #define MAX_PASSWORD_LEN 1024
...
-    return encrypted_password != NULL &&
+    ret = encrypted_password != NULL &&
+        strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
         strcmp(encrypted_password, pw_password) == 0;
+    freezero(pw_password, strlen(pw_password));
+    return ret;

실제 비밀번호 해시는 절대 4자보다 짧지 않습니다. 따라서 저장된 값이 *, !, x 또는 그보다 짧은 다른 문자라면, crypt() 함수가 어떤 값을 반환하든 상관없이 해시 비교는 항상 실패합니다. 이로 인해 이 게시물에 소개된 모든 방법(bcrypt 방식 포함)이 차단됩니다. PermitEmptyPasswords로 제어되는 별도의 빈 비밀번호 처리 방식은 변경되지 않습니다.

또한 이 패치는 shadow_pw() 함수를 수정하여, xstrdup()을 통해 생성된 섀도 항목의 복사본을 반환하고, 사용 후 해당 복사본을 지우도록 했습니다. 이를 통해 두 번의 시도 뒤에 숨어 있던 버퍼 문제가 해결됩니다.

openssh-unix-dev 메일링 리스트에서 데미안 밀러(Damien Miller)는 이러한 선택에 대해 설명했습니다. 그는 “우리는 이 문제를 신속하게 완화할 수 있는 유리한 위치에 있다”고 밝혔습니다. 이 글을 쓰는 시점에서 musl은 오류 발생 시 여전히 “*”를 반환하지만, 해당 프로젝트의 유지보수자는 다음 릴리스 주기에서 이 메커니즘을 변경할 예정이라고 밝혔습니다.

지금 어떻게 해야 할까요?

sshd 업데이트 및 재시작

Alpine에서는 다음과 같이 업데이트하고 재시작하세요. 이는 Alpine 공지사항에 나와 있는 명령어입니다. -a 플래그를 사용하면 apk가 저장소의 버전과 일치하도록 패키지를 교체하거나 다운그레이드할 수 있으므로, 어떤 부분이 변경되는지 확인하시기 바랍니다.

apk upgrade -U -a
rc-service sshd restart

OpenSSH 9.3p2는 새로운 연결이 발생할 때마다 /usr/sbin/sshd의 새로운 인스턴스를 시작하므로, 저희 실험실에서는 패키지가 설치되자마자 새로운 로그인 시 수정된 코드가 적용되었습니다. 어쨌든 sshd를 재시작하여 수신 대기 중인 프로세스도 새로운 바이너리를 실행하도록 하십시오. 컨테이너의 경우, 수정된 기본 이미지를 사용하여 이미지를 재구축한 후 컨테이너를 재시작하십시오. Alpine Linux 3.18을 사용하는 TuxCare ELS 고객은 ELS 저장소에서 수정된 버전을 받을 수 있습니다.
ELS 살펴보기 →

오늘 업데이트를 할 수 없다면, 비밀번호 로그인을 해제하세요.

이렇게 하면 위험에 노출된 계정뿐만 아니라 모든 사용자의 비밀번호 로그인이 비활성화됩니다. 접근 권한이 필요한 모든 사용자가 정상적으로 작동하는 SSH 키를 가지고 있는지 확인하고, 현재 세션을 계속 열어 두십시오. /etc/ssh/sshd_config.d 드롭인 폴더를 다룰 때는 주의하십시오. Alpine의 기본 구성은 3.19 버전부터 이 폴더를 읽으므로, 3.18 및 그 이전 버전에서는 드롭인 파일만으로는 아무런 효과가 없습니다. Alpine 보안 권고문에 제시된 다음 명령어를 사용하면 드롭인 파일을 작성하고, /etc/ssh/sshd_config 파일 자체도 수정하며, /etc/ssh/sshd_config.bak에 백업 파일을 생성합니다.

mkdir -p /etc/ssh/sshd_config.d
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
sed -i.bak -e '1i PasswordAuthentication no' \
  -e 's/^\(\s*PasswordAuthentication[^y#]*\)yes/\1no/i' \
  /etc/ssh/sshd_config
sshd -t && rc-service sshd restart
sshd -T | grep -i passwordauthentication

마지막 명령을 실행하면 ‘passwordauthentication no’가 출력되어야 합니다. 만약 출력되지 않는다면, 비밀번호 로그인이 여전히 활성화되어 있는 것입니다.

발생해서는 안 되는 로그인 시도를 찾아보세요

Alpine의 기본 syslog 파일인 /var/log/messages에서 *.가 포함된 계정에 대한 “Accepted password” 행을 찾아보세요. /etc/shadow의 경우, 저희 실험실에서는 항상 바로 그 직전에 동일한 연결 포트에서 “Failed password” 행이 표시되는 것을 확인했습니다. 이 두 가지 정보만으로는 판단하지 마십시오. 다른 환경에서는 첫 번째 시도에서 이미 로그인이 성공할 수도 있고, 실제 사용자가 비밀번호를 잘못 입력해도 동일한 조합이 기록될 수 있으며, 많은 컨테이너는 syslog 데몬을 실행하지 않아 아무것도 기록하지 않을 수 있고, 루트 권한을 가진 공격자가 로그를 삭제할 수도 있습니다. 또한 새로운 사용자, authorized_keys에 추가된 새로운 키, doas 사용 여부를 확인하십시오. 로그인 시도를 배제할 수 없다면, 해당 시스템을 침해당한 것으로 간주하십시오.

오래된 시스템 및 숨겨진 시스템 확인하기

오래된 Docker 이미지나 임베디드 기기에서는 때때로 musl 기반 OpenSSH가 실행되는데, 이를 업데이트해 주는 사람은 아무도 없습니다. 이 부분도 확인해 보세요.

이곳에서 TuxCare가 중요한 이유

이 업스트림 임시 해결책은 OpenSSH 10.6p2에 반영되었습니다. 많은 운영 환경에서는 최신 버전의 OpenSSH를 사용하지 않습니다. 이들은 수년 전 배포판에 포함되어 있던 버전을 계속 사용하고 있습니다. Alpine Linux 3.18은 OpenSSH 9.3p2를 포함하고 있으며, 2025년 5월에 지원 종료될 예정입니다.

Alpine은 이번 문제를 매우 잘 처리했습니다. 취약점이 공개된 당일, Alpine은 3.16 버전까지 거슬러 올라가는 모든 브랜치에 대한 수정 패키지를 공개했으며, 지원 종료된 브랜치도 예외는 아니었습니다. Alpine 3.18의 경우, 우리 연구실에서 확인한 수정 패키지는 9.3_p2-r4입니다. 하지만 Alpine은 지원이 종료된 브랜치에 대한 수정 제공을 보장하지는 않습니다. 이번에는 수정 패키지를 제공했지만, 다음 버그에 대해서는 아무도 장담할 수 없습니다.

TuxCare의 Endless Lifecycle Support(ELS)는 이를 확실한 약속으로 만들어 줍니다. Alpine Linux 3.18 ELS의 경우, 저희 팀은 업스트림 릴리스가 나온 당일에 동일한 업스트림 커밋을 OpenSSH 9.3p2로 백포트하여 병합했습니다. 이 패치는 ELS 패키지의 22개 패치 모두에 적용되며, Alpine 자체의 pw.patch와 동일한 코드입니다. 수정된 패키지는 x86_64 및 aarch64용 openssh 9.3_p2.tuxcare.els7입니다.
ELS 살펴보기 →

이 버그는 또한 단일 프로젝트의 보안 권고 사항만 주시하는 것만으로는 충분하지 않은 이유를 보여줍니다. 이 버그는 musl과 OpenSSH 양쪽의 코드가 모두 관련되어 있으며, 임시 해결책은 OpenSSH에 포함되어 있고, 영향을 받는 시스템은 musl 기반 서버, 컨테이너 및 임베디드 장치입니다. 누군가는 이 모든 사항을 추적하고, 고객이 여전히 사용 중인 버전에 대해 테스트를 거친 패치를 제공해야 합니다. 이것이 바로 우리 보안 팀이 수행하는 업무입니다.

4,500명 이상의 Linux 및 오픈 소스 전문가와 함께하세요!

한 달에 두 번. 스팸이 없습니다.

목차
필요한 오픈 소스 보안에 대한 답변 얻기

4,500명 이상의 Linux 및 오픈 소스 전문가와 함께하세요!

한 달에 두 번. 스팸이 없습니다.