前言

在一个Laravel Debug RCE(CVE-2021-3129)利用中看到的奇妙操作,通过php://filter过滤器配合文件写入读取操作,去除文件中的多余字符,很有意思。


phar

一个phar文件要满足什么条件才能正常地触发反序列化?

翻一翻php源码,在phar.c中找到phar解析函数phar_parse_pharfile,第一个条件:

/* check for ?>\n and increment accordingly */
if (-1 == php_stream_seek(fp, halt_offset, SEEK_SET)) {
    MAPPHAR_ALLOC_FAIL("cannot seek to __HALT_COMPILER(); location in phar \"%s\"")
}

可以看到,php会搜索stub中的:

__HALT_COMPILER();

标识作为phar文件的开头,在实际测试中如果存在多个标识,则会选择最后一个,所以phar文件前面部分有冗余数据并不影响,后面就是一个完整的phar文件。

再看看phar文件后面部分存在冗余数据会不会影响,再看后面的代码:

if (-1 == php_stream_seek(fp, -8, SEEK_END)
    || (read_len = php_stream_tell(fp)) < 20
    || 8 != php_stream_read(fp, sig_buf, 8)
    || memcmp(sig_buf+4, "GBMB", 4)) {
    efree(savebuf);
    php_stream_close(fp);
    if (error) {
        spprintf(error, 0, "phar \"%s\" has a broken signature", fname);
    }
    return FAILURE;
}

这里会读取最后的8个字符,限制了必须以GBMB这个标识结束,所以后面就不能存在一些乱七八糟的冗余数据了。

经过测试,在phar压缩包的内部文件数据部分加上些奇奇怪怪的数据并不会影响phar反序列化。

场景

原场景中的处理代码如下:

$output = file_get_contents($parameters['viewFile']);

//省略xxx(对$output做一些处理)
//xxx

file_put_contents($parameters['viewFile'], $output);

简单来说就是读取某个文件中的内容,处理之后又放了回去,期间就可以通过php://filter对文件内容进行编码方面的修改。

read/write

用于标识文件操作类型的标识,类似:

php://filter/write=convert.iconv.utf-8.utf-16le/resource=C:/twings.txt

在标识为write的情况下,file_get_contents这种文件读操作就不会触发编码转换,所以就可以在不影响读出数据的情况下对写入的数据进行修改。

Base64编码

先看一眼php进行base64解码的相关代码,找到base64.c,这里首先设置了一张base64码表:

$base64_reverse_table = [
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -1, -1, -2, -2, -1, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, 62, -2, -2, -2, 63,
    52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -2, -2, -2, -2, -2, -2,
    -2,  0,  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, -2, -2, -2, -2, -2,
    -2, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
    41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2,
    -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2
];

然后

ch = base64_reverse_table[ch];
if (!strict) {
    /* skip unknown characters and whitespace */
    if (ch < 0) {
        continue;
    }
} else {
    /* skip whitespace */
    if (ch == -1) {
        continue;
    }
    /* fail on bad characters or if any data follows padding */
    if (ch == -2 || padding) {
        goto fail;
    }
}

解码时的第二个参数代表是否开启strict模式(默认不开启),此时会跳过空格和非法字符。

而开启之后,解码过程中只会跳过空格,而遇到非法字符(或者=后面还有其他字符)则会终止解码。

实际上php://filter会进行的base64解码却有些许不一样,找到filter.c,同样先是一个类似的码表:

static unsigned int b64_tbl_dec[256] = {
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 62, 64, 64, 64, 63,
    52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 64, 64, 64,128, 64, 64,
    64,  0,  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, 64, 64, 64, 64, 64,
    64, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
    41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
    64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64
};

其中一段处理代码则如下:

i = b64_tbl_dec[(unsigned int)*(ps++)];
icnt--;
ustat |= i & 0x80;

if (!(i & 0xc0)) {
    if (ustat) {
        err = PHP_CONV_ERR_INVALID_SEQ;
        break;
    }
    if (6 <= pack_bcnt) {
        pack_bcnt -= 6;
        pack |= (i << pack_bcnt);
        urem = 0;
    } else {
        urem_nbits = 6 - pack_bcnt;
        pack |= (i >> urem_nbits);
        urem = i & bmask(urem_nbits);
        pack_bcnt = 0;
    }
} else if (ustat) {
    if (pack_bcnt == 8 || pack_bcnt == 2) {
        err = PHP_CONV_ERR_INVALID_SEQ;
        break;
    }
    inst->eos = 1;
}

这里的变量命名比较奇特,ustat看起来用于标识是否识别到了padding(即=),当识别到合法字符则会进入上面的判断,如果上个字符为=则会判断为解码失败而结束解码。

一般情况下如果识别到了非法字符或者=,则会跳过。

所以虽然可以通过多次解码跳过非法字符来清除冗余字符,但是其中还是存在问题的,如果多次解码中出现了=,而且=后面还存在合法字符则会发生错误。

UTF编码

既然base64不是很好用,那么就需要找到另一种更方便去除冗余字符的编码方式了,比如UTF-8和UTF-16le。

UTF-8大家都知道是什么,UTF-16le用两个字节表示UTF-8中的一个字节(所以需要让冗余字符数为偶数),对我们常用的字母和符号来说就是在UTF-8的字符后面加多一个\x00。

它们之间的转换一般不会发生错误(字符数为奇数时会发生错误),同时一般可写文件也不会存在\x00这种奇怪字符,所以通过这两种编码之间的编码转换就可以将文件中原本存在的字符转化为base64中的非法字符,再结合base64解码就可以去除冗余字符。

quoted-printable

一种看起来不是很有用的转码,能够将不可见字符替换成=xx的形式,可以用来处理一些特殊字符。

CVE-2021-3129

总之先搭个环境,因为我只是对其中一部分感兴趣,所以根据参考文章简单弄一个,去下载一个别人搭建好的环境,然后用docker-compose启动。

先简单看看它的报错日志,有点长:

[2021-02-28 11:43:31] local.ERROR: file_get_contents(abcdefghijklmnopqrstuvwxyz): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(abcdefghijklmnopqrstuvwxyz): failed to open stream: No such file or directory at /src/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError(2, 'file_get_conten...', '/src/vendor/fac...', 75, Array)
#1 /src/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents('abcdefghijklmno...')
#2 /src/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->makeOptional(Array)
#3 /src/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->run(Array)
#4 /src/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController->__invoke(Object(Facade\\Ignition\\Http\\Requests\\ExecuteSolutionRequest), Object(Facade\\Ignition\\SolutionProviders\\SolutionProviderRepository))
#5 /src/vendor/laravel/framework/src/Illuminate/Routing/Route.php(254): Illuminate\\Routing\\ControllerDispatcher->dispatch(Object(Illuminate\\Routing\\Route), Object(Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController), '__invoke')
#6 /src/vendor/laravel/framework/src/Illuminate/Routing/Route.php(197): Illuminate\\Routing\\Route->runController()
#7 /src/vendor/laravel/framework/src/Illuminate/Routing/Router.php(693): Illuminate\\Routing\\Route->run()
#8 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(128): Illuminate\\Routing\\Router->Illuminate\\Routing\\{closure}(Object(Illuminate\\Http\\Request))
#9 /src/vendor/facade/ignition/src/Http/Middleware/IgnitionConfigValueEnabled.php(25): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#10 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Facade\\Ignition\\Http\\Middleware\\IgnitionConfigValueEnabled->handle(Object(Illuminate\\Http\\Request), Object(Closure), 'enableRunnableS...')
#11 /src/vendor/facade/ignition/src/Http/Middleware/IgnitionEnabled.php(23): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#12 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Facade\\Ignition\\Http\\Middleware\\IgnitionEnabled->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#13 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#14 /src/vendor/laravel/framework/src/Illuminate/Routing/Router.php(695): Illuminate\\Pipeline\\Pipeline->then(Object(Closure))
#15 /src/vendor/laravel/framework/src/Illuminate/Routing/Router.php(670): Illuminate\\Routing\\Router->runRouteWithinStack(Object(Illuminate\\Routing\\Route), Object(Illuminate\\Http\\Request))
#16 /src/vendor/laravel/framework/src/Illuminate/Routing/Router.php(636): Illuminate\\Routing\\Router->runRoute(Object(Illuminate\\Http\\Request), Object(Illuminate\\Routing\\Route))
#17 /src/vendor/laravel/framework/src/Illuminate/Routing/Router.php(625): Illuminate\\Routing\\Router->dispatchToRoute(Object(Illuminate\\Http\\Request))
#18 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(166): Illuminate\\Routing\\Router->dispatch(Object(Illuminate\\Http\\Request))
#19 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(128): Illuminate\\Foundation\\Http\\Kernel->Illuminate\\Foundation\\Http\\{closure}(Object(Illuminate\\Http\\Request))
#20 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php(21): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#21 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Illuminate\\Foundation\\Http\\Middleware\\TransformsRequest->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#22 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php(21): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#23 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Illuminate\\Foundation\\Http\\Middleware\\TransformsRequest->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#24 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/ValidatePostSize.php(27): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#25 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Illuminate\\Foundation\\Http\\Middleware\\ValidatePostSize->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#26 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php(86): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#27 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Illuminate\\Foundation\\Http\\Middleware\\PreventRequestsDuringMaintenance->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#28 /src/vendor/fruitcake/laravel-cors/src/HandleCors.php(37): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#29 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Fruitcake\\Cors\\HandleCors->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#30 /src/vendor/fideloper/proxy/src/TrustProxies.php(57): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#31 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(167): Fideloper\\Proxy\\TrustProxies->handle(Object(Illuminate\\Http\\Request), Object(Closure))
#32 /src/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}(Object(Illuminate\\Http\\Request))
#33 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\\Pipeline\\Pipeline->then(Object(Closure))
#34 /src/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\\Foundation\\Http\\Kernel->sendRequestThroughRouter(Object(Illuminate\\Http\\Request))
#35 /src/public/index.php(52): Illuminate\\Foundation\\Http\\Kernel->handle(Object(Illuminate\\Http\\Request))
#36 /src/server.php(21): require_once('/src/public/ind...')
#37 {main}
"}

调用file_get_contens读文件时,因不存在的文件名报错后会将文件名会写入日志中,一共会写入三处,因为第三处会将超出长度上限的内容省略,所以这里可以控制的内容为两处,它们前后都存在一些冗余字符。

正确操作应该是运用前面提到的多种转码手段,quoted-printable用于解决文件名无法存在\x00的问题(UTF-16le需要用到),UTF-16le用于解决base64解码会因为=出现错误的问题,base64解码用于去除冗余字符。

具体的操作流程应该如下:

  1. 通过base64解码和write标识符,利用解码失败解码结果为空的特性清空整个log(可跳过),如果清空不了可以和convert.iconv.utf-16le.utf-8一起用
  2. 加上15个字符的前缀发送poc
  3. 发送一个数据包,用于保证UTF-16le转码时字符数为偶数
  4. 按顺序解码去除冗余字符
  5. 触发phar反序列化

此时会解码出两段poc,可以通过添加后缀也可以通过修改phar数据的方式来完成反序列化。


参考文章

Laravel Debug RCE

另一篇Laravel Debug RCE

phar相关知识


Web PHP

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

从FTP到PHP-FPM
Jenkins相关漏洞学习(二)