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/ben10adminで始まるユーザとしてアクセスすると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があっても「購入しても残高が減らない」とかかなぁと思っていると、よく見たらredeemnew Promise(resolve => setTimeout(resolve, delay * 1000));してた。

というわけで、ここを殴ります。

Burp CEのIntruderがおそすぎたので、適当にシェルスクリプトを書きました。何回か挑戦したらFlagを購入できる金額まで増えたので買います

srdnlen{6peed_1s_My_0nly_Competition}

感想

なんか微妙なクオリティだった(.DS_Storeが残ってたり)ものの、いままでやったことが無いタイプの問題だったのでまぁ面白かったです。Webやってないだけかもだけど。

あとジークアクスはまだ見れてないです。早くみたい。