PHP代码审计实战:从eval()漏洞到CTFshow Web15绕过技巧 1. 项目概述一次由CTF题引发的深度代码审计思考最近在复盘CTFshow的Web15这道题时我脑子里反复回响着一句话“又是eval()惹的祸”。这道题本身难度不算顶级但它像一面镜子精准地照出了我们在PHP代码审计尤其是面对动态代码执行函数时那些根深蒂固的思维误区和审查盲点。很多刚入门代码审计的朋友一看到eval()、assert()、system()这些函数就两眼放光觉得找到了突破口但往往在复杂的代码逻辑和层层过滤面前碰得头破血流。Web15这道题恰恰就是一个关于如何绕过对eval()参数进行严格过滤的经典教学案例。它不单单是考察一个知识点更是对审计者耐心、细心和思维发散能力的综合考验。今天我就以这道题为引子结合我这些年踩过的坑和积累的经验系统性地拆解PHP中eval()及相关危险函数的审计脉络希望能帮你建立起一套更立体、更有效的审计方法论。2. 核心漏洞原理与eval()的“罪与罚”在深入Web15之前我们必须把地基打牢。eval()这个函数在PHP中可谓“臭名昭著”却又让无数安全研究员又爱又恨。它的官方定义很简单把字符串作为PHP代码来执行。这赋予了它无与伦比的灵活性但也打开了潘多拉魔盒。2.1eval()为什么是“万恶之源”从语言设计层面看eval()的存在是为了满足极致的动态性需求。比如在一些古老的模板引擎或者需要动态生成并执行代码的特定场景如某些数学表达式计算器它曾是快速实现的方案。然而其危险性就源于这种“将数据直接转化为代码”的能力。在安全领域这直接模糊了“数据”和“代码”的边界是远程代码执行漏洞的温床。一个最简单的致命示例$user_input $_GET[‘cmd’]; eval($user_input);当用户访问?cmdphpinfo();时服务器就会执行phpinfo()函数。如果攻击者传入的是system(‘whoami’);后果可想而知。但现实中几乎没有开发者会写出如此赤裸裸的漏洞代码。漏洞往往隐藏在复杂的逻辑和自以为安全的过滤之后。2.2 与assert()、preg_replace()/e修饰符的“同流合污”在审计时我们不能只盯着eval()。有几个函数和它“臭味相投”需要一并纳入审查雷达assert() 在PHP 7之前assert()也是一个可以执行代码的函数。它本意用于调试断言但如果传入的是字符串它也会像eval()一样执行。assert(‘phpinfo()’)同样可以触发代码执行。虽然PHP 7后默认不再将字符串参数作为代码执行但在历史代码或特定配置下仍是威胁。preg_replace()的/e修饰符 这是一个已被废弃但遗毒甚广的特性。当使用/e修饰符时preg_replace()的第二个参数替换字符串会被当作PHP代码执行。// 危险示例 echo preg_replace(‘/^(.*)$/e’, ‘\\1’, $_GET[‘input’]); // 如果 inputphpinfo() 则 phpinfo() 会被执行create_function() 这个函数用于动态创建匿名函数其内部实现也使用了eval()。如果用户输入被拼接进函数体字符串同样会导致代码执行。$func create_function(‘$a’, ‘return ‘ . $_GET[‘code’] . ‘;’); // 如果 code‘}phpinfo();//‘ 就能逃逸出函数体执行代码注意在审计中要养成使用全局搜索如grep -r “eval\|assert\|preg_replace.*/e\|create_function”的习惯将这些高危函数一网打尽。2.3 漏洞利用的常见“入口点”用户输入如何流到eval()里这是审计的关键路径。常见的入口点包括$_GET$_POST$_REQUEST 最直接的HTTP参数。$_COOKIE 容易被忽略但同样可控。$_SERVER[‘HTTP_*’] 如HTTP_USER_AGENTHTTP_REFERER等请求头信息。文件操作返回值 如file_get_contents()读取了用户上传或可控的文件内容。数据库查询结果 从数据库中取出的数据如果当初入库时未过滤或过滤不当也可能成为代码字符串。反序列化点 虽然反序列化本身是另一个大话题但反序列化出来的对象属性最终也可能被拼接到eval()的字符串中。审计的思路就是像侦探一样追踪这些“污点数据”从入口到最终执行点eval()的完整传播路径并分析沿途经过了哪些“净化处理”。3. CTFshow Web15 详细审计与绕过实战现在我们回到CTFshow Web15。通常题目源码不会直接给出我们需要通过信息收集、参数测试等方式去推测和还原代码逻辑。假设我们通过测试发现题目核心逻辑如下此为根据常见套路还原的模拟代码?php error_reporting(0); highlight_file(__FILE__); if(isset($_GET[‘c’])){ $c $_GET[‘c’]; // 第一层过滤严格的黑名单 $blacklist array(‘ ‘, ‘\t’, ‘\r’, ‘\n’, ‘\’’, ‘“’, ‘’, ‘\[‘, ‘\]’, ‘\$’, ‘\\’, ‘\^’, ‘\|’); foreach ($blacklist as $blackitem) { if (stripos($c, $blackitem) ! false) { die(“Hacker!”); } } // 第二层过滤移除特定字符串 $c str_ireplace(“php”, “”, $c); $c str_ireplace(“file”, “”, $c); $c str_ireplace(“data”, “”, $c); $c str_ireplace(“rot13”, “”, $c); $c str_ireplace(“quote”, “”, $c); $c str_ireplace(“glob”, “”, $c); $c str_ireplace(“://”, “”, $c); // 第三层检查是否包含 ‘flag’ 关键字 if(stripos($c, ‘flag’) ! false){ die(“Hacker!!”); } // 第四层使用正则匹配并执行 if(preg_match(‘/[a-zA-Z0-9_\/\\#\%\\*\;\:\.\,\?\!\-]/i’, $c, $matches)){ eval($matches[0]); } else { die(“Hacker!!!”); } } ?3.1 逐层过滤分析与思维突破这道题的过滤堪称“铁桶阵”我们一层层来看黑名单过滤 禁用了空格、制表符、换行、单双引号、反引号、方括号、美元符号、反斜杠、异或符、管道符。这几乎封死了所有常见的命令连接、变量引用、字符串定义和特殊符号执行的方式。字符串替换过滤 使用str_ireplace将 “php” “file” “data” “rot13” “quote” “glob” “://” 替换为空。注意这里是递归替换如果你传入pphphp 第一次替换掉中间的php后变成pphp 第二次又会把剩下的php替换掉最终变成p。这招非常狠直接让简单的双写绕过如phpphp失效。关键字过滤 直接检查整个$c中是否包含flag字符串有则拦截。正则匹配 只允许字母、数字、下划线以及一些特殊符号/#%*;:.?!-并且只取匹配到的第一个结果$matches[0]传给eval()。乍一看简直无懈可击。命令执行需要空格、管道、重定向这里没有。文件读取需要file_get_contents、highlight_file或php://协议相关关键词被过滤。直接写system(‘ls’);也因为空格和引号被禁。常规思路似乎全部被堵死。突破口在哪里关键在于理解eval()的本质和PHP语言的灵活性。eval()执行的是PHP代码而PHP代码的写法是多样的。我们被“命令执行”的思维固化了认为一定要调用system()、shell_exec()这类函数。但别忘了eval()本身就是一个超级函数我们传入的任何合法PHP代码都会被执行。我们的目标不一定是执行系统命令也可以是读取文件、列出目录或者利用PHP内置功能实现突破。3.2 构造无空格、无引号、无关键字的Payload既然不能有空格和引号我们如何调用函数和传递字符串参数呢函数调用phpinfo()可以但我们需要的是读文件。show_source()、readfile()都需要参数。字符串表示 没有引号如何表示字符串‘flag.php’PHP中字符串除了用引号定义还可以用字符数组构造或者利用常量、函数返回值间接生成。一个经典的技巧是使用.点号进行字符串拼接以及利用超全局变量$_GET本身来传递第二个参数。因为正则允许很多符号包括.和[]。Payload构造思路一利用.拼接和$_GET传参cshow_source(current(getallheaders()));然后我们在HTTP请求头中设置一个头比如X: flag.php。getallheaders() 获取所有HTTP请求头返回一个数组。current() 获取数组中的当前元素第一个元素。我们传入的X: flag.php会在这个数组里。show_source() 高亮显示文件源码。这个Payload里没有空格没有引号flag关键字在请求头里不在$c中完美绕过所有过滤。但这里用到了()和{}需要确认它们在正则允许范围内。题目正则允许小括号和花括号吗我们模拟的正则[a-zA-Z0-9_\/\\#\%\\*\;\:\.\,\?\!\-]不允许()和{}。所以这个思路在本题可能行不通但它是一个非常重要的绕过技巧。Payload构造思路二利用scandir()和print_r()我们的目标是列出目录找到文件名。scandir(‘.’)可以列出当前目录。但需要表示当前目录‘.’。cprint_r(scandir(chr(46)));chr(46) ASCII码46对应的字符就是点号.。这样我们就得到了字符串‘.’且没有使用引号。scandir(‘.’) 列出当前目录所有文件和文件夹。print_r() 打印数组让我们看到结果。 这个Payload只包含函数名、括号、点和数字完全符合正则要求。通过它我们可能发现目录下存在flag.php或类似文件。Payload构造思路三直接读取文件终极方案假设我们通过方法二知道了文件名是flag.php。如何读取它直接show_source(‘flag.php’)不行有引号和关键字。 我们可以用chr()函数逐个构造字符flag.php的ASCII码序列是102(f), 108(l), 97(a), 103(g), 46(.), 112(p), 104(h), 112(p)那么构造cshow_source(chr(102).chr(108).chr(97).chr(103).chr(46).chr(112).chr(104).chr(112));这个Payload非常长但完全由函数调用、点号和数字组成是绝对合法的。它成功绕过了空格、引号、关键字过滤直接读取了flag.php的源代码从中找到flag。3.3 实战中的技巧与变形在实际解题或真实审计中我们还需要考虑以下变种过滤了chr怎么办可以用hex2bin()配合十六进制字符串或者用base_convert()进行进制转换生成字符。例如hex2bin(‘666c61672e706870’)的结果就是‘flag.php’。过滤了点号.怎么办可以用current(localeconv()) 这个函数返回的数组第一个元素通常是点号.。或者用phpversion()[0] PHP版本号第一个字符也经常是数字但特定环境下可能是点号不够稳定。如何执行命令即使过滤了system、shell_exec 还可以用反引号本题被过滤、passthru()、exec()、popen()、proc_open()。如果函数名也被过滤可以考虑用动态函数调用$_GET[‘a’]($_GET[‘b’])的形式前提是$和[]没被过滤。Web15这道题就是要求我们放弃对常见Payload的依赖回归到PHP代码本身利用语言特性去构造出我们需要的“字符串”和“函数调用”是一道锻炼基本功的绝佳题目。4. 企业级PHP代码审计中eval()的挖掘与防御CTF题目是理想化的沙盒而真实世界的代码审计则复杂、庞杂得多。面对动辄数万行、框架封装、多层继承的业务代码如何高效、准确地找到eval()漏洞4.1 自动化静态扫描与人工审计的结合工具先行 使用RIPS、Fortify SCA、SonarQube等静态代码分析工具进行初步扫描。它们能快速定位到eval()、assert()、preg_replace(/e)等危险函数调用点并尝试进行简单的污点跟踪。但切记工具报告只是线索不是结论。工具会产生大量误报将无害调用报为漏洞和漏报复杂的过滤绕过无法识别。人工研判 这是审计的核心。拿到工具报告后需要人工验证每一条路径。确认用户输入点 向上追踪看传入eval()的参数是否真正来自用户可控的输入源$_GET$_POST$_COOKIE$_REQUESTfile_get_contents(‘php://input’)等。分析过滤逻辑 仔细阅读从输入点到执行点之间所有的数据处理代码。是黑名单还是白名单过滤函数是str_replace、preg_replace还是preg_match过滤是循环多次还是一次过滤后是否进行了长度检查或类型转换寻找逻辑缺陷 这是高手和普通审计者的分水岭。例如过滤顺序问题 先删除script标签再解码HTML实体可能导致scrscriptipt被过滤成script。正则缺陷 使用不严谨的正则如/.*/、/^[a-z]$/i可能被多行注入、数组注入、编码注入绕过。字符串处理函数误用trim()只去除首尾字符addslashes()对数字型注入无效htmlspecialchars()默认不处理单引号等。回溯与发散思维 不要只盯着一条路。如果当前路径被完美过滤看看是否有其他入口点如上传文件的文件名、HTTP请求头、数据库的某个字段能间接影响到最终参数。思考是否有“时间差攻击”条件竞争或“二次注入”的可能。4.2 那些年我们踩过的“坑”与实用技巧坑1str_replace的递归过滤陷阱。正如Web15所示str_replace(“php”, “”, $input)会对替换后的结果再次进行替换。这看似加强了过滤实则可能被利用。例如输入pphphp- 替换中间php后为pphp- 再次替换剩下php后为p。攻击者可以利用这个特性来“抵消”过滤但需要精确计算。更安全的做法是使用preg_replace并限制替换次数为1次。坑2大小写敏感/不敏感处理不一致。代码中可能用了stripos不区分大小写来查找关键字但后续的eval执行是区分大小写的。或者反过来。这种不一致性可能导致绕过。坑3过滤在序列化/反序列化之后。这是反序列化漏洞中常见的问题。代码可能先对用户输入进行过滤然后序列化存储。当从存储中取出反序列化时过滤措施已经失效原始的恶意对象被成功还原。坑4依赖客户端过滤。前端JavaScript做了严格的输入检查但后端PHP完全没有验证。这是最低级的错误但依然存在。技巧使用php -l进行语法检查。在构造复杂的无引号、无空格Payload时可以先将Payload写在一个本地PHP文件中用php -l filename.php检查语法是否正确。这能节省大量在Web端盲目测试的时间。技巧善用错误信息。如果eval()执行出错且网站开启了错误显示错误信息可能会泄露部分代码上下文或文件路径这有助于我们调整Payload。4.3 根本性的防御方案从开发角度杜绝此类漏洞的最佳实践是绝对禁止 在新项目中完全禁止使用eval()、assert()字符串参数、preg_replace的/e修饰符、create_function()。在代码规范中明确写入并在代码审查中严格执行。白名单过滤 如果业务上确实需要动态执行极少数情况必须使用白名单机制。只允许执行预定义好的、安全的函数或代码片段。例如定义一个映射数组$allowedFunctions [‘max’ ‘max’ ‘min’ ‘min’]; 只允许执行数组内的函数。使用安全的替代方案数学计算 用eval(“return $expression;”)计算数学表达式改用bcmath扩展或intval()/floatval()进行安全计算。动态函数调用 用call_user_func()或call_user_func_array() 但同样要对函数名进行白名单控制。模板渲染 使用成熟的、安全的模板引擎如Twig、Smarty配置安全选项它们会自行处理变量输出避免代码注入。最小权限原则 运行PHP的Web服务器进程如www-data nginx应该以最低必要的权限运行避免一旦被攻破就获得系统高级权限。部署WAF 在应用前端部署Web应用防火墙可以拦截一些常见的、模式化的攻击Payload作为最后一道防线。但绝不能替代安全的代码。5. 从Web15延伸PHP代码审计的通用方法论Web15聚焦于eval()但PHP代码审计是一个系统工程。总结下来一套有效的审计流程大致如下信息收集 了解目标程序使用的框架ThinkPHP Laravel Yii等、版本、已知公开漏洞。查看composer.json、入口文件引用的类库。入口点枚举 找出所有用户输入点不仅是$_GET/$_POST 还包括$_SERVER变量、文件上传、Session、Cookie、数据库二次注入、网络请求SSRF触发点等。危险函数定位 使用工具或全局搜索定位所有危险函数代码执行、命令执行、文件操作、数据库操作、反序列化、XXE等。正向追踪与逆向回溯正向从入口到危险函数 选择一个入口点手动或借助工具模拟数据流看它能否流向危险函数并记录沿途的所有处理逻辑。逆向从危险函数到入口 选择一个危险函数向上回溯寻找哪些变量可以传入此处这些变量又来自哪些源头。逻辑漏洞挖掘 跳出单纯的功能点审计关注业务逻辑。如支付金额是否可负、验证码是否可重复使用、权限校验是否在每步操作中都得到检查、条件竞争等。漏洞验证与利用 对发现的疑似漏洞点搭建本地环境或使用测试接口进行验证构造出可稳定利用的Payload。报告撰写 清晰描述漏洞位置、触发条件、利用方式、潜在危害并提供修复建议。修复建议应具体最好能给出代码示例。这个过程需要极大的耐心和细心就像在迷宫中寻找唯一的出口。每一个过滤函数、每一个条件判断、每一个变量的传递都可能隐藏着突破的关键或坚固的防御。Web15中的eval()绕过只是这个庞大迷宫中的一个精巧房间。掌握它你就掌握了破解这一类房间的钥匙。但请记住钥匙不止一把迷宫也永远会有新的房间。保持好奇持续学习才是安全研究员最重要的品质。