| 항목 | 내용 |
|---|---|
| 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 프로토콜(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() 경로로 들어간다 — 즉 이름에 대한 어떤 화이트리스트도
없다.
사실 "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는 서비스가 비밀번호·토큰 같은 민감한 값을 안전하게 받을 수 있도록
"자격증명(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가 이 값을 설정했으니 믿는다"는
전제가 깨졌다는 사실을 전혀 알아채지 못한다.
- 로컬의 저권한 사용자가 자신이 쓸 수 있는 임의의 디렉터리(예: 자기 홈 디렉터리 아래)에
login.noauth라는 파일을 만들고 그 안에 문자열yes를 적어 둔다. 여기까지는 root 권한이 전혀 필요 없다 — 자기 소유 파일을 만드는 평범한 행위다. - 같은 사용자가
telnet localhost(또는 telnetd가 열려 있는 아무 호스트)로 접속하면서, TELNET NEW-ENVIRON 옵션을 통해CREDENTIALS_DIRECTORY=<자신이 만든 디렉터리 경로>를 서버에 전달한다. - telnetd는
state.c의 서브옵션 처리기에서 이 값을 검증 없이setenv("CREDENTIALS_DIRECTORY", "<공격자 디렉터리>", 1)로 자신의 프로세스 환경에 반영한다. telnetd/pty.c의start_login()이scrub_env()(LD_*, IFS 등 30년 전 목록만 검사)를 호출한 뒤login을execv()로 실행한다 —CREDENTIALS_DIRECTORY는 이 목록에 없으므로 그대로 살아남아login에게 상속된다.login이 시작되자마자load_credentials()가$CREDENTIALS_DIRECTORY를 읽고, 그 디렉터리 안의login.noauth파일 내용이yes인 것을 확인해cxt->noauth = 1을 세팅한다.login은 telnetd(root)의 자식으로 이미 root로 실행 중이므로 이 플래그는 그대로 유효해진다.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.c의 start_login()에서 login을 execv()하기 직전에 이 변수를 명시적으로
제거한다:
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를 반드시 유지해야 한다면 패치 적용 전까지 서비스를 비활성화하거나, 신뢰할 수 없는 네트워크에서의 접근을 차단한다.
- NVD CVE-2026-28372 — 공식 CVE 설명, CVSS, CWE-829 분류. (검증됨)
- 수정 커밋 4db2f19 — "telnetd: don't allow systemd service credentials", GNU inetutils cgit — 실제 패치 diff와 커밋 메시지를 직접 확인. (검증됨, 1차 자료)
- 최초 보고 — Ron Ben Yizhak, bug-inetutils 메일링 리스트, 2026-02 — 발견자 본인의 기술 설명 및 공격 절차. (검증됨, 1차 자료)
- oss-security: "CVE-2026-28372: Telnetd Vulnerability Report" 및 후속 스레드 — CVE 배정 경위와 관련 논의. (검증됨)
- GNU inetutils
telnetd/state.c(NEW-ENVIRON 처리) 및telnetd/pty.c—setenv()/scrub_env()실제 소스 확인. (검증됨, 1차 자료) - util-linux
login-utils/login.c—load_credentials()/safe_getenv()로직 확인. (검증됨, 1차 자료; 다만 저장소는 GitHub 미러이며 공식 배포 태그와의 완전 일치는 별도 확인하지 않음) - systemd.io — System and Service Credentials —
$CREDENTIALS_DIRECTORY설계 의도와 보안 모델 설명. (검증됨) - Debian Security Tracker — CVE-2026-28372 — 배포판별 수정 버전, DLA/DSA 번호. (검증됨)
- "27 Years old Telnet Vulnerability..." — Cyber Security News, 1995년 원본 취약점 설명 — 1995년
LD_PRELOAD사건의 역사적 배경. (보조 자료, 기술적 핵심 수치는 인용하지 않고 배경 설명에만 사용)
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" 이라는 진술을 그대로 신뢰했다.