做安全题目久了会发现一个有意思的现象:越是看起来"什么都没给"的题,越容易卡住新手。PHPINFO 信息泄露这一类题目就是典型——打开页面,满屏都是表格、版本号、配置项,密密麻麻几千行,很多人第一反应是"这要怎么找",然后关掉页面去搜题解。可这类题恰恰是整套信息泄露题型里最基础、也最能反映一个人检索习惯的一道题。这篇内容我打算把这道题从头到尾拆一遍,包括PHPINFO 页面到底是什么、泄露了哪些东西、在几千行输出里怎么高效定位目标、命令行方式怎么批量检索,以及我在实操中反复踩过的几个坑。无论你是刚接触 CTF 的新手,还是想给自己的检索流程做个复盘的人,应该都能从里面拿到可以直接复用的东西。
1. 面对一屏全是表格的页面:这道题在考什么
1.1 第一步不是找 flag,而是确认自己看到的到底是什么
很多人打开题目地址,看到一个白底黑字、密密麻麻的页面,第一反应是"这页面坏了"。其实这页面一点没坏,它是 PHP 内置函数phpinfo()被调用后的标准输出。你可以把phpinfo()理解成 PHP 给自己做的一次"全身体检报告":它会把当前运行环境的几乎所有可公开信息一次性倾倒出来,包括 PHP 版本、编译参数、已加载的扩展模块、php.ini的路径与生效配置、当前请求的 HTTP 头、服务器软件版本、文件系统路径、环境变量等等。
这份"体检报告"本来是给开发者排错用的。比如你在本地搭环境,发现某个扩展没生效,看一眼 phpinfo 里有没有对应的模块块,立刻就能判断问题出在安装还是配置;再比如上传大小限制和预期不符,直接查upload_max_filesize和post_max_size两行就清楚了。问题是,一旦这样的页面被放到线上且没有做访问限制,等于把服务器的内部构造说明书挂在了门牌上。这道题考的就是一个很朴素的能力:当信息被过量地摆在你面前时,你能不能快速识别出哪一条是有价值的。
所以正确的开局动作不是埋头滚动页面,而是先在心里建立一个判断——我现在看的这份东西,理论上应该包含哪些类别的信息,哪些类别里有可能藏着出题人放的"东西"。有了这个框架,检索才不是瞎翻。
1.2 一屏 phpinfo 里,哪几块内容是重点
phpinfo()的输出并不是一堆无规律的乱码,它有非常清晰的段落结构。按从上到下的顺序,通常能看到这么几块:
- PHP 基本信息表:版本号、
System(操作系统信息)、Server API、Build Date、Loaded Configuration File、DOCUMENT_ROOT、SERVER_SOFTWARE等。 - 配置详情(Configuration):分成
PHP Core、date、session、mysqli等一大串模块,每个模块一张表,列出配置项和它的默认值/当前值。 - Environment(环境变量):这里会打印
$_ENV和$_SERVER里的内容,包括你这次请求带的头、服务器设置的环境变量。 - PHP Variables:当前脚本作用域里的变量。
- HTTP 头与模块信息:
HTTP_ACCEPT、HTTP_USER_AGENT、HTTP_HOST、REQUEST_URI等。 - License 段落:纯文字,没有任何检索价值,可以直接跳过。
对做题的人来说,真正值得优先扫描的是Environment 和 PHP Variables 两块。原因很直接:配置项的值基本是环境本身的客观描述,出题人想藏东西,最方便的做法是把值塞进环境变量里,而环境变量会被原样打印出来。另外,PHP Variables里打印的$_SERVER数组,也常被用来承载题目自定义的键值对。
我在实际做题时有个固定的扫描顺序:先扫Environment段,再扫PHP Variables,最后才回头翻配置项。这个顺序不是玄学,是因为前两块的信息量小、变化大,配置项则是几百行长得几乎一样的表格,放在最后看效率最高。
1.3 这道题背后的真实意义:信息泄露为什么被单独列成一类
有人会想,不就是个 phpinfo 页面吗,能有多严重。放在真实项目里,这类页面的危害路径其实相当清晰。攻击者拿到DOCUMENT_ROOT就知道你的站点根目录绝对路径,拿到Loaded Configuration File就知道php.ini在哪,拿到disable_functions就能判断哪些危险函数被禁了、绕过思路该往哪个方向走,拿到open_basedir就知道目录限制的范围,拿到数据库扩展的版本就能对照已知的公开漏洞。
更麻烦的是allow_url_fopen、allow_url_include、file_uploads、upload_tmp_dir、session.save_path这几个配置项的组合值,它们决定了很多后续操作的可行性。这些信息单独看都不致命,但拼在一起,等于给对手递了一张地图。信息泄露之所以被单独归成一类,原因就在这里:它本身不是"一刀致命"的漏洞,而是把后续所有攻击的成本都拉低了。
理解了这层,做题时的心态会不一样。你不是在几千行文字里玩找不同,你是在练习一种"从泄露面推导可利用点"的思维方式,这个思维方式在实际工作中做安全自查时同样管用。
2. 在几千行输出里定位目标:手工检索的正确姿势
2.1 浏览器查找功能的三个隐藏细节
绝大多数人的第一反应是Ctrl + F然后输入flag。这一步没错,但有三个细节经常被忽略,导致明明内容就在页面上却搜不到。
第一个是大小写。浏览器的查找默认不区分大小写(Chrome、Firefox、Edge 都是这样),所以这条一般不会翻车。但如果你用的是某些终端浏览器或者自定义了查找设置,就得留意这一点了。
第二个是分段加载。页面越长,浏览器的渲染和查找就越吃资源。我遇到过页面超过一万行的情况,Ctrl + F输入第一个字符时明显卡顿,输完全词后光标跳了半天才定位过去。如果页面特别长,可以先用Ctrl + Home回到顶部,再执行查找,命中后连续按回车逐个跳转,这样比反复重输关键词稳。
第三个是关键词的读音问题。出题人有时候不会直白地写flag{...},而是写成FLAG、Flag、ctf、key、secret、token,甚至换成拼音或者缩写的变量名。只搜flag一个词,命中率并不高。我通常会依次搜这几个:flag、ctf、key、secret、token、admin、pass。这套词表来自我自己的经验积累,不是标准答案,但覆盖面比搜单个词强得多。
还有一个非常容易被忽略的点:搜索结果可能在页面底部的 License 段落之后,也可能夹在两张超长配置表中间的某个环境变量里。搜索命中后不要急着复制,先看看它所在的那一行属于哪个段落,通常上下文就能告诉你这个值的用途。
2.2 搜索词命中之后,真正的判断才刚开始
Ctrl + F只是一把筛子,筛出来的东西还要判断。我见过不少人搜到flag字样就直接复制提交,结果发现是flag这个单词出现在某个配置项的名字里,比如某个模块的说明文字提到"识别 flag 参数",和题目答案毫无关系。这种假阳性在长页面里出现频率很高。
判断标准我一般用两条:一是这个值是否具备答案的典型格式(有花括号、有固定长度、有明显的随机串特征);二是这个值是否出现在一个"人为塞进去"的位置,比如环境变量区、自定义变量区,而不是出现在某段官方说明文字中间。
举个具体的例子。在 Environment 段里,如果看到一行为MYSQL_HOST = 127.0.0.1,这是正常的部署配置;但如果突然冒出一行CUSTOM_XXX = ZmxhZ3s...,长度明显异常、字符集明显不像正常配置值,那基本就是目标。这时候别急着下结论,先看它是不是被编码过。
提示:看到一串形如
ZmxhZ3s...的字符,先怀疑 base64。这类字符串的典型特征是只包含大小写字母和数字、长度为 4 的倍数、结尾常常带=。拿去解码往往能直接还原。
2.3 手工检索的上限在哪里
手工搜索适合题目输出量在几千行以内的情况。一旦超过这个量级,浏览器搜索会变得非常难受,而且人的注意力很难在长时间滚动中保持稳定。我自己就吃过这个亏:某次在页面上来回翻了快十分钟,最后发现目标其实在第一屏往下一点的位置,被一张表格的分隔行挡住了视觉焦点。
这就是为什么我一直建议,凡是能从命令行抓下来的内容,就不要纯靠眼睛找。不是手工检索不好,而是它的可靠性和可重复性都有上限。人眼找东西受状态影响太大,命令行找东西则是确定性的,同样的输入永远得到同样的输出,还能留下记录方便复盘。
3. 把页面拉回本地:命令行检索的完整链路
3.1 抓取环节:先把页面完整地拿到手
命令行做检索的第一步是把页面保存成本地文件。最基础的一条命令是:
curl -s "http://<题目地址>/phpinfo.php" -o phpinfo.html这里有几个参数值得说明。-s是静默模式,屏蔽进度条和错误提示,输出更干净;-o指定保存路径。如果你不关心保存,只想看看响应头,可以换成-i。
但直接这么抓有个风险:服务器可能返回 gzip 压缩后的内容,保存下来的文件是二进制,grep上去全是乱码。这时候加--compressed让 curl 自动解压:
curl -s --compressed "http://<题目地址>/phpinfo.php" -o phpinfo.html抓完之后先别急着搜索,做两个快速检查:
wc -l phpinfo.html head -n 20 phpinfo.htmlwc -l告诉你这个文件有多少行,心里有个数;head看开头,确认抓到的是 HTML 而不是错误页、登录页或者跳转提示。这一步非常关键,我遇到过好几次抓下来的是一个 302 跳转后的空页面,白白在那儿 grep 了半天。
如果题目环境的会话是需要的(比如需要带上 cookie 才能访问),那就得在 curl 里加-H "Cookie: xxx"或者-b cookie.txt。关于怎么拿到这个 cookie,标准做法是在浏览器开发者工具的 Network 面板里看对应请求的 Request Headers,把 Cookie 整行复制出来。这套流程在任何需要保持登录态的题目里都通用。
3.2 过滤环节:grep 的几种组合打法
文件到手之后,检索就变成了纯文本处理,效率比浏览器高一个数量级。
最朴素的一条:
grep -i "flag" phpinfo.html-i忽略大小写。命中的行会带行号(加-n)打印出来。但phpinfo()生成的 HTML 有个特点:键和值是分在两个<td>里的,键在class="e"的单元格,值在class="v"的单元格。这意味着如果目标值本身不含flag字样,只搜flag是完全搜不到的。
所以更实用的做法是带上上下文:
grep -i -n -A 3 -B 3 "flag" phpinfo.html-A和-B分别打印命中行之后和之前的若干行。这样即便关键词只出现在键名里,你也能顺带看到它对应的值。
第二种打法是用正则直接匹配答案的形态。绝大多数题目的答案有固定包裹格式:
grep -oiE "[a-zA-Z0-9_]*\{[^}]{4,}\}" phpinfo.html这条正则匹配任意"标识符 + 花括号包裹内容"的组合,-o只输出匹配部分,-i忽略大小写,-E启用扩展正则。它能一次性把页面上所有长得像答案的东西全部捞出来,包括你不认识的格式。
第三种打法是用 Python 做结构化解析。当页面结构复杂、或者需要按表格键值对精确提取时,纯文本 grep 会显得笨拙:
import re import requests from bs4 import BeautifulSoup html = requests.get("http://<题目地址>/phpinfo.php").text soup = BeautifulSoup(html, "html.parser") for tr in soup.find_all("tr"): tds = tr.find_all("td") if len(tds) == 2: key = tds[0].get_text(strip=True) val = tds[1].get_text(strip=True) if re.search(r"[a-zA-Z0-9_]*\{[^}]{4,}\}", val): print(f"{key} => {val}")这段代码的逻辑是遍历所有表格行,取出键值对,只打印值里含花括号包裹内容的行。相比 grep,它的好处是输出干净、键值对应明确,做完一遍之后你对"这个值属于哪个配置项"一目了然。
3.3 命中之后:解码、验证与去重
检索出候选值之后,往往还有最后一公里。
第一种情况是编码。前面提过 base64,除此之外还有 URL 编码(%66%6c%61%67)、十六进制(666c6167)、反转字符串。识别方法很简单:看字符集。纯0-9a-f且长度为偶数的,试十六进制;含%的,试 URL 解码;只含大小写字母和数字、长度为 4 的倍数的,试 base64。Python 一行就能验证:
import base64 print(base64.b64decode("ZmxhZ3s...").decode())第二种情况是重复出现。同一个值可能在页面里出现多次,比如既出现在环境变量里,又出现在$_SERVER数组里。grep输出会有多行,用sort -u去重:
grep -oiE "[a-zA-Z0-9_]*\{[^}]{4,}\}" phpinfo.html | sort -u第三种情况是假阳性。有些框架的模板、CSS、JS 里也会出现花括号结构,比如 CSS 规则body { margin: 0 }。这种情况在 phpinfo 页面里比较少见,但如果页面上被注入了别的内容,就有可能。判断方法还是看上下文:真正的答案一般出现在表格的值单元格里,而不是<style>或<script>标签内。
4. 那些被当成背景板的字段:PHPINFO 里真正值钱的情报
4.1 路径、版本与配置项构成的信息拼图
做完这道题之后,我建议你回头再看一遍页面,这次不看 flag,看那些你没注意过的字段。它们的价值远比一道题的答案大。
| 字段 | 泄露了什么 | 后续可能被怎么用 |
|---|---|---|
DOCUMENT_ROOT | 站点根目录的绝对路径 | 判断文件包含、路径穿越的起点 |
Loaded Configuration File | php.ini的绝对路径 | 定位配置文件,判断可读范围 |
allow_url_fopen/allow_url_include | 是否允许包含远程资源 | 决定远程包含类操作是否可行 |
disable_functions | 被禁用的函数清单 | 直接反映防御策略的重点方向 |
open_basedir | 目录访问限制范围 | 判断文件操作能触达的边界 |
upload_tmp_dir/session.save_path | 临时目录与会话目录路径 | 判断上传文件的落地位置 |
Server API | PHP 的运行方式 | 判断是模块模式还是 CGI/FPM |
PHP Version | 版本号 | 对照该版本已公开的缺陷清单 |
这张表里的每一行,单独看都只是"服务器的一个属性",但组合起来就是一张相当完整的环境地图。做安全自查的时候,思路正好反过来:把这些项挨个过一遍,凡是暴露出敏感值的,就该考虑是否需要在生产环境屏蔽。
我自己的习惯是,每做完一道信息泄露题,就在笔记里记下"这道题泄露了哪些字段、这些字段在真实场景里对应什么风险"。攒到现在,这份笔记已经成了我给别人做安全培训时最常用的素材。
4.2 从泄露面往下推:这道题的延伸思路
在合法的练习环境里,phpinfo 页面的价值不止于找 flag。它经常作为一道题的"第一关",后续环节需要用到这一关拿到的信息。
举几个典型方向。拿到DOCUMENT_ROOT之后,如果题目同时存在文件包含点,就能推出该包含哪些路径;拿到session.save_path之后,如果存在上传点,就能推断会话文件的落盘位置;拿到upload_tmp_dir之后,可以判断临时文件在请求结束前是否还留在磁盘上。这些思路在练习环境里走一遍,对理解"信息泄露如何被串联利用"非常有帮助。
需要强调的是,这些操作的前提是环境属于你自己或者你有明确授权。练习环境本身就是为了让人把原理摸清楚而搭建的,在授权范围内折腾是学习,范围之外就是另一回事了。
4.3 HTTP 头那一段为什么值得单独看
phpinfo()页面里有一段HTTP_*开头的字段,打印的是你这次请求带过去的头信息。很多人直接略过,其实这一段有两个用途。
一个用途是理解请求是怎么被服务器接收的。比如你看到的HTTP_USER_AGENT就是你的浏览器标识,HTTP_ACCEPT是你能接受的内容类型,HTTP_HOST是你请求的域名。对照浏览器开发者工具里的请求头,能帮助你建立起"请求头 → 服务端视角"的对应关系,这个对应关系在之后做任何需要自定义头的题目时都用得上。
另一个用途是,部分题目会把信息藏在自定义头里。也就是说,服务端读取了你带过去的某个头,然后回显在 phpinfo 里。这种情况下,如果你只看页面内容不看请求,很容易想不明白这个值是从哪来的。遇到看不懂的字段,回头看一眼自己的请求头,往往就通了。
5. 我在这道题上踩过的坑和对应的排查表
5.1 五个高频失败原因
这道题看起来简单,但我确实见过不少人卡了很久,而且卡的原因高度集中。把它们列成一张表,遇到问题的时候对着查会快很多。
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
搜索flag完全无结果 | 关键词被改写或编码 | 换搜ctf、key、secret,或直接抓下来跑正则 |
| 抓到的是空文件或错误页 | 请求被重定向、缺少会话 | 用-i看响应头,检查状态码是否为 200 |
| 保存的 HTML 打开是乱码 | 内容被 gzip 压缩 | curl 加--compressed |
| 搜到多处结果但都不像答案 | 假阳性 | 看值所在单元格的类型,优先关注环境变量区 |
| 拿到值但提交失败 | 编码未还原或带了多余字符 | 检查首尾空格、换行,尝试解码 |
这张表里我最想强调第一行。关键词被改写这件事,在题目设计里非常常见,出题人为了增加一点难度,会把标志改成不那么直白的样子。我自己的习惯是维护一个关键词列表,从最直白的开始依次试,而不是死磕一个词。列表大概是:flag、FLAG、ctf、CTF、key、secret、token、pass、admin。这套词表覆盖了绝大多数常见变体。
第二行也值得展开。状态码检查这一步,我建议养成固定动作:抓取之后立刻curl -I看一眼响应头。200是正常,301、302意味着有跳转,403意味着被拦了,500意味着服务端出错。不同状态码对应的下一步完全不一样,先搞清楚状态,再决定要不要继续。
5.2 拿到候选值之后的自检清单
从检索到提交之间,还有一段容易出错的空隙。我现在的固定自检流程是四条:
- 去空白:把值前后可能带的空格、换行、制表符全部去掉。HTML 里的值有时候会被换行折断,复制出来会带一个不可见字符。
- 查完整性:花括号是否成对,结尾是否被截断。页面上长字符串换行显示的情况很常见。
- 验证编码:如果字符集看着不对劲,先怀疑编码,解一次再判断。
- 核对格式:对照题目要求的提交格式,有的环境不要求带外层包裹,有的要求带,搞错一个字符就是失败。
第四条听着很傻,但确实是最常见的失败原因之一。我见过有人把整行KEY = flag{xxx}原样提交上去,结果当然不对——多出来的键名和等号都是噪音。
5.3 一个反直觉的经验:先别急着翻页
还有一个经验值得单独说。这类题目的答案位置,往往比你想象的靠前。因为题目生成的 phpinfo 输出里,Environment段通常位于页面中部偏前的位置,而很多人下意识认为"东西应该藏在最后面",于是从底部往上翻,反而绕开了目标区。
我现在的做法是:拿到页面先看wc -l的行数,然后直接定位到Environment这个标题所在的行号,从那里开始看。
grep -n "Environment" phpinfo.html拿到行号后,用sed打印那一段附近的几十行:
sed -n '1200,1350p' phpinfo.html这个套路比漫无目的地滚动高效太多。定位到结构位置再局部阅读,是一种通用的长文档处理方式,换成别的大体量文本同样适用。
6. 换个视角:为什么这个页面绝不该出现在线上
6.1 泄露的代价来自"信息拼图"而不是单条信息
把这道题当成一次防御视角的演练,收获会更大。很多人对信息泄露的危害感受不深,是因为总在想"泄露一个版本号能怎样"。问题在于,攻击者从来不会只用一条信息。版本号告诉你该找哪个方向的缺陷,绝对路径告诉你文件操作的起点,配置项告诉你哪些路是通的、哪些是堵的。这些信息叠加之后,试探的成本被大幅压缩。
我做过一个粗略的对比:在一个完全黑盒的目标上做安全自查,光是摸清技术栈和目录结构就要花不少时间;而如果对方恰好暴露了一个 phpinfo 页面,这部分时间几乎可以压缩到几分钟。这个差距就是信息泄露的实际代价。
6.2 落地层面怎么处理
从处置角度,我的建议分三层。
第一层是删除。这类页面通常是开发阶段为了排错临时放的,功能上线后就不该继续存在。凡是名字里带info、test、phpinfo、debug的文件,都值得在发布前过一遍清单。
第二层是限制访问。如果确实需要在某个阶段保留,至少加上访问控制,比如限定来源地址、加基本认证、或者放在内网段。核心思路是让"未授权的人看不到"。
第三层是可观测。假设真的有人访问了这类页面,日志里应该看得出来。对这类路径配置访问告警,比事后翻日志要主动得多。
我在实际项目里见过一种情况:某个诊断页面被包在了一个需要登录的路由后面,看起来是安全的。但那个路由的鉴权逻辑本身有缺陷,绕一下就能直接访问。所以"加了鉴权"不等于"安全了",验证的时候一定要用未登录状态实际访问一遍,看返回的是登录页还是内容页。
6.3 一个可以立刻用上的自查脚本
最后分享一个我在做上线前检查时常用的小脚本,逻辑很简单:给一批常见路径发请求,看哪些返回了 200 且内容里带有 phpinfo 的特征字符串。
import requests paths = ["/phpinfo.php", "/info.php", "/test.php", "/php_info.php"] marker = "PHP Version" for p in paths: try: r = requests.get("http://<目标地址>" + p, timeout=5, allow_redirects=False) if r.status_code == 200 and marker in r.text: print(f"[命中] {p} 存在 phpinfo 输出") else: print(f"[正常] {p} 状态码 {r.status_code}") except requests.RequestException as e: print(f"[异常] {p} {e}")allow_redirects=False是刻意加的,因为很多环境的默认行为是跳转到首页,跟着跳会得到一个 200 但其实什么都没暴露,容易误判。这个脚本只用于你拥有或已获授权的环境,跑之前先确认范围。
我自己用下来的体会是,这道题真正的价值不在于找到那个字符串,而在于它逼着你把"看页面—抓页面—筛内容—验结果"这条链路走通一遍。这条链路一旦形成习惯,之后遇到任何体量的信息泄露题,不管是备份文件、源码泄露还是目录列表,处理思路都是同一套:先判断泄露面在哪,再用工具把内容拉下来做确定性的检索,最后对候选值做解码和验证。至于 flag 长什么样,反而是整件事里最不重要的部分。