srdnlen CTF 2025 Writeup
srdnlen
日本時間の午前2時から始まりました。起床事故を起こした上、ジークアクス見に行きたいという気持ちと戦いながらちょっとやったのでWriteupです。
今回はWebのかんたんなやつだけ解きました
ben10
ben10という作品があり、そのキャラクターがいろいろでてきます。
アプリケーションはFlask製です。その時点でSSTIなどの可能性を考えますが、全然違いました
@app.route('/image/<image_id>')
def image(image_id):
"""Display the image if user is admin or redirect with missing permissions."""
if 'username' not in session:
return redirect(url_for('login'))
username = session['username']
print(username)
if image_id == 'ben10' and not username.startswith('admin'):
return redirect(url_for('missing_permissions'))
flag = None
if username.startswith('admin') and image_id == 'ben10':
flag = FLAG
return render_template('image_viewer.html', image_name=image_id, flag=flag)
こんな感じで、/image/ben10
にadmin
で始まるユーザとしてアクセスするとFlagが取得できます。
で、そのadmin
はユーザ作成時に自動で作成されます。たとえばfoobar
というユーザ名で登録するとadmin^foobar^{secrets.token_hex(5)}
という名前のユーザも自動で作成されるわけです。
@app.route('/register', methods=['GET', 'POST'])
def register():
"""Handle user registration."""
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if username.startswith('admin') or '^' in username:
flash("I don't like admins", "error")
return render_template('register.html')
if not username or not password:
flash("Both fields are required.", "error")
return render_template('register.html')
admin_username = f"admin^{username}^{secrets.token_hex(5)}"
admin_password = secrets.token_hex(8)
try:
conn = sqlite3.connect(DATABASE)
cursor = conn.cursor()
cursor.execute("INSERT INTO users (username, password, admin_username) VALUES (?, ?, ?)",
(username, password, admin_username))
cursor.execute("INSERT INTO users (username, password, admin_username) VALUES (?, ?, ?)",
(admin_username, admin_password, None))
conn.commit()
except sqlite3.IntegrityError:
flash("Username already exists!", "error")
return render_template('register.html')
finally:
conn.close()
flash("Registration successful!", "success")
return redirect(url_for('login'))
return render_template('register.html')
また、forgot_password
はこのような実装になっており、
@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
"""Handle password reset."""
if request.method == 'POST':
username = request.form['username']
reset_token = request.form['reset_token']
new_password = request.form['new_password']
confirm_password = request.form['confirm_password']
if new_password != confirm_password:
flash("Passwords do not match.", "error")
return render_template('forgot_password.html', reset_token=reset_token)
user = get_user_by_username(username)
if not user:
flash("User not found.", "error")
return render_template('forgot_password.html', reset_token=reset_token)
if not username.startswith('admin'):
token = get_reset_token_for_user(username)
if token and token[0] == reset_token:
update_password(username, new_password)
flash(f"Password reset successfully.", "success")
return redirect(url_for('login'))
else:
flash("Invalid reset token for user.", "error")
else:
username = username.split('^')[1]
token = get_reset_token_for_user(username)
if token and token[0] == reset_token:
update_password(request.form['username'], new_password)
flash(f"Password reset successfully.", "success")
return redirect(url_for('login'))
else:
flash("Invalid reset token for user.", "error")
return render_template('forgot_password.html', reset_token=request.args.get('token'))
admin
のパスワードを、非admin
から変更できるバグがあります
とはいえbotがいないのでXSSでもなさそうだし、SQLiも無いし、どうやってsecrets.token
込みのユーザ名特定すんねんと唸っていたところ、templateになんかいました
<!-- secret admin username -->
<div style="display:none;" id="admin_data"></div>
これでadmin
のユーザ名は特定できます。なんなんだ。
/password_reset
から、自分で登録した非Adminのトークンを取得し、そのトークンを使用してadmin
のパスワードを変更してログインしました。
srdnlen{b3n_l0v3s_br0k3n_4cc355_c0ntr0l_vulns}
Focus. Speed. I am speed
買い物ができるWebアプリケーションです。
フレームワークにはexpressが使われており、DBはmongoです。mongoを使うということはNoSQL Injectionでしょう。
最初の所持金は0ですが、ランダムに生成されたDiscount Codeを登録すると増えます。
// Generate a random discount code
const generateDiscountCode = () => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let discountCode = '';
for (let i = 0; i < 12; i++) {
discountCode += characters.charAt(Math.floor(Math.random() * characters.length));
}
return discountCode;
};
router.get('/redeem', isAuth, async (req, res) => {
try {
const user = await User.findById(req.user.userId);
if (!user) {
return res.render('error', { Authenticated: true, message: 'User not found' });
}
// Now handle the DiscountCode (Gift Card)
let { discountCode } = req.query;
if (!discountCode) {
return res.render('error', { Authenticated: true, message: 'Discount code is required!' });
}
const discount = await DiscountCodes.findOne({discountCode})
if (!discount) {
return res.render('error', { Authenticated: true, message: 'Invalid discount code!' });
}
// Check if the voucher has already been redeemed today
const today = new Date();
const lastRedemption = user.lastVoucherRedemption;
if (lastRedemption) {
const isSameDay = lastRedemption.getFullYear() === today.getFullYear() &&
lastRedemption.getMonth() === today.getMonth() &&
lastRedemption.getDate() === today.getDate();
if (isSameDay) {
return res.json({success: false, message: 'You have already redeemed your gift card today!' });
}
}
// Apply the gift card value to the user's balance
const { Balance } = await User.findById(req.user.userId).select('Balance');
user.Balance = Balance + discount.value;
// Introduce a slight delay to ensure proper logging of the transaction
// and prevent potential database write collisions in high-load scenarios.
new Promise(resolve => setTimeout(resolve, delay * 1000));
user.lastVoucherRedemption = today;
await user.save();
return res.json({
success: true,
message: 'Gift card redeemed successfully! New Balance: ' + user.Balance // Send success message
});
} catch (error) {
console.error('Error during gift card redemption:', error);
return res.render('error', { Authenticated: true, message: 'Error redeeming gift card'});
}
});
で、まぁfindOne
にパラメータを直接渡しているのでNoSQL Injectionができます。
/redeem?discountCode[$gt]=
しかし、Discount Codeは一日に一度しか使えず、さらに一回で増える金額ではFlagを購入できません。
色々見たりしているうちに、名前がSpeedであることからRace Conditionを疑います。
とはいえRaceがあっても「購入しても残高が減らない」とかかなぁと思っていると、よく見たらredeem
でnew Promise(resolve => setTimeout(resolve, delay * 1000));
してた。
というわけで、ここを殴ります。
Burp CEのIntruderがおそすぎたので、適当にシェルスクリプトを書きました。何回か挑戦したらFlagを購入できる金額まで増えたので買います
srdnlen{6peed_1s_My_0nly_Competition}
感想
なんか微妙なクオリティだった(.DS_Store
が残ってたり)ものの、いままでやったことが無いタイプの問題だったのでまぁ面白かったです。Webやってないだけかもだけど。
あとジークアクスはまだ見れてないです。早くみたい。
Comments