Go 主机安全面试:Linux TIOCSTI 终端输入注入与提权链路检测
TIOCSTI 是 tty 的一个 ioctl,含义是“把字符塞回终端输入队列”。它不改文件、不建进程、不产生网络连接,却能让一个低权限进程把命令打进另一个会话的终端,包括 root 正在操作的终端。历史上它被用来做终端逃逸:在一个受限 shell 里注入命令,等管理员在同一个终端里继续操作时,命令就以管理员的身份被执行。
这类攻击的技术含量不高,检测难点也不在 syscall 本身,而在于“字符进了谁的终端、当时谁在终端上、之后发生了什么”这三层信息需要关联。只看一次 ioctl(TIOCSTI),既可能是一次攻击,也可能来自屏幕阅读器、串口工具或自动化运维脚本。
岗位场景
攻击者拿到低权限 shell(Web RCE、弱口令、容器逃逸前)
-> 发现当前 shell 的 stdin 不是 tty,或自身处于受限 shell
-> 打开 /dev/pts/N 或自己的控制终端
-> ioctl(fd, TIOCSTI, "curl ... | sh\n") 逐字符注入
-> 管理员在同一个终端里执行下一条命令,或退出受限 shell
-> 注入内容被 shell 当作键盘输入执行
-> Go Agent 关联 ioctl 调用、tty 设备、会话、注入字节与后续进程
-> 输出“谁向哪个终端注入了什么、目标会话属于谁、是否已执行”这里有一个容易被忽略的前提:TIOCSTI 注入的是终端行规程的输入缓冲,不是某个进程的 stdin 文件描述符。目标进程必须正在从该终端读输入,注入内容才有机会被执行。
高频面试题
1. TIOCSTI 到底做了什么?它和写 /dev/pts/N、写 /proc/PID/fd/0 有什么区别?
简洁答案: TIOCSTI 通过 tty 行规程把字节当作“键盘输入”送进终端输入队列,后续由终端的读者(通常是 shell 或前台进程)读取。写 /dev/pts/N 是向终端输出方向写,内容显示在屏幕上;写 /proc/PID/fd/0 只是往该 fd 指向的对象写,若 fd 是 tty 且以写方式打开,效果偏输出侧,不能稳定地伪装成按键输入。
关键知识点:
- tty 是字符设备,有输入侧和输出侧。键盘、串口、pty master 写入属于输入侧;程序
write到 tty 通常走输出侧。 TIOCSTI的语义是注入输入字符,最终调用行规程的receive_buf,和真实按键走的是同一条输入路径。- shell 读取的是输入侧,因此注入的
\n会被当成回车,注入的\x04可能被当成 EOF。 - 注入是按字节进行的。一次 ioctl 只送一个字节,攻击工具通常循环调用。
- 目标 tty 不一定是攻击者自己的控制终端;能否注入取决于权限检查,见下一题。
Go 落地思路: 事件模型里把 tty 设备号、tty 路径、会话 ID、控制终端标记分开存。不要用“文件路径”一个字段表达所有终端对象,/dev/pts/3 会被复用,设备号加时间窗才可靠。
2. 内核如何限制 TIOCSTI?dev.tty.legacy_tiocsti、CAP_SYS_ADMIN 和控制终端三者是什么关系?
简洁答案: 在较新的内核中,TIOCSTI 先检查 legacy_tiocsti 开关:开关关闭且调用者没有 CAP_SYS_ADMIN 时直接返回 EIO。通过这一关后,如果目标 tty 不是调用者的控制终端,且调用者没有 CAP_SYS_ADMIN,返回 EPERM。因此普通进程只能往自己的控制终端注入,跨终端注入需要特权。
关键知识点:
dev.tty.legacy_tiocsti是 sysctl 开关,对应内核编译项CONFIG_LEGACY_TIOCSTI;编译项决定默认值,sysctl 允许运行时调整。- 判定顺序很重要:先看开关与能力,再看“是否自己的控制终端”。关掉开关后,没有
CAP_SYS_ADMIN的进程连自己的终端也不能注入。 - 这里的
capable(CAP_SYS_ADMIN)检查的是初始 user namespace 的能力;容器里拿到同名 capability 不等于能通过。 - “自己的控制终端”来自进程的 session/controlling tty 关系,不是“当前 fd 指向这个 tty”。打开
/dev/pts/3并不会让它变成你的控制终端。 - 老内核没有这个 sysctl,行为更宽松;讨论利用面时必须带内核版本,不能一概而论。
Go 落地思路: 规则里把 legacy_tiocsti 的当前值作为上下文特征,而不是在 Agent 启动时缓存一次。这个值可以在运行期被修改,缓存会让规则长期失真。
3. 为什么说 TIOCSTI 是“终端逃逸”而不是普通命令执行?典型利用链是什么?
简洁答案: 因为它绕过的是“当前进程能执行什么”的限制,把命令送进了另一个受信任的输入通道。典型链路是:受限 shell 或低权限进程向父终端注入命令,等管理员在同一终端执行下一条命令或退出受限环境时,注入内容以管理员权限运行。
关键知识点:
- 受限 shell、
rbash、只允许特定命令的运维入口,限制的是当前进程可执行的命令,不限制它对控制终端的 ioctl。 - 攻击者常注入“下载并执行”“添加 SSH key”“反弹 shell”这类短命令,末尾带换行。
- 注入成功后不一定立刻执行,可能要在输入队列里等待;检测上表现为 ioctl 与后续进程之间有明显时间差。
- 管理员用
sudo、su后仍在同一终端操作时,风险最高,因为后续输入由高权限 shell 读取。 - 这不是内核提权漏洞,不需要打内核补丁;它利用的是终端输入模型和人的操作习惯。
Go 落地思路: 攻击链关联不要只按 PID 父子关系。注入进程和最终执行进程可能没有父子关系,应该按“tty 会话 + 时间窗 + 注入字节”关联。
4. 采集侧有哪些可行手段?eBPF、auditd、pty 监控各自能拿到什么?
简洁答案: eBPF 可以在 ioctl 或 tty 相关内核函数上取到调用进程、fd、tty 设备、命令码和参数字节,适合实时检测;auditd 可以通过 syscall 规则记录 ioctl,但参数是用户态指针,默认拿不到注入内容;监控 pty master 只能看到经过该 pty 的数据,覆盖不到真实串口和已有终端。
关键知识点:
ioctl(fd, TIOCSTI, &ch)的第三个参数是指向单字节的指针。eBPF 需要用bpf_probe_read_user读取,并做长度和失败处理。TIOCSTI的命令码是0x5412,但不要在规则里只写魔法数字,代码里用常量并注明来源。- 单纯 hook
sys_ioctl只能看到“尝试”,看不到内核是否返回EPERM/EIO;要判断成功,需要看返回值或 hook 更靠内的 tty 函数。 - auditd 的 syscall 规则能给出
pid、uid、auid、comm、exe、tty,适合做溯源;注入字节需要额外的 TTY 审计记录。 - 内核的 TTY 审计记录格式类似
ioctl=TIOCSTI ... data=<hex>,但它受 tty 审计开关和整体审计开关影响,不能假设默认开启。
Go 落地思路: 采集器把“syscall 尝试事件”和“tty 审计内容事件”分成两类,用 pid + tty + 时间窗 合并。不要在采集端就丢弃没有内容的事件,成功与否本身就是信号。
5. 如何区分正常使用和恶意注入?哪些场景会合法调用 TIOCSTI?
简洁答案: 合法调用通常来自屏幕阅读器、串口/终端自动化、测试工具和某些远程运维组件,特征是固定程序、固定终端、低频、内容可解释。恶意的特征是低权限进程、短时间大量单字节调用、跨会话、注入内容包含下载执行或持久化命令。
关键知识点:
- 单字节循环是强特征:正常工具往往成批处理,恶意 PoC 为了简单会一个字符一次 ioctl。
- 关注调用者基线:
brltty、终端模拟器、自动化测试框架出现属于合理范围;nginx、php-fpm、java出现就很可疑。 - 关注目标终端归属:Web 服务进程往 root 的 pts 注入,优先级应远高于用户往自己的终端注入。
- 关注注入内容:
curl|sh、wget、base64 -d、写authorized_keys、bash -i等应提升风险。 - 关注上下文:刚发生过 Web RCE、异常子进程、提权尝试时,同样的 ioctl 风险权重要提高。
Go 落地思路: 规则输出不要只有布尔值,输出可解释的特征向量:调用者基线分、跨会话标记、字节频率、内容命中项、前后攻击链分数。告警里带这些字段,客户才容易判断。
6. 如果客户说“装了 Agent 却没告警”,你会怎么排查?
简洁答案: 按“有没有产生事件、有没有采到内容、规则有没有命中、告警有没有被降噪或上报失败”四段排查,先确认内核版本和 legacy_tiocsti 状态,再确认采集点是否覆盖该调用路径。
关键知识点:
- 先确认攻击是否真的成功:
EPERM/EIO的尝试和成功注入是两回事,客户可能只看到了失败的 PoC。 - 确认内核版本:老内核没有
legacy_tiocsti,行为和新内核不同;新内核关掉开关后普通进程根本无法注入。 - 确认采集方式:只 hook
sys_ioctl的实现可能漏掉其他进入 tty 的路径;只依赖 audit 的实现可能因为规则未加载而全漏。 - 确认字段:fd 到 tty 的解析是否成功,容器内看到的
/dev/pts/N与宿主是否一致,设备号是否被命名空间影响。 - 确认规则:条件是否要求“注入内容非空”,而该采集路径拿不到内容,导致永远不命中。
- 确认告警链路:是否被白名单、去重、限流或上报背压吞掉。
Go 落地思路: 给采集链路加分段计数器:ioctl_seen、tty_resolved、data_read_ok、rule_matched、alert_enqueued、upload_ok。排障时看哪一段掉零,比翻日志快得多。
7. 检测规则怎么设计才能既覆盖攻击又控制误报?
简洁答案: 用分层规则:底层记录所有 TIOCSTI 调用作为事实,中层按调用者基线、终端归属和频率打分,高层只在满足“跨会话 + 可疑内容 + 攻击链上下文”时出高优告警。
关键知识点:
- 第一层不告警,只入库,保证可回溯;事件保留时间和成本要明确。
- 第二层用白名单收窄合法程序,但白名单要绑定可执行文件哈希或签名,不能只绑进程名。
- 第三层做组合条件,例如“非 tty 相关进程 + 目标不是自己的控制终端 + 10 秒内超过 20 次调用”。
- 对内容做归一化后再匹配,避免大小写、空格、编码变体绕过;但不要把内容当唯一判据。
- 规则要有版本和灰度,先观察模式跑一段时间,再决定是否提升告警级别。
Go 落地思路: 规则引擎输入统一成结构化事件,内容和元数据分开;匹配器只读字段,不直接解析原始日志。这样同一规则可以复用于 eBPF 和 audit 两种来源。
8. 用 Go 写一个最小 PoC 时,哪些细节最容易写错?
简洁答案: 最常见的是把 TIOCSTI 当成写字符串的接口、忘记逐字节调用、打开终端时用了错误的读写模式,以及没有区分“注入成功”和“命令已执行”。
关键知识点:
unix.IoctlSetInt适合传整数参数,但TIOCSTI需要传字节地址,应该用能传指针对应的 ioctl 封装,或直接走unix.Syscall。- 每次只注入一个字节,循环处理字符串;注入
\n才会触发 shell 执行。 - 打开目标终端通常需要读写权限;只读打开可能无法完成 ioctl,具体取决于设备和权限。
- ioctl 返回成功只代表字符进入输入队列,不代表目标进程已经读取或执行。
- PoC 必须在隔离环境验证,不要对生产终端做实验。
Go 落地思路: 把 PoC 拆成“打开终端、注入字节、等待、验证”四步,每步单独记录错误。这样既能用于白盒测试,也能作为采集器的回归用例。
func inject(fd int, s string) error {
for i := 0; i < len(s); i++ {
b := []byte{s[i]}
if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), unix.TIOCSTI, uintptr(unsafe.Pointer(&b[0]))); errno != 0 {
return errno
}
}
return nil
}这段代码只说明调用形状,真实 PoC 还要处理终端打开方式、平台常量差异和权限失败。
9. 生产环境应该怎么处置?直接关闭 legacy_tiocsti 有什么风险?
简洁答案: 优先评估并关闭 dev.tty.legacy_tiocsti,同时保留 CAP_SYS_ADMIN 例外给确实需要的辅助技术;关闭前要在测试环境验证屏幕阅读器、串口工具、自动化运维和旧版终端程序是否依赖它。
关键知识点:
- 关闭开关后,没有
CAP_SYS_ADMIN的进程无法再使用TIOCSTI,包括注入自己的终端。 - 需要该能力的合法程序应单独授予最小能力,而不是整体放开开关。
- 关闭是缓解,不是检测替代;已有攻击链、内核层绕过和其他终端注入路径仍需监控。
- 还要配合终端会话管理:减少共享 root 终端、限制多人共用账号、审计高权限会话。
- 变更要有回滚方案,记录内核版本、开关值和验证结果。
Go 落地思路: 把 sysctl 值、内核版本、Agent 版本一起上报,规则和服务端都能感知配置漂移。不要只在安装时检查一次。
10. 如果面试官问“这个点能体现什么工程能力”,怎么答?
简洁答案: 能体现三件事:把冷门内核机制讲清楚、把检测做成可解释的分层链路、把客户漏报拆成可验证的采集段。重点不是知道 TIOCSTI 这个名字,而是知道它在真实攻击链里的位置和检测边界。
关键知识点:
- 能区分机制、权限、利用条件和检测信号,不把“有 syscall”直接等同于“有攻击”。
- 能说明不同采集手段的覆盖面和盲区,而不是宣称 eBPF 万能。
- 能设计可回滚的缓解措施,并说明兼容性风险。
- 能用最小 PoC 和白盒用例验证规则,而不是只靠线上告警观察。
- 能把误报治理和告警解释做成产品能力,而不是单纯调阈值。
Go 落地思路: 面试时用一个具体链路串起来:事件怎么采、字段怎么建模、规则怎么分层、告警怎么解释、漏报怎么定位、缓解怎么回滚。比背概念更有说服力。
Go 落地思路
一个务实的实现可以分成四层:
- 采集层:eBPF 或 auditd 产生
ioctl事实事件,尽量带上pid、uid、comm、exe、fd、tty设备号、命令码、返回值。 - 补全层:用
/proc/<pid>/fd、/proc/<pid>/stat、/proc/<pid>/status补进程和会话信息;容器场景要记录 namespace,避免宿主和容器内的终端路径混淆。 - 规则层:先做调用者基线,再做终端归属和频率判断,最后结合内容与攻击链上下文打分。
- 输出层:告警带可解释字段和原始事件引用,支持回放和客户自查。
事件结构可以保持扁平,避免过度建模:
type TTYInjectEvent struct {
PID int
UID uint32
Comm string
Exe string
TTYMajor uint32
TTYMinor uint32
SessionID uint32
IsOwnTTY bool
Bytes []byte
Ret int
Timestamp int64
}规则判断时,Ret == 0 只说明 ioctl 成功,不能说明命令执行。真正的高优告警应该再看目标终端后续是否产生了对应进程。
学习要点
TIOCSTI注入的是终端输入队列,不是进程内存,也不是 stdin 文件。- 权限判定有两道门:
legacy_tiocsti/CAP_SYS_ADMIN,以及“是否自己的控制终端”。 - 老内核和新内核行为不同,检测和处置都要带版本上下文。
- eBPF 能拿到调用和参数字节,auditd 更适合做 syscall 级溯源,两者可以互补。
- 合法场景存在,白名单要绑定程序身份而不是只绑进程名。
- 关闭 sysctl 是有效缓解,但会影响依赖该能力的辅助技术。
小练习
- 在隔离虚拟机中查看当前内核的
dev.tty.legacy_tiocsti值,并解释它与CONFIG_LEGACY_TIOCSTI的关系。 - 写一个最小 Go 程序,向自己的控制终端注入一个换行符,观察 shell 行为;再尝试向另一个用户的终端注入,记录返回的 errno。
- 用 auditd 添加一条
ioctl审计规则,观察TIOCSTI调用产生的记录,列出你能拿到的字段和拿不到的字段。 - 设计一条规则,要求同时满足“调用者不是终端相关程序”“目标不是自己的控制终端”“10 秒内超过 20 次调用”才告警,并说明每个条件的误报风险。
- 假设客户报告漏报,写出你会检查的六个分段计数器和对应的验证命令。
- 关闭
legacy_tiocsti后,验证一个屏幕阅读器或串口工具是否受影响,并设计回滚步骤。
复盘
这道题的核心不是记住一个 ioctl 编号,而是理解终端输入模型和检测边界。能把“注入成功”和“命令执行”分开,能把“有事件”和“有告警”分开,能把“缓解”和“检测”分开,回答就达到了主机安全岗位的要求。
