# WriteUp Web Challange Với khao khát được vào CLB. Em hy vọng sẽ có cơ hội gia nhập KCSC để học hỏi và phát triển cùng các anh chị trong CLB ạ. ## Development A Casino Link challange: https://dreamhack.io/wargame/challenges/2453 Source code: <details> <summary>server.js</summary> ```js const express = require('express'); const cookieParser = require('cookie-parser'); const { NodeVM } = require('vm2'); const jwt = require('jsonwebtoken'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const INITIAL_BALANCE = 100; const MAX_BALANCE = 2000000; const FLAG_PRICE = 1000000; const FLAG_PATH = path.join(__dirname, 'flag.txt'); let FLAG = 'FLAG_NOT_FOUND'; try { FLAG = fs.readFileSync(FLAG_PATH, 'utf8').trim(); } catch (e) { console.error('[!] flag.txt 읽기 실패:', e); } const JWT_PRIVATE_KEY = fs.readFileSync(path.join(__dirname, 'jwtRS256.key')); const JWT_PUBLIC_KEY = fs.readFileSync(path.join(__dirname, 'jwtRS256.key.pub')); const JWT_PUBLIC_KEY_PEM = fs.readFileSync(path.join(__dirname, 'jwtRS256.key.pub'), 'utf8'); app.use(express.json()); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'html'))); function signUser(uid, role) { const payload = { uid, role }; return jwt.sign(payload, JWT_PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '1h' }); } function getUserFromReq(req) { let token = req.cookies.auth; if (!token && req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { token = req.headers.authorization.slice(7); } if (!token) return null; try { const [headerB64] = token.split('.'); const headerJson = Buffer.from(headerB64, 'base64url').toString('utf8'); const header = JSON.parse(headerJson); let decoded; if (header.alg === 'RS256') { decoded = jwt.verify(token, JWT_PUBLIC_KEY, { algorithms: ['RS256'] }); } else if (header.alg === 'HS256') { decoded = jwt.verify(token, JWT_PUBLIC_KEY_PEM, { algorithms: ['HS256'] }); } else { throw new Error('unsupported alg'); } return decoded; } catch (e) { return null; } } function requireAuth(req, res, next) { const user = getUserFromReq(req); if (!user) { return res.status(401).json({ error: '인증 필요' }); } req.user = user; next(); } function requireRole(role) { return (req, res, next) => { const user = getUserFromReq(req); if (!user) { return res.status(401).json({ error: '인증 필요' }); } if (user.role !== role) { return res.status(403).json({ error: '권한 부족' }); } req.user = user; next(); }; } const balances = new Map(); function getUserId(req) { const user = getUserFromReq(req); return user ? user.uid : 'guest'; } function getBalance(req) { const uid = getUserId(req); let bal = balances.get(uid); if (bal === undefined) { bal = INITIAL_BALANCE; balances.set(uid, bal); } if (bal < -99999) bal = -99999; if (bal > MAX_BALANCE) bal = MAX_BALANCE; return bal; } function setBalance(req, balance) { const uid = getUserId(req); let bal = balance; if (bal < -99999) bal = -99999; if (bal > MAX_BALANCE) bal = MAX_BALANCE; balances.set(uid, bal); } app.post('/api/login/guest', (req, res) => { const uid = 'guest-' + Date.now().toString(36); const token = signUser(uid, 'user'); res.cookie('auth', token, { httpOnly: true, sameSite: 'lax' }); res.json({ token, user: { uid, role: 'user' } }); }); app.get('/api/me', (req, res) => { const user = getUserFromReq(req); res.json({ user: user || null }); }); app.get('/api/balance', (req, res) => { res.json({ balance: getBalance(req) }); }); app.post('/api/spin', (req, res) => { const bet = parseInt((req.body && req.body.bet) || 0, 10); let bal = getBalance(req); if (!Number.isFinite(bet) || bet <= 0) { return res.status(400).json({ error: 'invalid bet' }); } if (bet > bal) { return res.status(400).json({ error: '잔액 부족' }); } const symbols = ['🍒', '🍋', '⭐', '7']; const reels = [ symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)] ]; let delta; let message; if (reels[0] === '7' && reels[1] === '7' && reels[2] === '7') { delta = 100; message = 'JACKPOT! +100 크레딧!'; } else if (reels[0] === reels[1] && reels[1] === reels[2]) { delta = 30; message = '트리플 매치! +30 크레딧!'; } else { delta = -bet; message = `꽝… -${bet} 크레딧.`; } bal += delta; setBalance(req, bal); res.json({ reels, delta, balance: bal, message }); }); app.post('/api/strategy/run', requireRole('vip'), (req, res) => { const code = (req.body && req.body.code) ? String(req.body.code) : ''; if (!code || code.length > 4000) { return res.status(400).json({ error: 'invalid code length' }); } const blackList = /\brequire\b|\bprocess\b|\bchild_process\b|\bfs\b/; if (blackList.test(code)) { return res.status(400).json({ error: 'forbidden identifier in code' }); } const logs = []; const sandbox = { balance: getBalance(req), history: [], spin: (bet) => { let bal = sandbox.balance; bet = parseInt(bet, 10) || 0; if (bet <= 0) { return { error: 'invalid bet' }; } if (bet > bal) { return { error: '잔액 부족' }; } const symbols = ['🍒', '🍋', '⭐', '7']; const reels = [ symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)] ]; let delta; if (reels[0] === '7' && reels[1] === '7' && reels[2] === '7') { delta = 100; } else if (reels[0] === reels[1] && reels[1] === reels[2]) { delta = 30; } else { delta = -bet; } bal += delta; sandbox.balance = bal; const info = { bet, reels, delta, balance: bal }; sandbox.history.push(info); return info; } }; const vm = new NodeVM({ console: 'redirect', sandbox: { sandbox }, timeout: 1000, eval: false, wasm: false }); vm.on('console.log', (msg) => { logs.push(String(msg)); }); let result; try { result = vm.run(code, 'strategy.js'); } catch (e) { return res.status(400).json({ error: String(e), logs }); } setBalance(req, sandbox.balance); return res.json({ result: result === undefined ? null : result, logs, finalBalance: sandbox.balance, spins: sandbox.history }); }); app.post('/api/shop/flag', (req, res) => { let bal = getBalance(req); if (bal < FLAG_PRICE) { return res.status(400).json({ error: `잔액이 부족합니다. 플래그 가격은 ${FLAG_PRICE} 크레딧입니다.`, balance: bal }); } bal -= FLAG_PRICE; setBalance(req, bal); return res.json({ message: '플래그를 구매했습니다.', flag: FLAG, balance: bal }); }); app.get('/helpsign', (req, res) => { res.type('text/plain'); res.sendFile(path.join(__dirname, 'jwtRS256.key.pub')); }); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'html', 'index.html')); }); app.listen(PORT, () => { console.log(`[*] VM-JWT Casino listening on http://0.0.0.0:${PORT}`); }); ``` </details> #### Tổng quan: Server cung cấp cơ chế tài khoản guest, hệ thống xác thực bằng JWT, và một endpoint dành cho user VIP để thực thi “strategy code” trong sandbox (vm2). Ngoài ra còn có một cửa hàng cho phép mua flag khi người chơi có đủ balance. -> Mục tiêu của chall là tìm cách thu thập đủ balance để mua flag. ![image](https://hackmd.io/_uploads/r1X9pwOMbl.png) #### Phân tích source: Đầu tiên ta thấy có 2 function đáng chú ý.![image](https://hackmd.io/_uploads/BJoneOdMWl.png) - **signUser** ký JWT bằng **private_key** và sử dụng thuật toán **RS256**. > [RS256] Thuật toán này sử dụng cặp key: private_key để kí và public_key để verify - Tuy nhiên, trong hàm **getUserFromReq** (dòng 53), ta nhìn thấy điểm bất thường: - Biến **alg** được lấy trực tiếp từ phần header của JWT – một giá trị hoàn toàn do attacker kiểm soát. - Tại đây, nếu attacker đặt **alg = HS256**, server sẽ thực hiện verify bằng **public_key**. > [HS256] Thuật toán này sử dụng chung 1 key để kí và verify -> Như vậy nếu như ta có được public key ta có thể sử dụng nó để kí JWT hợp lệ theo HS256 và bypass được lớp xác thực JWT. Rất may mắn server đã lưu public key tại endpoint **/helpsign**: ![image](https://hackmd.io/_uploads/SkXxr_uMZg.png) -> Từ đó, ta có thể tạo JWT giả mạo và tùy chỉnh role của user nâng quyền lên VIP. Sau khi leo lên quyền VIP, ta có thể truy cập API cho phép chạy Javascript code ( có giới hạn ) trong môi trường sandbox của NodeVM: ```js app.post('/api/strategy/run', requireRole('vip'), (req, res) => { const code = (req.body && req.body.code) ? String(req.body.code) : ''; if (!code || code.length > 4000) { return res.status(400).json({ error: 'invalid code length' }); } const blackList = /\brequire\b|\bprocess\b|\bchild_process\b|\bfs\b/; if (blackList.test(code)) { return res.status(400).json({ error: 'forbidden identifier in code' }); } const logs = []; const sandbox = { balance: getBalance(req), history: [], spin: (bet) => { let bal = sandbox.balance; bet = parseInt(bet, 10) || 0; if (bet <= 0) { return { error: 'invalid bet' }; } if (bet > bal) { return { error: '잔액 부족' }; } const symbols = ['🍒', '🍋', '⭐', '7']; const reels = [ symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)], symbols[Math.floor(Math.random() * symbols.length)] ]; let delta; if (reels[0] === '7' && reels[1] === '7' && reels[2] === '7') { delta = 100; } else if (reels[0] === reels[1] && reels[1] === reels[2]) { delta = 30; } else { delta = -bet; } bal += delta; sandbox.balance = bal; const info = { bet, reels, delta, balance: bal }; sandbox.history.push(info); return info; } }; const vm = new NodeVM({ console: 'redirect', sandbox: { sandbox }, timeout: 1000, eval: false, wasm: false }); vm.on('console.log', (msg) => { logs.push(String(msg)); }); let result; try { result = vm.run(code, 'strategy.js'); } catch (e) { return res.status(400).json({ error: String(e), logs }); } setBalance(req, sandbox.balance); return res.json({ result: result === undefined ? null : result, logs, finalBalance: sandbox.balance, spins: sandbox.history }); }); ``` Khi truy cập endpoint này, người dùng có thể gửi lên một đoạn “strategy code”, và đoạn code đó sẽ được thực thi bên trong môi trường sandbox của vm2. Sandbox này được thiết kế để mô phỏng việc người chơi tự động hóa việc chơi slot machine: họ có thể gọi **sandbox.spin(bet)** để đặt cược và hệ thống sẽ trả về kết quả từng vòng quay. - Trước khi chạy code server đưa vào VM biến **sandbox.balance**, giá trị chính là số dư thật hiện tại của tài khoản. ```js const sandbox = { balance: getBalance(req), ... } ``` - Trong lúc chạy code, sandbox có toàn quyền thao tác lên biến **sandbox.balance**. Đây chỉ là một giá trị trong môi trường VM. - Sau khi code chạy xong, server thực hiện lệnh: ```js setBalance(req, sandbox.balance); ``` Nghĩa là balance thật của user sẽ được gán bằng giá trị sandbox.balance. Như vậy với quyền VIP (giả mạo JWT), attacker có thể gửi một strategy code đơn giản là có thể có đủ tiền mua flag: ```js sandbox.balance = 2000000; ``` #### Exploit: - Bước 1: Gửi req tới **/helpsign** để lấy public_key và kí token JWT **role=vip** bằng thuật toán HS256 với public_key vừa lấy được: ![image](https://hackmd.io/_uploads/r1TVAYOG-x.png) - Bước 2: Sử dụng token vừa kí gửi req tới **/api/strategy/run** với body như ảnh: ![image](https://hackmd.io/_uploads/H1v41cuz-e.png) - Bước 3: Gửi req tới **/api/shop/flag** ta thành công lấy flag: ![image](https://hackmd.io/_uploads/BkvAkcOMWl.png) ## Special Web Link challange: https://dreamhack.io/wargame/challenges/2560 Source code: <details> <summary>app.py</summary> ```python from flask import Flask, request, make_response, render_template import bleach from selenium.webdriver.chrome.service import Service from selenium import webdriver import time import os app = Flask(__name__) key = os.urandom(32).hex() def clean(msg): allowed_tags = ['a', 'b', 'br', 'font', 'h1', 'i', 'math', 'p', 'span', 'strong', 'style', 'u'] allowed_attrs = { 'a': ['href', 'title'], 'font': ['color', 'size'] } cleaned = bleach.clean(msg, tags=allowed_tags, attributes=allowed_attrs, strip_comments=False) return cleaned def read_url(url): driver = None try: service = Service(executable_path="/usr/local/bin/chromedriver") options = webdriver.ChromeOptions() for opt in [ "headless", "window-size=1920x1080", "disable-gpu", "no-sandbox", "disable-dev-shm-usage", ]: options.add_argument(opt) driver = webdriver.Chrome(service=service, options=options) driver.implicitly_wait(3) driver.set_page_load_timeout(3) driver.get("http://127.0.0.1:5000/") driver.add_cookie({'name':'membership','value':f'{key}'}) driver.get(url) time.sleep(1) except Exception as e: if driver: driver.quit() return False if driver: driver.quit() return True @app.route('/') def index(): user_membership = request.cookies.get('membership', 'guest') VIP = False FLAG = None if user_membership == key: VIP = True FLAG = "B1N4RY{**redacted**}" response = make_response(render_template( 'index.html', membership=user_membership, is_vip=VIP, flag=FLAG )) if 'membership' not in request.cookies: response.set_cookie('membership', 'guest') return response @app.route('/event') def event(): msg = request.args.get('msg', '') clean_msg = clean(msg) return render_template('event.html', cookie_name=msg, clean_name=clean_msg) @app.route('/send') def send(): name = request.args.get('name') url = f'http://127.0.0.1:5000/event?msg={name}' result = read_url(url) if result: return '<script>alert("응모되었습니다!"); history.back();</script>' else: return '<script>alert("오류 발생!"); history.back();</script>' if __name__ == '__main__': app.run(host="0.0.0.0", port=5000,debug=False) ``` </details> #### Phân tích source: Bắt đầu với route / ![image](https://hackmd.io/_uploads/SyyX5h_zWl.png) Tại dòng 57, ứng dụng lấy giá trị cookie membership từ request. ```python user_membership = request.cookies.get('membership', 'guest') ``` Đây là giá trị hoàn toàn do attacker kiểm soát. Tiếp tục quan sát dòng 62: ```python if user_membership == key: VIP = True FLAG = "B1N4RY{**redacted**}" ``` Ứng dụng chỉ trả về FLAG khi cookie_membership trùng khớp với giá trị key mà server tạo ra. Như vậy, để lấy được FLAG, ta cần phải có được giá trị key này. Quay lại đầu file, ta thấy key được sinh ra ngẫu nhiên tại dòng 10: ```python > [10] key = os.urandom(32).hex() ``` Do đó, việc brute-force key là không thể. Tuy nhiên, quan sát hàm read_url cho thấy một điểm quan trọng: server sử dụng Selenium Chrome headless để tự động truy cập URL theo request của người dùng. ![image](https://hackmd.io/_uploads/SJ7di3ufZg.png) Tại dòng 42: ```python driver.add_cookie({'name':'membership','value':f'{key}'}) ``` Bot Selenium truy cập vào localhost và gán cookie membership bằng đúng key VIP của server. Nghĩa là bot luôn hoạt động trong trạng thái VIP. Kết hợp với route /send?name=..., attacker có thể kiểm soát URL mà bot sẽ truy cập: /event?msg=<payload>. ![image](https://hackmd.io/_uploads/rJzqR3ufWl.png) Tại endpoint /event: ![image](https://hackmd.io/_uploads/B1SWJTOGWe.png) clean_msg là biến lấy từ user input qua tham số msg sau đó đi qua hàm clean tại dòng 82: ![image](https://hackmd.io/_uploads/SJZglaOfWx.png) Hàm này filter khá nhiều chỉ còn lại một vài attribute và tag. Biến clean_msg này được truyền và render qua tham số clean_name tại file event.html![image](https://hackmd.io/_uploads/BJAvx6_MWe.png) Ta thấy biến này được render với tham số **| safe** tại dòng 30 > Tham số `| safe`: bỏ qua cơ chế escape, nghĩa là giá trị clean_name sẽ được render nguyên văn, HTML hoặc Javascript bên trong sẽ thực thi bình thường nếu trình duyệt nhận -> XSS Đến đây, hướng khai thác trở nên rõ ràng: nếu có thể inject được payload XSS vượt qua bộ lọc của Bleach, attacker có thể đánh cắp giá trị cookie VIP của bot thông qua một request mà bot sẽ tự kích hoạt khi truy cập URL do attacker cung cấp. Từ cookie đó, attacker hoàn toàn có thể gửi request như một VIP thật sự và lấy FLAG. Tuy nhiên, với danh sách các tag và attribute được phép trong hàm **clean()**, việc chạy được JavaScript là cực kỳ khó. Sau một thời gian thử các kỹ thuật bypass thông thường nhưng không thành công, mình chuyển hướng sang kiểm tra xem phiên bản **Bleach** mà bài đang sử dụng có lỗ hổng hay không. ![image](https://hackmd.io/_uploads/H176zTuGbl.png) Rất may mắn, chỉ cần tra cứu nhanh là thấy ngay rằng phiên bản Bleach này bị ảnh hưởng bởi lỗ hổng **CVE‑2021‑23980**: Link: https://github.com/mozilla/bleach/security/advisories/GHSA-vv2x-vrpj-qqpq ![image](https://hackmd.io/_uploads/H1SPm6uf-g.png) - So sánh điều kiện, ta thấy hoàn toàn khớp với hàm clean() trong challenge: - Cho phép một số tag HTML nhất định - Không strip comment - Không giới hạn giá trị của các attribute - Và đặc biệt là cho phép tag \<style>, vốn liên quan trực tiếp tới vector khai thác của lỗ hổng Thậm chí, lỗ hổng này đã có sẵn POC chính thức từ Mozilla: https://bugzilla.mozilla.org/show_bug.cgi?id=1689399 Với tất cả thông tin trên, ta đã có đầy đủ dữ kiện để bắt đầu xây dựng payload khai thác. #### Exploit: Đầu tiên, ta test thử payload có sẵn trong POC xem có hoạt động không:![Pasted image](https://hackmd.io/_uploads/r1kQHa_M-e.png) Hoàn toàn thành công. Tiếp theo, Craft payload để lấy cookie gửi về webhook: ![Pasted image (2)](https://hackmd.io/_uploads/BkSOHT_fZl.png) Ta thấy đã xuất hiện fetch tại console chứng tỏ payload thành công. Gửi payload thông qua endpoint **/send?name=...** để kích hoạt hàm **read_url** lấy key gửi về webhook: ![Pasted image (3)](https://hackmd.io/_uploads/HJl8I6dfWg.png) Thành công lấy key: ![image](https://hackmd.io/_uploads/Bk8AITdfWg.png) Gửi cookie: **membership=key** ta thành công lấy flag ![Pasted image (4)](https://hackmd.io/_uploads/B1kMDTuf-g.png)