CVE-2026-24061:GNU InetUtils Telnetd 身份验证绕过漏洞
漏洞描述
GNU InetUtils telnetd(版本 1.9.3 至 2.7)存在高危远程认证绕过漏洞。攻击者可通过 Telnet 协议的环境变量协商机制,在连接阶段注入恶意 USER 环境变量(如 USER="-f root")。由于 telnetd 在处理 NEW_ENVIRON 子选项时未对客户端提供的环境变量值进行任何安全校验,并且在启动登录进程时直接使用该变量构造 /bin/login 命令,导致系统执行 login -f root。而 login 的 -f 参数会跳过身份验证,直接以指定用户身份登录,从而使攻击者无需密码即可获得 root shell,完全控制目标服务器。

漏洞分析
攻击者执行:
USER='-f root' telnet -a 目标服务器IP 23
USER='-f root':设置恶意环境变量
-a 或 --login:告诉telnet客户端发送 USER 环境变量到服务器
首先进行环境变量修改

使用telnetd_setup函数对服务器进行初始化,然后进入telnetd_run函数开始解析用户发送的命令
首先是telnetd_setup函数

在514行

第一处的作用是清除现有USER环境变量
第二处会进行终端类型的获取,并会触发协议处理getterminaltype函数

可以看到该函数在telnetd/utility.c
调到这里,对该函数进行审计
int
getterminaltype (char *uname, size_t len)
{
int retval = -1;
settimer (baseline);
#if defined AUTHENTICATION
/*
* Handle the Authentication option before we do anything else.
* Distinguish the available modes by level:
*
* off: Authentication is forbidden.
* none: Volontary authentication.
* user, valid, other: Mandatory authentication only.
*/
if (auth_level < 0)
send_wont (TELOPT_AUTHENTICATION, 1);
else
{
if (auth_level > 0)
send_do (TELOPT_AUTHENTICATION, 1);
else
send_will (TELOPT_AUTHENTICATION, 1);
ttloop (his_will_wont_is_changing (TELOPT_AUTHENTICATION));
if (his_state_is_will (TELOPT_AUTHENTICATION))
retval = auth_wait (uname, len);
}
#else /* !AUTHENTICATION */
(void) uname; /* Silence warning. */
(void) len; /* Silence warning. */
#endif
#ifdef ENCRYPTION
send_will (TELOPT_ENCRYPT, 1);
#endif /* ENCRYPTION */
send_do (TELOPT_TTYPE, 1);
send_do (TELOPT_TSPEED, 1);
send_do (TELOPT_XDISPLOC, 1);
send_do (TELOPT_NEW_ENVIRON, 1);
send_do (TELOPT_OLD_ENVIRON, 1);
#ifdef ENCRYPTION
ttloop (his_do_dont_is_changing (TELOPT_ENCRYPT)
|| his_will_wont_is_changing (TELOPT_TTYPE)
|| his_will_wont_is_changing (TELOPT_TSPEED)
|| his_will_wont_is_changing (TELOPT_XDISPLOC)
|| his_will_wont_is_changing (TELOPT_NEW_ENVIRON)
|| his_will_wont_is_changing (TELOPT_OLD_ENVIRON));
#else
ttloop (his_will_wont_is_changing (TELOPT_TTYPE)
|| his_will_wont_is_changing (TELOPT_TSPEED)
|| his_will_wont_is_changing (TELOPT_XDISPLOC)
|| his_will_wont_is_changing (TELOPT_NEW_ENVIRON)
|| his_will_wont_is_changing (TELOPT_OLD_ENVIRON));
#endif
#ifdef ENCRYPTION
if (his_state_is_will (TELOPT_ENCRYPT))
encrypt_wait ();
#endif
if (his_state_is_will (TELOPT_TSPEED))
{
static unsigned char sb[] =
{ IAC, SB, TELOPT_TSPEED, TELQUAL_SEND, IAC, SE };
net_output_datalen (sb, sizeof sb);
}
if (his_state_is_will (TELOPT_XDISPLOC))
{
static unsigned char sb[] =
{ IAC, SB, TELOPT_XDISPLOC, TELQUAL_SEND, IAC, SE };
net_output_datalen (sb, sizeof sb);
}
if (his_state_is_will (TELOPT_NEW_ENVIRON))
{
static unsigned char sb[] =
{ IAC, SB, TELOPT_NEW_ENVIRON, TELQUAL_SEND, IAC, SE };
net_output_datalen (sb, sizeof sb);
}
else if (his_state_is_will (TELOPT_OLD_ENVIRON))
{
static unsigned char sb[] =
{ IAC, SB, TELOPT_OLD_ENVIRON, TELQUAL_SEND, IAC, SE };
net_output_datalen (sb, sizeof sb);
}
if (his_state_is_will (TELOPT_TTYPE))
net_output_datalen (ttytype_sbbuf, sizeof ttytype_sbbuf);
if (his_state_is_will (TELOPT_TSPEED))
ttloop (sequenceIs (tspeedsubopt, baseline));
if (his_state_is_will (TELOPT_XDISPLOC))
ttloop (sequenceIs (xdisplocsubopt, baseline));
if (his_state_is_will (TELOPT_NEW_ENVIRON))
ttloop (sequenceIs (environsubopt, baseline));
if (his_state_is_will (TELOPT_OLD_ENVIRON))
ttloop (sequenceIs (oenvironsubopt, baseline));
if (his_state_is_will (TELOPT_TTYPE))
{
char *first = NULL, *last = NULL;
ttloop (sequenceIs (ttypesubopt, baseline));
/*
* If the other side has already disabled the option, then
* we have to just go with what we (might) have already gotten.
*/
if (his_state_is_will (TELOPT_TTYPE) && !terminaltypeok (terminaltype))
{
free (first);
first = xstrdup (terminaltype);
for (;;)
{
/* Save the unknown name, and request the next name. */
free (last);
last = xstrdup (terminaltype);
_gettermname ();
if (terminaltypeok (terminaltype))
break;
if ((strcmp (last, terminaltype) == 0)
|| his_state_is_wont (TELOPT_TTYPE))
{
/*
* We've hit the end. If this is the same as
* the first name, just go with it.
*/
if (strcmp (first, terminaltype) == 0)
break;
/*
* Get the terminal name one more time, so that
* RFC1091 compliant telnets will cycle back to
* the start of the list.
*/
_gettermname ();
if (strcmp (first, terminaltype) != 0)
{
free (terminaltype);
terminaltype = xstrdup (first);
}
break;
}
}
}
free (first);
free (last);
}
return retval;
}
其中

send_do (TELOPT_XXX, 1); 这一系列调用向远程Telnet客户端发送了一个“DO”请求,告知客户端“我希望你启用 TELOPT_TTYPE(终端类型)、TELOPT_TSPEED(终端速度)等特性”
ttloop 函数的调用参数 his_will_wont_is_changing(TELOPT_XXX) 是关键。它的作用是检查远程客户端对于特定选项(如TELOPT_TTYPE)的“WILL”(同意)或“WONT”(拒绝)响应状态是否发生了变化。
将 ttloop 的参数设置为这些状态检查的逻辑“或”,意味着 ttloop 会持续循环运行,直到所有发送出去的选项请求都收到了客户的明确响应(无论是同意还是拒绝),也就是状态不再“正在变化”。
因此,ttloop 在此处扮演了同步等待客户端回复的角色,确保协议协商步骤正确完成后再继续。

ttloop函数的实质是循环调用io_drain()

此时io_drain()在utility.c,我们跳到utility.c去查看其函数内容

读取网络数据:将客户端发送来的原始字节流读入缓冲区 (netibuf)
然后执行telrcv()

跳到telnetd/state.c查看其内容
void
telrcv (void)
{
register int c;
static int state = TS_DATA;
while ((net_input_level () > 0) & !pty_buffer_is_full ())
{
c = net_get_char (0);
#ifdef ENCRYPTION
if (decrypt_input)
c = (*decrypt_input) (c);
#endif /* ENCRYPTION */
switch (state)
{
case TS_CR:
state = TS_DATA;
/* Strip off \n or \0 after a \r */
if ((c == 0) || (c == '\n'))
break;
/* FALL THROUGH */
case TS_DATA:
if (c == IAC)
{
state = TS_IAC;
break;
}
/*
* We now map \r\n ==> \r for pragmatic reasons.
* Many client implementations send \r\n when
* the user hits the CarriageReturn key.
*
* We USED to map \r\n ==> \n, since \r\n says
* that we want to be in column 1 of the next
* printable line, and \n is the standard
* unix way of saying that (\r is only good
* if CRMOD is set, which it normally is).
*/
if ((c == '\r') && his_state_is_wont (TELOPT_BINARY))
{
int nc = net_get_char (1);
#ifdef ENCRYPTION
if (decrypt_input)
nc = (*decrypt_input) (nc & 0xff);
#endif /* ENCRYPTION */
/*
* If we are operating in linemode,
* convert to local end-of-line.
*/
if (linemode
&& net_input_level () > 0
&& (('\n' == nc) || (!nc && tty_iscrnl ())))
{
net_get_char (0); /* Remove from the buffer */
c = '\n';
}
else
{
#ifdef ENCRYPTION
if (decrypt_input)
(*decrypt_input) (-1);
#endif /* ENCRYPTION */
state = TS_CR;
}
}
pty_output_byte (c);
break;
case TS_IAC:
gotiac:
switch (c)
{
/*
* Send the process on the pty side an
* interrupt. Do this with a NULL or
* interrupt char; depending on the tty mode.
*/
case IP:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
send_intr ();
break;
case BREAK:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
send_brk ();
break;
/*
* Are You There?
*/
case AYT:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
recv_ayt ();
break;
/*
* Abort Output
*/
case AO:
{
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
ptyflush (); /* half-hearted */
init_termbuf ();
if (slctab[SLC_AO].sptr
&& *slctab[SLC_AO].sptr != (cc_t) (_POSIX_VDISABLE))
pty_output_byte (*slctab[SLC_AO].sptr);
netclear (); /* clear buffer back */
net_output_data ("%c%c", IAC, DM);
set_neturg ();
DEBUG (debug_options, 1, printoption ("td: send IAC", DM));
break;
}
/*
* Erase Character and
* Erase Line
*/
case EC:
case EL:
{
cc_t ch;
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
ptyflush (); /* half-hearted */
init_termbuf ();
if (c == EC)
ch = *slctab[SLC_EC].sptr;
else
ch = *slctab[SLC_EL].sptr;
if (ch != (cc_t) (_POSIX_VDISABLE))
pty_output_byte ((unsigned char) ch);
break;
}
/*
* Check for urgent data...
*/
case DM:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
SYNCHing = stilloob (net);
settimer (gotDM);
break;
/*
* Begin option subnegotiation...
*/
case SB:
state = TS_SB;
SB_CLEAR ();
continue;
case WILL:
state = TS_WILL;
continue;
case WONT:
state = TS_WONT;
continue;
case DO:
state = TS_DO;
continue;
case DONT:
state = TS_DONT;
continue;
case EOR:
if (his_state_is_will (TELOPT_EOR))
send_eof ();
break;
/*
* Handle RFC 10xx Telnet linemode option additions
* to command stream (EOF, SUSP, ABORT).
*/
case xEOF:
send_eof ();
break;
case SUSP:
send_susp ();
break;
case ABORT:
send_brk ();
break;
case IAC:
pty_output_byte (c);
break;
}
state = TS_DATA;
break;
case TS_SB:
if (c == IAC)
state = TS_SE;
else
SB_ACCUM (c);
break;
case TS_SE:
if (c != SE)
{
if (c != IAC)
{
/*
* bad form of suboption negotiation.
* handle it in such a way as to avoid
* damage to local state. Parse
* suboption buffer found so far,
* then treat remaining stream as
* another command sequence.
*/
/* for DIAGNOSTICS */
SB_ACCUM (IAC);
SB_ACCUM (c);
subpointer -= 2;
SB_TERM ();
suboption ();
state = TS_IAC;
goto gotiac;
}
SB_ACCUM (c);
state = TS_SB;
}
else
{
/* for DIAGNOSTICS */
SB_ACCUM (IAC);
SB_ACCUM (SE);
subpointer -= 2;
SB_TERM ();
suboption (); /* handle sub-option */
state = TS_DATA;
}
break;
case TS_WILL:
willoption (c);
state = TS_DATA;
continue;
case TS_WONT:
wontoption (c);
state = TS_DATA;
continue;
case TS_DO:
dooption (c);
state = TS_DATA;
continue;
case TS_DONT:
dontoption (c);
state = TS_DATA;
continue;
default:
syslog (LOG_ERR, "telnetd: panic state=%d\n", state);
printf ("telnetd: panic state=%d\n", state);
exit (EXIT_FAILURE);
}
}
}
该函数从网络读取攻击者发送的数据并进行解析,下面是解析的过程代码
case TS_DATA:
if (c == IAC)
{
state = TS_IAC;
break;
}
/*
* We now map \r\n ==> \r for pragmatic reasons.
* Many client implementations send \r\n when
* the user hits the CarriageReturn key.
*
* We USED to map \r\n ==> \n, since \r\n says
* that we want to be in column 1 of the next
* printable line, and \n is the standard
* unix way of saying that (\r is only good
* if CRMOD is set, which it normally is).
*/
if ((c == '\r') && his_state_is_wont (TELOPT_BINARY))
{
int nc = net_get_char (1);
#ifdef ENCRYPTION
if (decrypt_input)
nc = (*decrypt_input) (nc & 0xff);
#endif /* ENCRYPTION */
/*
* If we are operating in linemode,
* convert to local end-of-line.
*/
if (linemode
&& net_input_level () > 0
&& (('\n' == nc) || (!nc && tty_iscrnl ())))
{
net_get_char (0); /* Remove from the buffer */
c = '\n';
}
else
{
#ifdef ENCRYPTION
if (decrypt_input)
(*decrypt_input) (-1);
#endif /* ENCRYPTION */
state = TS_CR;
}
}
pty_output_byte (c);
break;
case TS_IAC:
gotiac:
switch (c)
{
/*
* Send the process on the pty side an
* interrupt. Do this with a NULL or
* interrupt char; depending on the tty mode.
*/
case IP:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
send_intr ();
break;
case BREAK:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
send_brk ();
break;
/*
* Are You There?
*/
case AYT:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
recv_ayt ();
break;
/*
* Abort Output
*/
case AO:
{
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
ptyflush (); /* half-hearted */
init_termbuf ();
if (slctab[SLC_AO].sptr
&& *slctab[SLC_AO].sptr != (cc_t) (_POSIX_VDISABLE))
pty_output_byte (*slctab[SLC_AO].sptr);
netclear (); /* clear buffer back */
net_output_data ("%c%c", IAC, DM);
set_neturg ();
DEBUG (debug_options, 1, printoption ("td: send IAC", DM));
break;
}
/*
* Erase Character and
* Erase Line
*/
case EC:
case EL:
{
cc_t ch;
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
ptyflush (); /* half-hearted */
init_termbuf ();
if (c == EC)
ch = *slctab[SLC_EC].sptr;
else
ch = *slctab[SLC_EL].sptr;
if (ch != (cc_t) (_POSIX_VDISABLE))
pty_output_byte ((unsigned char) ch);
break;
}
/*
* Check for urgent data...
*/
case DM:
DEBUG (debug_options, 1, printoption ("td: recv IAC", c));
SYNCHing = stilloob (net);
settimer (gotDM);
break;
/*
* Begin option subnegotiation...
*/
case SB:
state = TS_SB;
SB_CLEAR ();
continue;
case WILL:
state = TS_WILL;
continue;
case WONT:
state = TS_WONT;
continue;
case DO:
state = TS_DO;
continue;
case DONT:
state = TS_DONT;
continue;
case EOR:
if (his_state_is_will (TELOPT_EOR))
send_eof ();
break;
/*
* Handle RFC 10xx Telnet linemode option additions
* to command stream (EOF, SUSP, ABORT).
*/
case xEOF:
send_eof ();
break;
case SUSP:
send_susp ();
break;
case ABORT:
send_brk ();
break;
case IAC:
pty_output_byte (c);
break;
}
state = TS_DATA;
break;
case TS_SB:
if (c == IAC)
state = TS_SE;
else
SB_ACCUM (c);
break;
case TS_SE:
if (c != SE)
{
if (c != IAC)
{
/*
* bad form of suboption negotiation.
* handle it in such a way as to avoid
* damage to local state. Parse
* suboption buffer found so far,
* then treat remaining stream as
* another command sequence.
*/
/* for DIAGNOSTICS */
SB_ACCUM (IAC);
SB_ACCUM (c);
subpointer -= 2;
SB_TERM ();
suboption ();
state = TS_IAC;
goto gotiac;
}
SB_ACCUM (c);
state = TS_SB;
}
else
{
/* for DIAGNOSTICS */
SB_ACCUM (IAC);
SB_ACCUM (SE);
subpointer -= 2;
SB_TERM ();
suboption (); /* handle sub-option */
state = TS_DATA;
}
break;
这里通过解析 TELNET 协议,去提取环境变量,然后进入suboption函数处理子选项
这里是漏洞的核心点

case TELOPT_NEW_ENVIRON:
case TELOPT_OLD_ENVIRON:
{
// ... (前面的协议解析代码)
while (!SB_EOF()) {
c = SB_GET();
// ... 解析出 varp (变量名) 和 valp (变量值) ...
}
*cp = '\0';
if (valp)
setenv(varp, valp, 1); // <--- !!!漏洞核心触发点!!!
else
unsetenv(varp);
break;
}
setenv(varp, valp, 1);被无条件执行,意味着:
- 无来源验证:代码没有检查这个
SEND指令是否应该由客户端主动发起。在Telnet协议中,通常应由服务器发送SEND来请求变量,客户端用IS回应。这里客户端却“命令”服务器设置变量,而服务器接受了。 - 无内容过滤:代码没有对
valp(即-f root)的内容进行任何安全检查。它没有过滤以破折号(-)开头的值,而这类值正好可以被login程序解析为命令行参数。 - 无权限检查:代码直接设置了环境变量,特别是敏感的
USER变量,而没有验证其值是否合理(例如,是否是一个合法的用户名)。
相当于攻击者发送
IAC SB NEW-ENVIRON SEND VAR "USER" VALUE "-f root" IAC SE
后端就会进行如下解析:
1. 遇到 USERVAR → 识别为新变量开始
2. 收集 "USER" 到 varp
3. 遇到 VALUE → 切换到值收集模式
4. 收集 "-f root" 到 valp
5. 循环结束,执行:setenv("USER", "-f root", 1)
从而使得环境变量被恶意设置
然后启动登录进程

登录口

跳转到telnetd/pty.c进行审计
void
start_login (char *host, int autologin, char *name)
{
char *cmd;
int argc;
char **argv;
(void) host; /* Silence warnings. Diagnostic use? */
(void) autologin;
(void) name;
scrub_env ();
/* Set the environment variable "LINEMODE" to indicate our linemode */
if (lmodetype == REAL_LINEMODE)
setenv ("LINEMODE", "real", 1);
else if (lmodetype == KLUDGE_LINEMODE || lmodetype == KLUDGE_OK)
setenv ("LINEMODE", "kludge", 1);
cmd = expand_line (login_invocation);
if (!cmd)
fatal (net, "can't expand login command line");
argcv_get (cmd, "", &argc, &argv);
execv (argv[0], argv);
syslog (LOG_ERR, "%s: %m\n", cmd);
fatalperror (net, cmd);
}
scrub_env ();
清理危险环境变量

进入该函数之后可以看见,其未对USER过滤!!!
模板展开引擎
得知未对USER过滤,那么攻击者构造的恶意USER就会被传入,下面是登录时对登录模板进行解析的流程

// 登录模板默认配置
login_invocation = " -p -h %h %?u{-f %u}{%U}"
// 当没有认证用户时(%u为空)

cmd = expand_line (login_invocation);

exp.cp = (char *)line;
_expand_block(&exp);
该函数对login_invocation开始展开


_expand_cond (exp);
展开条件

- 解析前半部分:
模板引擎逐字复制 -p -h
遇到 %h,调用 _expand_var(exp),将其展开为客户端的主机名或IP地址(例如 192.168.1.100)。此时中间结果为 -p -h 192.168.1.100
- 进入条件块
**%?u**(关键决策点):
遇到 %?u,_expand_cond() 被调用
_expand_cond() 看到 ?,知道这是一个条件判断。它先尝试展开 %u
%u 代表“已认证的用户名”。在攻击场景下,攻击者没有进行任何认证,因此 _expand_var() 在处理 %u 时返回 NULL
由于 p 为 NULL(%u 无值),_expand_cond() 执行 else 分支:
_skip_block(exp):跳过第一个块 {-f %u}。这个块本意是“如果已认证,就添加 -f 参数和用户名”
_expand_block(exp):展开第二个块 {%U}
- 展开
**{%U}**(恶意变量注入):
_expand_block() 开始处理 {%U} 内的内容
遇到 %U,再次调用 _expand_cond()(此时没有 ?,进入else分支)
_expand_var(exp) 被调用以处理 %U
%U 的含义是:USER 环境变量的值。
由于漏洞前期 suboption() 函数已成功设置了 USER='-f root',且 scrub_env() 未将其清除,此时 getenv("USER") 返回的正是 -f root
因此,%U 被展开为字符串 -f root,并附加到结果中

p = _var_short_name (exp);

- 最终拼接:
将所有部分拼接起来,最终的登录命令变为:login -p -h 192.168.1.100 -f -f root(第一个 -f 默认参数,第二个 -f root 来自注入的变量。对于 login 程序,-f 意味着“跳过密码验证”,其后的 root 是要登录的用户。)
官方修复
完全修复

1. 修复策略:源头修复
补丁没有仅仅修复 USER 变量,而是创建了一个通用的 sanitize() 函数,对所有可能被用于构建命令行的变量进行统一过滤。
static char * sanitize (const char *u) {
/* 忽略以 '-' 开头或包含Shell元字符的值,因为它们可能引发问题 */
if (u && *u != '-' && !u[strcspn (u, "\t\n !\"#$&'()*:;<=>?[\\^`{|}~")])
return u;
else
return ""; // 或返回空字符串
}
2. 修复范围:全面覆盖
补丁将 sanitize() 函数应用到了 _var_short_name() 函数中所有从不可信来源获取的变量
这种全面过滤的策略防止了攻击者未来可能从其他参数寻找注入点。
3. 过滤规则:双重检查
sanitize() 函数执行两个关键检查:
- 不以破折号开头 (
*u != '-'):防止值被解释为命令行参数(如-f、--option)。 - 不包含Shell元字符:使用
strcspn()检查是否包含空白字符和! \" # $ & ' ( ) * ; < = > ? [ \ ^{ | } ~` 等可能被Shell用于命令分隔、重定向或扩展的特殊字符。
临时修复

case 'U':
{
/* Ignore user names starting with '-' or containing shell
metachars, as they can cause trouble. */
char const *u = getenv("USER");
return xstrdup((u && *u != '-'
&& !u[strcspn(u, "\t\n !\"#$&'()*;<=>?[\\^`{|}~")])
? u : "");
}
修复逻辑:
- 检查是否以
-开头:*u != '-' - 检查是否包含Shell元字符:
!u[strcspn(u, "危险字符集")] - 如果检查失败,返回空字符串:
? u : ""
参考
https://www.openwall.com/lists/oss-security/2026/01/20/2
https://codeberg.org/inetutils/inetutils/commit/ccba9f748aa8d50a38d7748e2e60362edd6a32cc
https://codeberg.org/inetutils/inetutils/commit/fd702c02497b2f398e739e3119bed0b23dd7aa7b