打CTF刚开始那会儿,我碰到Bugku这套Web题里的eval题目,第一反应是去翻源码,结果翻了半天只看到一段短短的参数拼接,手忙脚乱地试了一堆payload,最后的flag还是靠群里老哥提示才拿到的。现在回想起来,这道题真正卡人的地方不是“会不会用system”,而是很难转过弯来——eval到底在eval什么?为什么我用一堆命令执行函数总是打不进去?这篇文章就围绕Bugku-web(eval)这一题,把从环境准备、漏洞原理、完整解题链路到同类变体延伸的整套思路重新梳理一遍。不管你是刚入门CTF的Web方向,还是已经在刷题但经常被PHP代码执行卡住,这篇都能给你一套能直接照着走的思路。
1. 为什么这道题值得反复琢磨:eval在CTF和真实Web中的位置
先说个结论:eval类题目是Web方向性价比极高的一类题目。它难度跨度大,入门版本就是给你一个明显的注入点,你直接往参数里塞命令执行函数就能拿flag;进阶版本则会加上括号绕过、关键字过滤、参数waf、函数禁用等等,考的东西从PHP语法一直延伸到服务端配置。Bugku这道题之所以被那么多人列为入门必刷,不是因为它难,而是因为它把“代码注入”这个核心概念讲得很清楚。
在你开始动手之前,我先把eval的作用说透。eval是一个PHP函数,它接收一个字符串,并且把这个字符串当作PHP代码来执行。听起来简单,但这里有个很多人忽略的点:eval执行的不是系统命令,而是PHP代码。也就是说,你往eval里传入ls这种命令字符串是不行的,它在PHP语法层面就直接报错;你要传的是system("ls"),让PHP先调用system函数,再让system去执行系统命令。这中间隔了一层,很多人第一次卡住就是卡在这。
这道题适合谁来学?我建议三类人必须刷一遍:一是刚入门CTF、想建立Web题目基本解题框架的新人;二是搞PHP开发但没怎么接触过安全视角,想了解一下“为什么不能随便把用户输入丢进eval”的开发者;三是准备面试Web安全岗位、需要把命令执行和代码执行讲清楚的人。题目本身虽然简单,但它牵扯出来的原理——字符串拼接、PHP语法闭合、系统命令执行、flag定位——在你往后刷几十道Web题的时候都会反复用到。
另外提一句,Bugku平台本身就是一个在线靶场,注册之后进到Web分类就能看到eval这道题。它不需要你自己搭环境,所有东西都在浏览器里完成,所以门槛很低。但正因为环境是别人搭好的,你要做的就是用黑盒的思路去探测、去猜测服务端代码长什么样,然后构造对应的payload。这个过程恰恰是CTF的精髓:你不是在写代码,你是在“猜代码”。
2. 靶场环境与题目初探:先摸清页面再动手
2.1 进入靶场后第一件事:打开开发者工具
很多新手一上来就把目光死盯着页面正文,其实这个习惯很不好。Web题目的信息不光在页面上,更多在URL参数、请求头、响应头、甚至JS文件里。Bugku的eval题通常只会给你一个参数点,但页面本身可能什么都没有,如果你不去看浏览器开发者工具里的请求信息,你连参数名都不知道。
我建议你按这个顺序做信息收集:
- 打开靶场URL,正常访问页面,用肉眼看看有什么内容。
- 按F12打开开发者工具,切到Network标签页,刷新页面,看请求了哪些资源。
- 点击请求,查看请求URL、请求方式(GET还是POST)、请求参数。
- 切到Console标签页,看有没有报错信息。
- 切到Sources标签页,看有没有引用额外的JS或PHP文件。
这套流程不管后续碰到什么Web题都用得上。特别是第一步和第二步,看起来只是“看一眼”,实际上决定了你知不知道往哪里注入。很多时候服务端会把参数名写在URL里,比如?cmd=something,你一看就明白注入点在cmd上。如果参数名写得比较隐晦,比如?data=1,你就要靠尝试去确认它是做什么的。
2.2 识别注入点:参数位置与请求方式的判断
在eval题目里,注入点往往就是一个通过GET或POST传参的值,服务端拿到这个值之后会拼进eval里执行。你需要先判断两件事:
- 注入点在哪里:是URL的query string,还是POST body,还是Cookie?Bugku这道题你基本只需要关注GET参数。
- 参数名是什么:常见的有cmd、code、exec、input、data等,具体看题目给的提示或者你抓到的请求。
判断请求方式比较简单:如果URL里直接能看到?xx=yy,那大概率是GET参数;如果你在开发者工具的Form Data或者Payload里看到参数,那可能是POST。对于eval题,GET参数是最常见的,因为简单直观,也方便在浏览器里直接改URL测试。
提示:如果你实在不知道参数名,可以尝试一些常规的键名去试探页面反应,比如
?cmd=phpinfo()、?code=phpinfo()。如果页面回显了PHP信息页面,说明参数找对了。这个技巧很适合各类代码执行题。
2.3 信息收集的几条底线思路
在动态题目里,服务端代码你是看不到的,但你可以通过调整输入来观察输出变化,从而推断代码结构。我总结了几条实用的底线思路供你参考:
- 输入一个合法但无意义的PHP表达式,比如
1,观察页面输出什么。 - 输入一个非法内容,比如
1",看看页面会不会报语法错误,报错信息往往能泄露代码片段。 - 输入一个PHP内置函数的调用,比如
phpinfo(),验证是否存在代码执行。 - 把参数值改成空,看看页面是否有默认输出或异常显示。
这些操作都是在“探测边界”——你知道服务端把输入放到了一个字符串里,但你不知道它周围拼了什么代码,所以你要用不同的输入去“戳”这个拼接过后的完整代码长什么样。这个过程很像盲人摸象,但多摸几下,形状就出来了。
3. eval代码执行的底层原理:从“填入字符串”到“执行代码”
3.1 PHP eval的运作机制
先看一段常见的示例代码:
<?php $a = $_GET['cmd']; eval("\$a = \"$a\";"); echo $a; ?>这段代码看起来只是把一个字符串塞进eval里,但你细品一下就发现问题了:用户传入的cmd值没有做任何过滤,就这么被拼到了一个双引号字符串里面,而这个字符串又被eval当成PHP代码执行。
我来拆解一下eval到底在做什么。你传入cmd=1,那么eval实际执行的代码是:
$a = "1";这是合法的PHP代码,执行完没问题。但如果你传入的是带特殊字符的内容,比如xxx"); phpinfo(); //,那拼完之后就变成:
$a = "xxx"); phpinfo(); //";我来拆一下这个payload的逻辑:
- 前面的
xxx");用于闭合掉原本的左双引号和分号,让当前PHP语句结束。 - 然后
phpinfo();是新插入的一条PHP语句,会被正常执行。 - 最后的
//会注释掉原本右双引号及之后的内容,避免语法错误。
你看,整个套路的核心就一个字:闭合。你得先搞清楚服务端代码拼接的上下文,然后构造内容,把自己要执行的代码“嵌入”进去,同时把服务端原本包裹你的引号、括号、分号处理妥当,不让语法报错。
3.2 为什么不能直接执行ls而要分两步
这个点很多人一直绕不明白。你可能会想:既然我可以执行PHP代码了,那ls不就是一个“命令”吗?为什么我传ls进去没反应?
原因我开头已经说了:eval执行的是PHP代码,不是系统命令。ls在PHP语法里什么都不是,它会直接报错。你要执行系统命令,就必须借助PHP提供的执行函数,比如system()、exec()、shell_exec()、passthru()等等。所以正确的思路是:
- 先闭合,让自己可以插入PHP代码。
- 再调用
system("ls")来执行系统命令。 - 查看回显,定位flag文件,用
cat命令读出来。
两步走,本质上是“先确保自己能执行PHP代码,再通过PHP函数去执行系统命令”。这个思路放之四海而皆准,不管后续遇到什么样的过滤,底层逻辑不变。
3.3 代码注入与命令注入的区别
我不止一次在群里看到有人把代码注入和命令注入混为一谈。严格来说,它们不是一回事:
- 代码注入(也就是eval漏洞):攻击者注入的是程序代码本身,目标语言是PHP、Python、JavaScript这类服务端脚本语言。你需要构造符合语言语法的payload,不一定是系统命令。
- 命令注入:攻击者注入的是操作系统命令,目标通常是shell。它往往发生在
system($cmd)、exec($cmd)这类直接拼命令字符串的位置,注入点拼接的是shell语法,比如;、&&、|这些。
在Bugku这道eval题里,本质上是代码注入。但因为最终你用system去执行了命令,所以链路变成了:代码注入到命令执行。搞清楚这一点,你在分析题目、构造payload的时候才不会走偏。比如你看到过滤了system,你就应该想“那我能不能改用exec?能不能用passthru?能不能用字符串拼接构造函数名?”而不是想着怎么绕过shell层面的限制。
4. 实战解题完整链路:从确认注入点到拿到flag
4.1 第一步:验证注入点是否可用
拿到题目之后,先做最朴素的事——用phpinfo()验证代码执行是否可用。如果服务端把eval执行结果直接回显到页面上,你可能会看到一个紫色的PHP环境信息页面,这基本上等于告诉你了:注入成功,代码执行没问题。
但要注意一点:不是所有题目都有回显。如果你的phpinfo()没有输出,不代表代码没执行,可能只是结果没有被输出到页面而已。这时候你要换个思路去验证。
我推荐一个稳妥的验证方式:用PHP写文件函数,把一段标记写进Web目录下的一个文件里,然后访问那个文件确认是否生成。比如:
file_put_contents("test.php", "hello");如果执行成功,Web目录下会多出一个test.php,内容为hello。这种方法也叫“写文件验证”,在回显缺失的时候特别好用。当然,写文件的前提是当前目录有写权限,CTF环境一般都有。
4.2 第二步:构造闭合用的经典payload
假设你在页面上看到的是类似?cmd=xxx这样的参数,服务端代码是典型的拼接写法,那么最经典的payload是:
1);system("ls");//拼接到服务端代码里之后长这样:
$a = "1");system("ls");//";简单解释一下各部分的用途:
1:填充原本的参数值位置,保证字符串里的内容不为空,看起来自然。");:闭合掉服务端原本的左双引号和当前语句的分号。system("ls");:你要执行的PHP代码,调用system函数执行ls命令,并将结果输出。//:注释掉后面残余内容,避免语法错误。
如果你是用GET请求,URL编码之后大概是:
?cmd=1%22);system(%22ls%22);//&xxx注意这里的"要视情况URL编码,浏览器可能会自动处理,但如果你写脚本或手动构造URL,要注意编码问题。没有编码的话,引号可能会被解析掉或导致请求异常。
提示:如果你不确定点字符串里的
"是不是双引号,可以先用phpinfo()试,再看页面返回情况。如果返回了一堆PHP配置信息,说明闭合方式奏效了。
4.3 第三步:遍历目录找到flag文件
拿到命令执行权限之后,你的目标就变成了“找flag”。不同题目的flag存放位置不一样,但大体思路是通用的:
- 先
ls当前目录,看有没有明显的flag文件。 - 如果当前目录没有,尝试
ls /var/www/html、ls ../../、ls /tmp等常见位置。 - 打开文件读取内容。如果是PHP文件,要特别留意:直接访问文件可能看不到源码(PHP执行后被渲染成空白或HTML了),要用
cat命令读取原始内容。
我的习惯是先用ls把当前目录列表打出来,再决定下一步。如果列表里有flag.php之类的文件,直接cat flag.php。如果flag藏在别的目录,用find / -name "*flag*" 2>/dev/null全局搜一下也可以,但这个命令在部分受限环境里可能执行很慢,甚至被禁用,所以要灵活取舍。
读取flag文件的时候注意一点:如果你cat flag.php之后页面没有输出,有可能是flag藏在了PHP注释里,因为浏览器端看不到注释内的内容。但cat是直接输出文件源码的,所以如果命令执行完毕且回显正常,你应该能在响应内容里看到flag{...}。如果实在看不到任何回显内容,老办法——把内容写进一个新文件再访问:
system("cat flag.php > a.txt");然后再通过http://靶场地址/a.txt访问这个文件,源代码就会原原本本地出现在浏览器里。
4.4 第四步:记录flag并复盘闭合逻辑
拿到flag之后别急着关页面。我建议你回头把自己输入的完整payload粘到一个本地文档里,然后对照服务端可能的源码,一步一步模拟代码拼接后的最终形态。这样做的好处是:下一次碰到类似题目,你脑子里就会自动浮现“闭合+注入+注释”这三板斧,不会再一头雾水。
比如你这次的payload是1);system("cat flag.php");//,那你可以在纸上写:
原始代码:eval("\$a = \"$a\";"); 代入后:eval("\$a = \"1);system("cat flag.php");//\";");你把这个代入后的代码在脑子里过一遍,体会一下每个引号和括号的配对关系,你就会明白为什么这个payload能成立。以后碰到包裹方式不同的题目,比如变成单引号包裹、括号包裹、或者加了一些正则过滤,你也能基于同样的分析逻辑去构造新payload。
5. 用脚本把题目自动化:从手工到半自动
5.1 为什么需要脚本
在CTF里,手工在浏览器里改URL测试当然能解决一道题,但当你需要连续测多个payload、多次调整命令的时候,手工操作就太慢了。尤其像eval这类题目,你要不断地闭合、执行、换命令、看输出,用脚本能节省大量时间。
另外,脚本还可以让你更好地理解请求结构。浏览器替你做了一大堆事情,反而让你看不清“我到底发送了什么”。用requests库发请求时,你会明确知道自己设置了哪个URL、哪个参数、哪个payload,这对调试和理解漏洞本质都很有帮助。
我写了一个最基础的Python脚本供你参考:
import requests url = "http://<靶场地址>/" # 靶场URL param_name = "cmd" # 参数名,按题目情况改 while True: cmd = input("$ ") # 输入要执行的系统命令,比如 ls if cmd == "exit": break # 构造闭合payload payload = f"1);system('{cmd}');//" resp = requests.get(url, params={param_name: payload}) print(resp.text) print("=" * 50)脚本的逻辑很直接:每次循环让你输入一条命令,然后自动拼接到payload里发出去,把响应内容打印出来。你可以在里面执行ls、cat、find等等,像操作一个半自动的shell一样。
5.2 脚本里常见的坑与改进方向
上面这个脚本看着简单,实际跑起来有几个问题值得注意:
- 命令里有引号怎么办:如果执行的命令本身包含单引号,比如
cat 'flag file.php',和你payload里的单引号会冲突。最简单的办法是把payload改成用双引号包裹命令,再配合转义处理。 - 回显内容可能混在HTML里:如果页面本身有大量HTML结构,命令输出会被淹没。你可以把输出内容用正则抠出来,或者先把命令执行结果写到临时文件,再请求读文件。
- 特殊字符需要URL编码:
requests库的params参数会自动帮你编码一部分,但如果payload里有特殊字符,最好用urllib.parse.quote手动编码一次,避免请求出错。
改进版可以这样写:
import requests from urllib.parse import quote url = "http://<靶场地址>/" payload = '1");system("cat flag.php");//' # 手动拼接URL并整体编码 full_url = f"{url}?cmd={quote(payload, safe='')}" resp = requests.get(full_url) print(resp.text)这种手动构造URL的方式虽然看起来麻烦一点,但你能精确控制编码结果。实战里头几次调试我建议都用这种显式拼接,等payload稳定之后再回到params参数方式。
5.3 从交互脚本到批量探测
交互脚本适合一条条命令慢慢测,但有些场景更适合一次性批量探测。比如你怀疑flag可能在多个位置,你可以写一个for循环,依次对/flag、/var/www/html、/tmp等路径执行ls或cat,把所有结果汇总打印。虽然命令级操作也可以靠交互输入完成,但批量的好处是更能看出规律。
再往后,你甚至可以把“探测—验证—读取flag”做成一个完整流程,自动比对响应中是否包含flag{关键字。这道题可能用不上这么复杂的自动化,但思路是通的:先手工摸清一条稳定路径,再脚本化,再批量化。CTF刷题的本质就是不断把“重复操作”变成“自动化”。
6. 同类题目的变体与实战关联:从CTF到真实防御
6.1 eval三兄弟:一句话木马、命令执行、反序列化
Bugku的这道evel只是最入门的一种用法,eval相关漏洞在真实世界里有几个常见的“变体”你必须了解:
- 一句话木马:服务端写死
eval($_POST['cmd']);,攻击者通过POST传参直接执行任意PHP代码。早期很多被攻破的网站都挂在这样一句话后门上,连接工具五花八门,原理都是利用eval动态执行代码的能力。 - 命令执行:代码里直接把用户输入拼接到
system($cmd)里,构成命令注入。虽然和eval漏洞的触发点不同(一个是PHP代码执行,一个是系统命令执行),但利用思路很相似——闭合、拼接、注入。 - 反序列化漏洞:PHP反序列化配合eval或
call_user_func等函数,可以形成更隐蔽的利用链。遇到这种情况,eval只是链条的最后一环,前面的触发点非常绕,但如果你能读懂eval的代码执行逻辑,理解链条也更快。
这些变体之间不是割裂的,核心都是“程序把用户可控的内容当成代码/命令来处理”。所以你在刷Bugku的eval时学到的东西,往后刷一句话木马、命令注入、PHP反序列化题时都能迁移过去。
6.2 PHP命令执行函数对比
既然这道题的最终落地方式是调用PHP函数执行系统命令,那就有必要把常用函数列出来做个对比。表格是最好的方式:
| 函数 | 是否有直接回显 | 返回值 | 适用场景 |
|---|---|---|---|
system() | 是 | 最后一行输出 | 最常见,CTF首选 |
exec() | 否(需echo) | 最后一行的输出 | 需要捕获输出时 |
shell_exec() | 否(需echo) | 全部输出的字符串 | 想拿完整输出时 |
passthru() | 是 | 无 | 二进制数据或原始输出时 |
popen() | 否 | 文件指针 | 需要持续读取时 |
实际使用中,system()最省事,因为它会把输出直接打到页面上。如果system()被过滤了,你可以试试passthru()、shell_exec(),再不行就用更绕的方式,比如通过exec()配合echo输出、通过popen()读取管道内容。我把这个表格存到笔记里之后,做命令执行相关题目快了很多,你不用每种函数都现场试,看表就知道哪个适合当前场景。
6.3 防御视角:为什么服务端要防eval
站在防守方的角度,eval漏洞要这样理解:它本质上是一种“把不可信数据放进可执行环境”的反模式。如果你就是PHP开发者,请记住这几条铁律:
- 永远不要把用户输入直接传给eval。哪怕你觉得做过过滤,也不要赌,因为绕过手段实在太多。
- 优先使用白名单:需要执行特定功能时,用预设的选项去映射代码逻辑,而不是直接执行用户传进来的字符串。
- 禁用危险函数:在运维层面,可以通过
disable_functions配置将system、exec、shell_exec、passthru等函数禁用,减小攻击面。 - 部署WAF和检测规则:正则匹配
eval、assert、system等关键字是基础中的基础,当然CTF里考点常常就是如何绕过这样的正则,所以真实环境里还需要结合代码审计、语义分析来查漏。
在CTF里我们扮演的是攻击方,但如果能站在防守方角度想一遍“服务端为什么会写这种代码”“哪里会漏”,你对题目的理解会深一个层次。很多老手刷题根本不追求数量,而是每一道题都把攻防两端的逻辑想透。
7. 常见卡点与我的经验
我先说一个几乎每个人都会踩的坑:在eval题里直接传ls却看不到任何回显,于是怀疑题目有问题或者自己没权限。实际上ls就是不行,原因还是那句话——eval执行的是PHP代码不是shell命令。这个坑踩过一次之后,后面再遇到同类题就彻底明白了。
第二个卡点是闭合问题。很多人知道要闭合,但搞不清楚当前拼接上下文里到底是"$a"还是'$a'还是$a本身。我建议你多做一步:先传一个cmd=1,看页面有没有正常显示1;再传一个cmd=1",观察页面是否报语法错误。如果报了语法错误,说明引号参与了拼接,你需要闭合;如果没报错,说明你传的值可能没有被引号包裹。这种情况下payload的写法会有很大差别。
第三个卡点是回显丢失。不是所有题目都会把命令输出打回页面,有时候你需要用“写文件后再读”的思路。我在实战中写文件用得最多的几个路径是当前目录、/tmp目录和可写的缓存目录。优选当前目录,因为直接访问URL就能读取,最快。命令参照:
system("cat flag.php > test.txt");然后请求/test.txt查看内容即可。如果当前目录不可写,再考虑/tmp,但要注意访问路径可能不一样。
第四个经验是关于过滤绕过的。虽然Bugku这道基础题没有太多过滤,但刷题后期你会遇到各种过滤:过滤system、过滤分号、过滤括号、过滤空格。这里提前给你打个底:
- 过滤关键字:可以用字符串拼接构造函数名,比如
sy."stem",PHP支持双引号字符串里拼接变量或字面量,不影响函数调用。 - 过滤空格:可以用
${IFS}(Linux下的默认分隔符变量),或者用$IFS$9、注释符等替代。 - 过滤分号:PHP不一定非要分号结尾,只要最后一个语句是合法的;也可以用闭合上下文代替分号,具体要结合题目。
这些进阶技巧建议你在基础题刷通之后再研究,不然容易一头雾水。
最后分享一个我自己的刷题习惯:每道题做完之后,我会在笔记里记录“闭合方式、payload模板、绕过的点、flag路径”四个信息。这样整理一段时间之后,你会发现Web题的套路其实非常有限,大部分题目都是排列组合。Bugku的eval这道题虽然简单,但它是你建立“代码执行”这个概念的第一块基石,值得你花点时间把它吃透,而不是拿完flag就走。
如果你后面刷到类似的代码执行题,不妨回来对照一下这篇的思路:先确认注入点,再判断拼接上下文,构造闭合payload,调用PHP函数执行命令,定位flag。每一次循环都会加深你对这套流程的理解。