1. 从一道CTF题看PHP代码审计的三个经典命门做PHP代码审计的人迟早会撞上这样一类题目代码不长逻辑也不绕但就是卡在那里过不去。你盯着屏幕反复读那几十行代码感觉每个函数都认识可组合在一起就是找不到入口。BJDCTF2020的ZJCTF这道题就是典型代表——它把PHP双引号变量解析、伪协议封装器、preg_replace的/e修饰符这三个知识点串成了一条链任何一个环节理解不到位整条链就断了。这道题的核心价值不在于它有多难而在于它用极短的代码量把PHP里三个容易被忽视但实战中频繁出现的特性揉在了一起。我见过不少刚入门代码审计的朋友做这道题时要么卡在第一步的变量覆盖要么在伪协议的选择上反复试错要么对preg_replace的替换逻辑理解有偏差。这篇文章就围绕这三个技术点把每一步的推理过程、踩坑经验和底层原理拆开来讲让你不仅知道答案是什么更知道为什么是这个答案以及下次遇到类似结构时该怎么快速定位突破口。适合阅读这篇文章的是有一定PHP基础、正在学习代码审计或准备CTF比赛的朋友。如果你对PHP的变量作用域、文件包含、正则替换这些概念还比较模糊建议先补一下基础再来看效果会更好。全文会按照“代码结构分析→双引号变量解析→伪协议利用→preg_replace突破→完整利用链”的顺序展开每一步都会解释清楚背后的逻辑而不是直接甩payload。2. 题目代码的结构拆解与入口定位2.1 从源码看数据流向这道题给出的源码大致是这样的结构首先通过extract()函数将GET请求的参数导入到当前作用域然后有一个file_get_contents()读取变量$text的内容并赋值给$file接着用if判断$file是否等于某个特定字符串如果相等就输出$file的内容最后还有一个preg_replace()对$file做正则替换。整个代码的逻辑链条是外部输入→变量覆盖→文件读取→条件判断→正则替换→最终输出。理解这段代码的关键在于追踪$file这个变量的生命周期。它最初可能来自extract()导入的GET参数然后被file_get_contents($text)的返回值覆盖接着参与if比较最后进入preg_replace()。每一步都可能成为突破口但每一步也都有各自的限制条件。很多初学者一上来就盯着最后的preg_replace()看反而忽略了前面变量覆盖和文件读取的配合关系。2.2 extract()带来的变量覆盖风险extract()这个函数在PHP开发中一直是个争议点。它的作用是把数组中的键值对导入到当前符号表键名作为变量名键值作为变量值。在这道题里如果代码写成extract($_GET)那么攻击者就可以通过URL参数直接覆盖代码中已定义的任何变量。比如原本代码里有个$file变量攻击者传一个?filexxx就能把它改掉。但这里有个细节需要注意extract()的行为受extract_type参数影响。默认情况下是EXTR_OVERWRITE也就是同名变量会被覆盖。如果代码里设置了EXTR_SKIP那就不会覆盖已有变量。这道题没有显式设置第二个参数所以默认就是覆盖模式。这意味着攻击者可以控制$file、$text等变量的初始值为后续的利用创造条件。我在实际审计中遇到过不少开发者认为extract()只是方便导入配置数组没意识到它会把用户输入直接变成变量。这种认知偏差在CTF题目里被放大成了利用点。所以看到extract()的第一反应应该是哪些变量可以被覆盖覆盖之后会影响哪些后续逻辑2.3 file_get_contents()与变量赋值的先后关系代码中有一行$file file_get_contents($text)这行代码的执行顺序很关键。如果extract()在前那么$text可以被攻击者控制file_get_contents()就会去读取攻击者指定的文件或URL。如果extract()在后那$text的值就是代码里预设的攻击者无法干预。这道题的顺序是extract()在前file_get_contents()在后所以攻击者可以通过?textxxx来控制读取的内容。但这里有个陷阱file_get_contents()的返回值会赋给$file也就是说无论攻击者之前把$file设置成什么执行到这行之后都会被覆盖。所以攻击者不能直接通过?filexxx来设置最终的$file值必须通过控制$text来间接影响$file。这个细节是很多初学者容易忽略的。他们看到extract()就以为可以直接覆盖$file结果发现传了?fileflag之后没有任何反应因为后面的file_get_contents()把$file重新赋值了。正确的思路应该是先让$text指向我们想要的内容然后让file_get_contents()把内容读出来赋给$file。2.4 if条件判断的绕过思路代码中有一个判断if ($file 特定字符串)只有满足条件才会输出$file的内容。这个特定字符串通常是题目预设的一个值比如ZJCTF或者类似的标记。攻击者需要让$file的值等于这个字符串才能进入输出分支。但问题在于$file的值来自file_get_contents($text)的返回值。如果$text指向的是一个本地文件那么$file就是文件的内容如果$text指向的是一个远程URL那么$file就是远程响应的内容。攻击者需要让这个内容恰好等于那个特定字符串。最直接的办法是使用data://伪协议直接在URL里构造内容让file_get_contents()读取后返回我们想要的字符串。这里就引出了伪协议的知识点。PHP的file_get_contents()支持多种封装器包括data://、php://input、php://filter等。不同的封装器有不同的用途和限制选择哪一个取决于具体的场景。在这道题里因为需要控制返回的内容data://是最直接的选择。3. PHP双引号中的变量解析一个容易被忽视的细节3.1 双引号与单引号的本质区别PHP里单引号和双引号的区别很多教程都讲过单引号不解析变量双引号解析变量。但具体怎么解析、解析的边界在哪里、哪些特殊字符会被处理这些细节往往被一笔带过。在这道题里双引号的变量解析行为直接影响了payload的构造方式。举个简单的例子$str hello $name如果$name world那么$str的值就是hello world。但如果写成$str hello $name那$str的值就是字面的hello $name变量不会被替换。这个规则看起来简单但在复杂字符串里变量名的边界判断就成了关键。比如$file_get_contentsPHP会把它解析成变量$file_get_contents而不是$file后面跟着字符串_get_contents。因为下划线是变量名的合法字符PHP会一直读到非变量名字符为止。如果想让$file和后面的字符串分开需要用{$file}这种花括号语法来明确边界。3.2 变量解析在代码审计中的利用场景在代码审计中双引号的变量解析经常出现在SQL语句拼接、文件路径拼接、命令拼接等场景。比如$query SELECT * FROM users WHERE name $name如果$name来自用户输入且没有过滤就可能造成SQL注入。这里的双引号解析让$name的值被直接嵌入到字符串中改变了原本的语义。在这道CTF题里双引号解析的利用点出现在preg_replace()的参数中。代码可能是这样的preg_replace(/某模式/, 某替换内容, $file)其中替换内容用双引号包裹里面包含了变量。如果攻击者能控制这个变量的值就能影响替换的结果。但具体怎么控制、控制到什么程度需要结合preg_replace的修饰符来分析。我见过一些选手在做这道题时明明已经找到了变量覆盖的入口却因为没注意到双引号解析的细节构造的payload被PHP解析成了完全不同的变量名导致利用失败。这种错误很隐蔽因为代码不会报错只是行为不符合预期。3.3 花括号语法与复杂变量表达式当变量名后面紧跟合法变量名字符时必须用{}来界定边界。比如${file}abc和{$file}abc都能正确解析出$file的值然后拼接abc。但$fileabc会被解析成变量$fileabc如果这个变量不存在就会产生一个Notice级别的警告并且值为空。在构造payload时这个细节尤其重要。假设我们想让$file的值后面拼接一个字符串就必须用花括号把变量名包起来。否则PHP会尝试解析一个更长的变量名结果得到空值整个payload就废了。我在实际测试中习惯用{$var}的写法因为它在各种PHP版本中行为一致不容易出问题。另外双引号里还支持数组下标和对象属性的解析比如$arr[key]和$obj-prop。这些语法在复杂场景下很有用但在CTF题目里出现频率相对较低。不过了解这些规则有助于快速判断一个字符串在PHP中会被解析成什么从而更准确地构造payload。3.4 双引号解析与转义字符的交互双引号里还有一些转义序列会被处理比如\n、\t、\\、\$等。其中\$用来输出一个字面的美元符号避免被解析成变量。如果payload里需要包含$字符但不希望它被解析就必须写成\$。这个特性在绕过某些过滤时很有用。比如代码可能检测字符串里是否包含$如果包含就拦截。但攻击者可以用\$来绕过检测因为检测函数看到的是\$而PHP解析后得到的是$。当然这道题里没有这种过滤但这个思路在更复杂的场景下值得记住。还有一个容易踩的坑在双引号里写正则表达式时反斜杠的处理会变得很微妙。因为PHP先处理双引号的转义再把结果传给正则引擎所以可能需要双重转义。比如想匹配一个反斜杠在双引号里要写成\\\\经过PHP解析后变成\\再经过正则引擎解析才匹配一个反斜杠。这个细节在构造preg_replace的payload时经常让人抓狂。4. PHP伪协议在文件读取中的实战选择4.1 为什么这道题需要伪协议回到题目逻辑$file file_get_contents($text)然后if ($file 目标字符串)。攻击者需要让$file的值等于目标字符串。如果$text是一个普通文件路径比如/etc/passwd那么$file就是该文件的内容几乎不可能恰好等于目标字符串。如果$text是一个远程URL虽然可以控制返回内容但题目环境通常没有外网而且allow_url_fopen可能被关闭。这时候伪协议就派上用场了。PHP内置了多种封装器可以在不依赖外部资源的情况下让file_get_contents()返回攻击者想要的内容。最常用的就是data://它允许在URL中直接嵌入数据file_get_contents()读取后返回这些数据。格式是data://text/plain,内容或者data://text/plain;base64,编码后的内容。用data://的好处是简单直接不需要依赖任何外部条件。只要allow_url_fopen开启默认是开启的就能使用。在这道题里构造?textdata://text/plain,ZJCTF就能让$file等于ZJCTF从而通过if判断。4.2 data://协议的编码细节与限制data://协议的内容部分如果包含特殊字符比如空格、、#等需要做URL编码。因为整个payload是放在URL参数里传递的这些字符在URL中有特殊含义。比如空格要写成%20要写成%26#要写成%23。如果内容里有中文或二进制数据还需要用base64编码。base64格式的写法是data://text/plain;base64,内容。注意base64和逗号之间没有空格逗号后面直接跟编码后的字符串。编码后的字符串里可能包含、/、这些字符在URL中会被解析成空格所以需要把写成%2B写成%3D。这个细节很容易被忽略导致payload在传输过程中被篡改。我在实际做题时习惯先用data://text/plain,的明文格式如果内容简单就直接用。只有当内容包含特殊字符或者需要传递二进制数据时才改用base64格式。这样可以减少编码出错的可能性。4.3 php://filter与其他伪协议的适用场景除了data://php://filter也是CTF中高频出现的伪协议。它的主要用途是读取文件源码并进行编码转换比如php://filter/convert.base64-encode/resourceindex.php可以把index.php的内容base64编码后返回。这在需要获取源码但直接读取会被PHP解析的情况下特别有用。但在这道题里php://filter不太适用因为我们需要控制返回的内容恰好等于目标字符串而不是读取某个文件的内容。php://input可以读取POST请求的body但需要配合POST请求使用而且file_get_contents(php://input)在某些配置下可能不可用。php://temp和php://memory是内存流但无法直接写入内容。所以综合来看data://是这道题最合适的选择。这也提醒我们在选择伪协议时要根据具体需求来判断需要控制内容就用data://需要读源码就用php://filter需要读POST数据就用php://input。没有万能的协议只有最适合当前场景的协议。4.4 allow_url_fopen与allow_url_include的区别这两个配置项经常被混淆。allow_url_fopen控制的是file_get_contents()、fopen()等函数能否打开URL。allow_url_include控制的是include()、require()等语句能否包含URL。这道题用的是file_get_contents()所以受allow_url_fopen影响。默认情况下allow_url_fopen是Onallow_url_include是Off。如果题目环境把allow_url_fopen关了data://就用不了。这时候可能需要考虑其他绕过方式比如利用本地文件包含或者日志文件。但在BJDCTF2020这道题的环境里allow_url_fopen是开启的所以data://可以正常工作。我在实际测试中会先尝试data://如果不行再考虑其他方案。判断allow_url_fopen是否开启有个小技巧尝试用file_get_contents(http://某地址)如果返回false或者报错说明可能被关闭了。当然在CTF环境里没法直接测试只能根据经验判断。5. preg_replace的/e修饰符从替换到代码执行5.1 preg_replace的基本工作机制preg_replace($pattern, $replacement, $subject)的作用是在$subject中搜索$pattern匹配的内容然后用$replacement替换。如果没有匹配到$subject原样返回。这个函数在PHP中用于正则替换功能强大但也有一些需要注意的细节。首先$pattern必须是一个合法的正则表达式包括定界符。比如/abc/表示匹配字符串abc。定界符可以是/、#、~等只要首尾一致即可。如果$pattern不合法preg_replace()会返回null并产生一个警告。其次$replacement中可以包含反向引用比如\1、\2表示匹配到的第一个、第二个捕获组。这个特性在文本处理中很有用但在安全场景下也可能被滥用。如果$replacement中包含$符号PHP会尝试解析变量这也是双引号解析发挥作用的地方。5.2 /e修饰符的历史与危害/e修饰符是PHP 5.5之前preg_replace()的一个特性它允许$replacement参数被当作PHP代码执行。具体来说当$pattern匹配到内容后$replacement中的反向引用会被替换成实际匹配的文本然后整个字符串会被eval()执行。这个设计原本是为了方便在替换时进行复杂计算但带来了严重的安全隐患。如果攻击者能控制$replacement的内容并且$pattern能匹配到攻击者控制的部分就可以实现任意代码执行。比如preg_replace(/abc/e, phpinfo(), abc)会执行phpinfo()。在CTF题目里这通常被用来读取flag文件或者执行系统命令。PHP 5.5将/e标记为废弃PHP 7.0正式移除。所以这道题的环境应该是PHP 5.x。在实际审计中如果看到preg_replace使用了/e修饰符基本可以确定存在代码执行漏洞。即使没有/e如果$replacement中包含用户可控的变量也可能通过其他方式利用。5.3 如何构造利用链在这道题里preg_replace的利用需要满足几个条件第一$pattern能匹配到$file中的某部分内容第二$replacement中包含可执行的PHP代码第三$replacement中的代码能通过双引号解析或者反向引用被注入。假设代码是preg_replace(/ZJCTF/e, \\1, $file)其中$file的值是ZJCTF。那么/ZJCTF/e会匹配到ZJCTF\\1是反向引用指向匹配到的内容ZJCTF然后eval(ZJCTF)会执行但ZJCTF不是合法PHP代码所以会报错。如果$file的值是phpinfo()那么eval(phpinfo())就会执行phpinfo()。但问题在于$file的值来自file_get_contents($text)我们需要让$file既等于if条件里的目标字符串又包含可执行的PHP代码。这两个条件看起来矛盾但通过双引号解析和变量覆盖可以解决。具体来说可以让$file的值是ZJCTF然后在$replacement中通过双引号解析注入代码。比如$replacement是{$file}()经过双引号解析后变成ZJCTF()如果ZJCTF是一个函数名就会调用该函数。但ZJCTF通常不是内置函数所以需要更巧妙的构造。常见做法是利用${phpinfo()}这样的语法在双引号中执行函数。PHP的双引号解析支持${表达式}的形式其中表达式会被求值。所以{${phpinfo()}}会先执行phpinfo()然后把返回值嵌入字符串。但phpinfo()的返回值是true转换成字符串是1所以最终字符串是1。这个技巧在CTF中经常用来绕过长度限制或者过滤。5.4 反向引用与代码注入的结合更常见的利用方式是通过反向引用注入代码。假设$pattern是/(.*)/e$replacement是\\1$subject是$file。那么(.*)会匹配$file的全部内容\\1引用这部分内容然后eval($file)执行。如果$file的值是system(cat /flag)就会执行系统命令读取flag。但这里又回到了前面的问题$file需要等于if条件里的目标字符串。如果目标字符串是ZJCTF那$file就是ZJCTFeval(ZJCTF)不会执行任何有用代码。所以需要让$file在通过if判断的同时还能在preg_replace中触发代码执行。一种思路是利用PHP的类型 juggling。if ($file ZJCTF)使用的是松散比较如果$file是0或者false在某些PHP版本中可能等于ZJCTF。但file_get_contents()返回的是字符串所以这条路走不通。另一种思路是让$file的值包含ZJCTF同时包含可执行代码比如ZJCTF;system(cat /flag)。但if条件是比较不是strpos所以必须完全相等。所以最终的利用链需要更精细的设计。通常的做法是先用data://让$file等于目标字符串通过if判断然后在preg_replace中通过双引号解析或者变量覆盖把可执行代码注入到$replacement中。具体的payload构造需要根据题目的实际代码来调整但核心思路就是这两步的配合。6. 完整利用链的构造与实测验证6.1 第一步用data://通过if判断假设目标字符串是ZJCTF构造URL?textdata://text/plain,ZJCTF。这样file_get_contents($text)返回ZJCTF$file被赋值为ZJCTFif ($file ZJCTF)成立进入输出分支。这一步的验证很简单如果页面输出了ZJCTF说明if判断通过了。如果没有输出可能是data://被禁用或者目标字符串不对。可以尝试用php://filter读取源码来确认目标字符串的具体值。6.2 第二步分析preg_replace的参数来源接下来需要看preg_replace的三个参数分别来自哪里。$pattern通常是硬编码的比如/ZJCTF/e。$replacement可能是硬编码的也可能包含变量。$subject通常是$file。如果$replacement是硬编码的比如\\1那么利用点就在于$file的内容。如果$replacement包含变量比如{$file}那么利用点就在于变量覆盖。在这道题里常见的设定是$replacement包含$file变量通过双引号解析注入代码。比如preg_replace(/ZJCTF/e, {$file}, $file)当$file是ZJCTF时$replacement解析后是ZJCTFeval(ZJCTF)不执行有用代码。但如果$file是${phpinfo()}那么$replacement解析后是phpinfo()的返回值eval执行后输出1。但$file又必须等于ZJCTF才能通过if判断。所以需要让$file在通过if判断时是ZJCTF在进入preg_replace时变成${phpinfo()}。这看起来不可能因为$file的值在if判断后没有改变。除非if判断和preg_replace之间还有变量覆盖的操作。6.3 第三步利用extract()的二次覆盖如果代码在if判断之后、preg_replace之前还有一次extract()调用那么攻击者可以在第一步用data://通过if判断然后在第二步用GET参数覆盖$file为${phpinfo()}。这样preg_replace执行时$file已经是${phpinfo()}$replacement解析后执行phpinfo()。这种“分步覆盖”的思路在CTF中很常见。关键在于找到代码中所有的变量覆盖点然后规划每个阶段需要设置什么值。有时候需要多次请求有时候一次请求就能完成取决于代码的执行流程。6.4 实测中的常见问题与排查在实际测试中最容易遇到的问题有几个第一data://的内容被URL编码后PHP没有正确解析导致$file的值不是预期的字符串。解决办法是用data://text/plain;base64,格式把内容base64编码后再URL编码。第二preg_replace的/e修饰符在PHP 7中已经移除如果题目环境是PHP 7这条路走不通需要寻找其他漏洞点。第三双引号解析的边界判断错误导致变量名被解析成更长的字符串。解决办法是用{}明确界定变量名。排查这些问题的方法很简单在本地搭建相同的PHP环境把代码复制进去逐步调试。用var_dump()输出每个变量的值看是否符合预期。如果本地环境跑通了再放到题目环境里测试。这种“本地复现→逐步调试→远程验证”的流程是CTF中解决复杂题目的标准做法。7. 从这道题延伸出的审计思维这道题虽然只有几十行代码但涉及的三个知识点——双引号变量解析、伪协议、preg_replace的/e修饰符——都是PHP代码审计中的高频考点。掌握它们不仅是为了做这一道题更是为了在遇到类似结构时能快速定位突破口。我的经验是看到extract()就想到变量覆盖看到file_get_contents()就想到伪协议看到preg_replace()就检查有没有/e修饰符。这三个条件同时出现时基本可以确定是一道利用链题目。剩下的工作就是理清数据流向找到每个环节的可控点然后串联起来。另外这道题也提醒我注意PHP版本的影响。/e修饰符在PHP 7中已经不存在data://协议在不同版本中的行为也可能有细微差异。在实际审计中确认目标环境的PHP版本是第一步否则可能白忙一场。查看版本的方法有很多比如phpinfo()、X-Powered-By响应头、报错信息中的版本号等。最后分享一个我在做题时的小习惯把代码里的每个变量列出来标注它的来源和去向画一张简单的数据流图。这样做的好处是能直观地看到哪些变量是可控的哪些操作会影响哪些变量。对于这道题数据流图大概是GET参数→extract()→$text→file_get_contents()→$file→if判断→preg_replace→输出。每个箭头都是一个可能的利用点逐个分析就能找到完整的利用链。