Skip to content

Latest commit

 

History

History
303 lines (256 loc) · 19.2 KB

File metadata and controls

303 lines (256 loc) · 19.2 KB

GNU inetutils telnetd의 systemd 서비스 자격증명 악용을 통한 인증 우회 (CVE-2026-28372)

메타데이터

항목 내용
CVE ID CVE-2026-28372
영향 소프트웨어 GNU inetutils telnetd (2.7 이하), 단 util-linux login(1) 2.40 이상과 함께 사용될 때만
CWE CWE-829 (Inclusion of Functionality from Untrusted Control Sphere)
CVSS v3.1 7.8 (NVD 기준, AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) / 7.4 (MITRE 기준, AV:L/AC:H)
공개일 2026-02-27 (oss-security 최초 보고), NVD 등록 2026-02-27
발견자 Ron Ben Yizhak (SafeBreach)
수정 커밋 GNU inetutils 4db2f19f4caac03c7f4da6363c140bd70df31386 ("telnetd: don't allow systemd service credentials")

개요

util-linux의 login(1)은 2.40 버전부터 systemd 서비스 자격증명(service credentials) 기능을 지원하도록 확장되면서, 환경변수 CREDENTIALS_DIRECTORY가 가리키는 디렉터리 안에 login.noauth 라는 파일이 있고 그 내용이 yes이면 비밀번호 인증을 완전히 건너뛰는 로직이 추가됐다. 이 기능은 systemd가 서비스 유닛을 기동할 때만 이 환경변수를 신뢰할 수 있는 값으로 설정해준다는 암묵적 전제 위에 설계된 것이다. 그런데 GNU inetutils의 telnetd는 TELNET 프로토콜의 "New Environment" 옵션을 통해 접속한 클라이언트가 원격에서 지정한 임의의 환경변수를 그대로 프로세스 환경에 심어준 뒤, 그 환경을 물려받은 채로 login을 실행한다. 즉 systemd만 설정해야 정상인 CREDENTIALS_DIRECTORY를, 로컬에 계정만 있으면 되는 일반 사용자가 텔넷 접속 한 번으로 자신이 원하는 값으로 채워 넣을 수 있었다. 이미 존재하던 telnetd의 환경변수 필터링(scrub_env)은 1995년에 발견된 LD_PRELOAD 류의 공격만 막도록 만들어진 고정 차단목록(blocklist)이어서, 2024년에 새로 생긴 CREDENTIALS_DIRECTORY라는 이름을 알지 못했다. 그 결과 로컬 저권한 사용자가 자신의 홈 디렉터리에 login.noauth 파일 하나만 만들어 두면, 텔넷으로 자기 자신에게 접속해 인증 없이 root로 로그인할 수 있었다.

사전 지식

TELNET의 "New Environment" 옵션과 telnetd의 환경변수 전달 구조

TELNET 프로토콜(RFC 854)은 단순히 문자를 주고받는 것 이상으로, 클라이언트와 서버가 "옵션(option)"을 협상해서 터미널 종류, 화면 크기, 인증 방식 같은 부가 정보를 교환할 수 있게 설계되어 있다. 그중 NEW-ENVIRON 옵션(RFC 1572, 예전 이름은 ENVIRON, RFC 1408)은 클라이언트가 "내 셸 환경변수는 이렇다"는 정보를 서버에 통째로 전달할 수 있게 해준다. 원래 목적은 사용자가 로컬 터미널에서 쓰던 TERM, DISPLAY 같은 값을 원격 세션에도 그대로 이어받기 위함이다.

GNU inetutils의 telnetd는 이 옵션으로 들어온 값을 다음과 같이 처리한다 (telnetd/state.c, NEW-ENVIRON 서브옵션 파서):

case NEW_ENV_VAR:
case ENV_USERVAR:
  *cp = '\0';
  if (valp)
    setenv (varp, valp, 1);   /* 클라이언트가 보낸 이름=값을 그대로 setenv */
  else
    unsetenv (varp);
  ...

즉 클라이언트가 "이 환경변수 이름은 X, 값은 Y다"라고 말하면, telnetd는 검증 없이 자기 프로세스의 환경에 setenv(X, Y, 1)을 그대로 호출한다. RFC상 VAR(서버가 이미 알고 있는 잘 알려진 변수)과 USERVAR(클라이언트가 임의로 추가하는 변수)를 구분하지만, 코드에서 보듯 둘 다 결국 같은 setenv() 경로로 들어간다 — 즉 이름에 대한 어떤 화이트리스트도 없다.

1995년의 교훈과 scrub_env(): 왜 차단목록은 낡는가

사실 "telnetd가 클라이언트의 환경변수를 무분별하게 받아준다"는 문제는 이번이 처음이 아니다. 1995년에 이미 David Borman이 발견한 유명한 취약점이 있었다 — 당시 telnetd들은 LD_PRELOAD, LD_LIBRARY_PATH, IFS 같은 변수를 클라이언트가 지정하게 해줬는데, 공격자가 LD_PRELOAD로 자신이 준비한 공유 라이브러리를 지정하면, 뒤이어 root 권한으로 실행되는 login이 그 라이브러리를 로드하면서(예: crypt() 같은 함수를 가로채는 방식으로) 임의 코드를 root 권한으로 실행시킬 수 있었다. 이 사건 이후 telnetd 구현체들은 scrub_env()라는 함수를 추가해서, login을 실행하기 직전에 위험하다고 알려진 변수들을 지운다:

/*
 * scrub_env()
 * Remove a few things from the environment that don't need to be there.
 * Security fix included in telnet-95.10.23.NE of David Borman <deb@cray.com>.
 */
static void
scrub_env (void)
{
  char **cpp, **cpp2;
  for (cpp2 = cpp = environ; *cpp; cpp++)
    {
      if (strncmp (*cpp, "LD_", 3)
          && strncmp (*cpp, "_RLD_", 5)
          && strncmp (*cpp, "LIBPATH=", 8) && strncmp (*cpp, "IFS=", 4))
        *cpp2++ = *cpp;
    }
  *cpp2 = 0;
}

이 함수는 30년 전에 알려진 특정 변수 이름 4가지만 걸러낸다. 즉 "안전한 것만 허용"(allowlist)이 아니라 "위험하다고 알려진 것만 차단"(blocklist) 방식이다. 차단목록의 근본적인 한계는, 그 목록을 만든 시점 이후에 새로 생긴 위험한 변수 이름은 절대 걸러낼 수 없다는 데 있다. CREDENTIALS_DIRECTORY는 이 목록이 고정된 지 한참 뒤인 2023~2024년에야 systemd 생태계에서 의미를 갖게 된 이름이라, scrub_env()는 이 변수의 존재 자체를 알 방법이 없었다.

systemd 서비스 자격증명(Service Credentials)과 $CREDENTIALS_DIRECTORY

systemd는 서비스가 비밀번호·토큰 같은 민감한 값을 안전하게 받을 수 있도록 "자격증명(credentials)" 메커니즘을 제공한다. 유닛 파일에 LoadCredential= 같은 지시어를 쓰면, systemd(PID 1)가 그 값을 일반 프로세스 환경변수가 아니라, 스왑되지 않는 메모리에 백업된 임시 디렉터리 안의 파일로 준비해 두고, 그 디렉터리 경로 하나만 $CREDENTIALS_DIRECTORY 환경변수로 서비스 프로세스에 넘겨준다. 이 설계가 평범한 환경변수보다 안전하다고 여겨지는 이유는:

  • 자식 프로세스에게 상속되지 않고, 커널 수준의 접근 제어가 각 접근마다 적용된다.
  • 서비스가 시작될 때 마련되고 종료될 때 해제되며, 그 사이에는 값이 바뀌지 않는다.
  • 파일시스템 네임스페이스 격리가 걸리면 다른 서비스에서는 아예 보이지 않는다.

핵심은 이 디렉터리 경로가 systemd 자신에 의해서만 설정된다는 신뢰(trust)를 전제로 설계되었다는 점이다. util-linux의 login(1)은 systemd 서비스로 기동됐을 때 LoadCredential=login.noauth:... 같은 방식으로 "이 로그인은 인증을 건너뛰어도 된다"는 값을 안전하게 전달받기 위해, 시작 시점에 다음과 같이 이 디렉터리를 확인한다 (util-linux login-utils/login.c):

static void load_credentials(struct login_context *cxt)
{
  char str[32] = { 0 };
  char *env;
  struct path_cxt *pc;

  env = safe_getenv ("CREDENTIALS_DIRECTORY");
  if (!env)
    return;

  pc = ul_new_path ("%s", env);
  ...
  if (ul_path_read_buffer (pc, str, sizeof (str), "login.noauth") > 0
      && *str && strcmp (str, "yes") == 0)
    cxt->noauth = 1;   /* -f 옵션과 동일하게 인증을 건너뜀 */
  ...
}

그리고 이 noauth 플래그는 login이 실제로 root 권한으로 실행 중일 때만(getuid() == 0) 존중된다:

/* login -f, then the user has already been authenticated */
cxt.noauth = cxt.noauth && getuid () == 0 ? 1 : 0;

if (!cxt.noauth)
  loginpam_auth (&cxt);   /* PAM 비밀번호 인증 */

telnetd는 root 권한으로 동작하며 자식 프로세스로 login직접 root로 실행하기 때문에(getuid() == 0 조건은 항상 참), 이 코드는 "systemd가 이 값을 설정했으니 믿는다"는 전제가 깨졌다는 사실을 전혀 알아채지 못한다.

취약점 분석

공격 흐름

  1. 로컬의 저권한 사용자가 자신이 쓸 수 있는 임의의 디렉터리(예: 자기 홈 디렉터리 아래)에 login.noauth 라는 파일을 만들고 그 안에 문자열 yes를 적어 둔다. 여기까지는 root 권한이 전혀 필요 없다 — 자기 소유 파일을 만드는 평범한 행위다.
  2. 같은 사용자가 telnet localhost (또는 telnetd가 열려 있는 아무 호스트)로 접속하면서, TELNET NEW-ENVIRON 옵션을 통해 CREDENTIALS_DIRECTORY=<자신이 만든 디렉터리 경로>를 서버에 전달한다.
  3. telnetd는 state.c의 서브옵션 처리기에서 이 값을 검증 없이 setenv("CREDENTIALS_DIRECTORY", "<공격자 디렉터리>", 1)로 자신의 프로세스 환경에 반영한다.
  4. telnetd/pty.cstart_login()scrub_env()(LD_*, IFS 등 30년 전 목록만 검사)를 호출한 뒤 loginexecv()로 실행한다 — CREDENTIALS_DIRECTORY는 이 목록에 없으므로 그대로 살아남아 login에게 상속된다.
  5. login이 시작되자마자 load_credentials()$CREDENTIALS_DIRECTORY를 읽고, 그 디렉터리 안의 login.noauth 파일 내용이 yes인 것을 확인해 cxt->noauth = 1을 세팅한다. login은 telnetd(root)의 자식으로 이미 root로 실행 중이므로 이 플래그는 그대로 유효해진다.
  6. loginpam_auth()(비밀번호 확인)가 통째로 건너뛰어진다. 이후 login은 평소처럼 "login:" 프롬프트를 띄우고, 공격자가 여기서 root를 포함해 시스템에 존재하는 어떤 사용자 이름이든 입력하면 비밀번호 확인 없이 그 사용자의 셸이 시작된다.

그림으로 보는 신뢰 경계 붕괴

   [정상적인 systemd 기동 경로]                   [telnetd를 경유하는 공격 경로]

   systemd(PID 1)                                 공격자(로컬 저권한 사용자)
        │  LoadCredential=login.noauth:...              │  1) ~/fake_cred/login.noauth = "yes" 생성
        ▼                                                ▼
   +-----------------------------+                 telnet 클라이언트
   | /run/credentials/<unit>/    |                       │  NEW-ENVIRON: CREDENTIALS_DIRECTORY=~/fake_cred
   |   login.noauth = "yes"      |  <- 커널 접근제어 O          ▼
   +-----------------------------+                 telnetd (root)  ---- setenv() 무검증 반영
        │ CREDENTIALS_DIRECTORY=/run/credentials/<unit>   │
        ▼                                                ▼
   +-----------------------------+                 scrub_env(): LD_*, IFS 만 제거
   |   login(1) (root로 실행)     |                       │  CREDENTIALS_DIRECTORY 그대로 통과
   |   safe_getenv(CRED_DIR) 신뢰 |  <-- 이 신뢰 전제가            ▼
   +-----------------------------+      telnetd 경로에선   login(1) (telnetd의 자식, root)
        │ noauth=1 (정상: systemd만 설정 가능)  깨짐        │  safe_getenv(CRED_DIR) == 공격자 디렉터리
        ▼                                                ▼
   비밀번호 인증 없이 서비스 계정 로그인               login.noauth == "yes" 확인 → noauth=1
                                                          │
                                                          ▼
                                                    loginpam_auth() 건너뜀
                                                          │
                                                          ▼
                                                    "login:" 프롬프트에 root 입력
                                                    → 비밀번호 없이 root 셸 획득

왼쪽은 설계가 의도한 흐름이고, 오른쪽은 telnetd가 "이 환경변수는 systemd만 설정한다"는 암묵적 전제를 검증하지 않고 그대로 통과시켜 버리면서 오른쪽 화살표 지점(login(1)CREDENTIALS_DIRECTORY를 신뢰하는 지점)이 왼쪽과 동일한 코드인데도 완전히 다른 신뢰 수준의 입력을 받게 되는 것이 근본 원인이다. 이는 전형적인 "혼란된 대리인(confused deputy)" 패턴이다 — login은 자신이 항상 systemd에 의해서만 이 값을 받는다고 가정했지만, telnetd라는 또 다른 특권 프로세스가 그 가정을 깨는 경로를 열어준 것이다.

수정 방법

수정은 GNU inetutils 쪽에서 이루어졌다(util-linux의 login(1)CREDENTIALS_DIRECTORY를 신뢰하는 동작 자체는 systemd에 의해 정상적으로 실행될 때는 문제가 없으므로 변경되지 않았다). 수정 커밋(4db2f19f4caac03c7f4da6363c140bd70df31386, 2026-02-15)은 telnetd/pty.cstart_login()에서 loginexecv()하기 직전에 이 변수를 명시적으로 제거한다:

   cmd = expand_line (login_invocation);
   if (!cmd)
     fatal (net, "can't expand login command line");
   argcv_get (cmd, "", &argc, &argv);

+  /* util-linux's "login" introduced an authentication bypass method
+   * via environment variable "CREDENTIALS_DIRECTORY" in version 2.40.
+   * Clear it from the environment before executing "login" to prevent
+   * abuse via Telnet.
+   */
+  unsetenv ("CREDENTIALS_DIRECTORY");
+
   execv (argv[0], argv);

이 패치는 scrub_env()의 오래된 고정 차단목록에 항목을 추가하는 대신, login 실행 직전에 별도로 콕 집어 지우는 방식을 택했다 — 커밋 메시지도 "쉽게 백포트할 수 있는 간단한 수정"이라고 명시한다.

   [패치 후]

   telnet 클라이언트 --NEW-ENVIRON: CREDENTIALS_DIRECTORY=~/fake_cred--> telnetd(root)
                                                                            │
                                                                setenv()로 여전히 반영됨
                                                                            │
                                                                            ▼
                                                        scrub_env(): LD_*, IFS 제거 (기존과 동일)
                                                                            │
                                                        + unsetenv("CREDENTIALS_DIRECTORY")  <- 신규
                                                                            ▼
                                                            execv("/bin/login", ...)
                                                                            │
                                                        login(1)의 safe_getenv(CRED_DIR) == NULL
                                                                            │
                                                                            ▼
                                                            load_credentials()가 즉시 return
                                                            → noauth 세팅 불가 → 정상 비밀번호 인증 강제

즉 공격자가 telnet 세션 안에서 아무리 CREDENTIALS_DIRECTORY를 설정해도, login이 실행되는 시점에는 이미 그 변수가 프로세스 환경에서 사라진 뒤이므로 load_credentials()env == NULL로 즉시 반환해 noauth가 세팅될 조건 자체가 사라진다.

이 수정은 GNU inetutils 2.8에 포함됐고, Debian은 각 배포판에 백포트했다 (bullseye 2:2.0-1+deb11u4, bookworm 2:2.4-2+deb12u3, trixie 2:2.6-3+deb13u3, unstable 2:2.8-2, DLA-4527-1 / DSA-6144-1).

완화책

  • 근본적으로 telnet 프로토콜은 평문 인증 정보 전송, 취약한 옵션 협상 이력(이번 건 포함 1995년부터 반복된 환경변수 신뢰 문제) 등으로 인해 원격 관리 용도로는 OpenSSH 등으로 대체하는 것이 권장된다.
  • telnetd를 반드시 유지해야 한다면 패치 적용 전까지 서비스를 비활성화하거나, 신뢰할 수 없는 네트워크에서의 접근을 차단한다.

참고 자료

미해결/불확실 지점

  • login이 "login:" 프롬프트에서 사용자 이름을 어떻게 받아 최종적으로 argv에 반영하는지 (즉 telnetd가 자동 로그인용으로 USER 환경변수 등을 넘겨주는 별도 경로가 있는지)는 이 CVE의 핵심 트리거(인증 우회)와 무관해 깊이 추적하지 않았다 — 공격자가 인증 없이도 "login:" 프롬프트에 원하는 사용자 이름을 직접 타이핑할 수 있다는 점만으로 공격이 성립하므로 별도 확인이 필요하지 않다고 판단했다.
  • util-linux 저장소의 GitHub 미러(util-linux/util-linux)에서 load_credentials()/ safe_getenv() 소스를 확인했으나, 이 함수가 정확히 몇 버전(2.40 마이너 릴리스)부터 존재했는지 커밋 이력까지 대조하지는 못했다 — NVD/벤더 권고에 명시된 "util-linux 2.40" 이라는 진술을 그대로 신뢰했다.