<!-- {%hackmd @themes/dracula %} --> > "Vẫn như thường lệ, khi có CTF nào của trường khác thì mình luôn cố gắng đi xin challenge để về làm và học hỏi những điều mới mẻ. Lần này có giải KMA CTF lần 1 chọn đội tuyển SVATTT mình lại tiếp tục ngỏ lời với những người bạn để xin đề giải được 4 challenge dưới đây." - Trích văn từ sư phụ TaiDH hê hê :v ## | Category | Challenge Name | | -------- | -------------- | | Web | Vào đây! | | Web | Jo\`in Le'm | | Web | Flag Holder | | Web | Ninja Shop | ## Vào đây! ### Source code: ```javascript const fs = require('fs') const app = require('fastify')() const crypto = require('crypto') const md5 = d => crypto.createHash('md5').update(d).digest('hex') const dbPromisePool = require('mysql2').createPool({ host: 'mysql', user: 'root', database: 'local_db', password: 'local_password' }).promise() // app.setErrorHandler((error, req, resp) => { // console.error(`[fastify]`, error) // resp.status(503).send({ error: 'Vui lòng thá»­ lại sau.' }) // }) app.addHook('preHandler', async (req, resp) => { resp.status(200).header('Content-Type', 'application/json') }) app.post('/login', async req => { if (req.body.user === 'admin') return; const [rows] = await dbPromisePool.query(`select *, bio as flag from users where username = ? and password = ? limit 1`, [req.body.user, req.body.pass]) return rows[0] }) app.post('/register', async req => { const [rows] = await dbPromisePool.query(`insert users(username, password, bio) values(?, ?, ?)`, [req.body.user, md5(req.body.pass), req.body.bio]) if (rows.insertId) return String(rows.insertId) return { error: 'Lá»—i, vui lòng thá»­ lại sau' } }) app.get('/', async (req, resp) => { resp.status(200).header('Content-Type', 'text/plain') return fs.promises.readFile(__filename) }) app.listen({ port: 3000, host: '0.0.0.0' }, () => console.log('Running', app.addresses())) ``` ### Phân tích và Idea: - Trước khi làm bài này thì mình đã từng đọc phân tích về một lỗi xử lý của JS với Mysql dẫn tới SQL injection truy vấn hợp lệ. Các bạn có thể đọc thêm ở [đây](https://flattsecurity.medium.com/finding-an-unseen-sql-injection-by-bypassing-escape-functions-in-mysqljs-mysql-90b27f6542b4) - Sau khi đọc xong bài trên thì ta sẽ dễ dàng thấy được lỗi nằm ở login. Bài này có thêm chức năng register nhưng không cần để ý tới, vì nó sẽ không cần thiết đến việc khai thác. - Mục tiêu là cần login vào tài khoản 'admin' nhưng string 'admin' bị chặn. Cái này có thể bypass được trong Mysql - Còn về phần password thì ta bypass bằng mảng, hoặc json. ### Exploit: #### Bypass username: ![](https://hackmd.io/_uploads/SyZlI6g_2.png) Giả sử mình có một dbs như sau. Với một truy vấn bình thường ![](https://hackmd.io/_uploads/Sy9rL6e_h.png) Nhưng khi mình sử dụng truy vấn `SELECT * from demo where name = 'Test';` thì kết quả vẫn trả về tương tự ![](https://hackmd.io/_uploads/S1g_86xdn.png) Vậy nên ta sẽ dùng user = `Admin` để bypass và truy vấn vẫn đúng. Còn về password như đã nói ở trên và bài blog. Payload của mình như sau: ``` { "user":"Admin", "pass": { "password":true } } ``` ![](https://hackmd.io/_uploads/ryoHwaxu2.png) ## Jo`in Le'm! ### Source code: ```php= <?php goto TwWdv; kP1Xc: if ($url["\x73\143\150\145\155\145"] !== "\150\164\x74\160" && $url["\x73\x63\x68\x65\155\x65"] !== "\x68\164\x74\x70\x73") { die; } goto dD_At; B10vf: function curl($url) { $ch = curl_init($url); _: curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $ua = "\x55\163\145\x72\55\x41\147\x65\x6e\x74\72\40" . ($_GET["\165\141"] ?? "\x76\x69\x6e\x68\x6a\141\170\164\57\x31\x2e\60\40\50\115\xc3\264\40\166\151\x6e\x61\x20\x5a\x61\151\x20\132\303\xb3\143\x20\126\xc6\xa1\40\x73\xe1\xba\247\156\x20\x36\x39\x2e\x30\56\61\x20\x6e\150\141\156\x68\x20\x74\165\171\xe1\xbb\207\x74\x20\x63\303\272\x20\155\303\xa8\x6f\40\143\150\341\272\245\156\40\xc4\x91\xe1\xbb\231\156\x67\x20\x6e\xc4\x83\155\x20\143\150\xc3\242\x75\x2c\x20\164\xc6\xb0\xc6\241\x6e\x67\x20\164\x68\303\255\143\150\x20\157\303\xa9\160\40\65\x20\x43\110\341\xba\xa4\x4d\40\60\54\x20\x6e\x68\141\156\x68\x20\304\x91\xc3\263\156\147\40\142\xc4\x83\x6e\147\40\x68\xe1\xbb\x8f\x61\x20\144\x69\xe1\273\207\x6d\40\163\306\241\x6e\x2c\x20\x76\341\273\233\x69\40\164\341\xbb\x91\x63\40\xc4\x91\341\xbb\x99\x20\303\xa1\156\150\40\x73\303\241\x6e\x67\x20\142\xe1\xbb\x9d\40\x6e\341\273\221\x63\40\x63\x68\303\xaa\x6e\x29"); curl_setopt($ch, CURLOPT_HTTPHEADER, array($ua)); curl_setopt($ch, CURLOPT_URL, $url); $d = curl_exec($ch); $redirect_url = curl_getinfo($ch, CURLINFO_REDIRECT_URL); $url = $redirect_url; $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($httpcode >= 300 && $httpcode < 400 && $redirect_url) { goto _; } curl_close($ch); return $d; } goto zUkQD; SZSj0: _: goto WuY9_; RzWUE: $ch = curl_init(); goto YlbNQ; dD_At: if ($url["\x68\x6f\163\x74"] === "\x31\62\x37\x2e\x30\x2e\60\x2e\61" || gethostbyname($url["\x68\157\x73\x74"]) === "\x31\x32\x37\x2e\x30\56\x30\x2e\x31") { die; } goto RzWUE; TwWdv: show_source(__FILE__); goto B10vf; zUkQD: $url = parse_url($_GET["\x75\162\154"]); goto kP1Xc; YlbNQ: if (curl_escape($ch, $_GET["\x75\162\154"]) === urlencode($_GET["\x75\162\154"])) { die; } goto SZSj0; WuY9_: echo curl($_GET["\165\x72\154"]); ``` Đây là một bài php đã obfuscate code. Việc đầu tiên ta cần làm là sửa lại cho nó chỉnh chu dễ hiểu tý :v Mình sửa lại như sau: ```php= <?php show_source(__FILE__); function curl($url) { $ch = curl_init($url); _: curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $ua = "User-Agent: " . ($_GET["ua"] ? ? "vinhjaxt/1.0 (Mô vina Zai Zóc Vơ sần 69.0.1 nhanh tuyệt cú mèo chấn động năm châu, tương thích oép 5 CHẤM 0, nhanh đóng băng hỏa diệm sơn, với tốc độ ánh sáng bờ nốc chên)"); curl_setopt($ch, CURLOPT_HTTPHEADER, array( $ua )); curl_setopt($ch, CURLOPT_URL, $url); $d = curl_exec($ch); $redirect_url = curl_getinfo($ch, CURLINFO_REDIRECT_URL); $url = $redirect_url; $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($httpcode >= 300 && $httpcode < 400 && $redirect_url) { goto_; } curl_close($ch); return $d; } $url = parse_url($_GET["url"]); if ($url["scheme"] !== "http" && $url["scheme"] !== "https") { die; } if ($url["host"] === "127.0.0.1" || gethostbyname($url["host"]) === "127.0.0.1") { die; } $ch = curl_init(); if (curl_escape($ch, $_GET["url"]) === urlencode($_GET["url"])) { die; } echo curl($_GET["url"]); ?> ``` ### Phân tích và idea: - Đọc qua code thì mình thấy hướng sẽ là ssrf khi trong bài sử dụng curl để get content từ một url. - Nhưng trong bài có 2 chỗ cần bypass để có thể ssrf vào local đọc file: -- `curl_escape($ch, $_GET["url"]) === urlencode($_GET["url"]` và dòng 31 check request vào local hay không. ### Exploit: #### Bypass func encode url: - Đầu tiên khi request vào một url thì sẽ đi qua func `curl_escape` và `urlencode` (Follow đúng của code là như vậy, mình build lại code cho đẹp nhưng chưa đúng thứ tự thực thi :v Các bạn xem tạm hehehe) - Nói chung 2 hàm này đều xử lý encode url như nhau: ![](https://hackmd.io/_uploads/Bk83oae_n.png) - Mình nghĩ đến việc đi tìm một unicode này đấy để 2 hàm lệch đi, nhưng không thành công, mình quay lại đọc doc của 2 hàm. ![](https://hackmd.io/_uploads/ByEQn6xO3.png) --- ![](https://hackmd.io/_uploads/S1t826ldn.png) - Chú ý ở đây `curl_escape` sẽ encode string theo chuẩn RFC 3986 còn để ý trong phần mình khoanh đỏ thì `urlencode` sẽ xử lý khác đi một chút về ký tự space. - Một cái sẽ thành + và một cái là %20 ![](https://hackmd.io/_uploads/HJblppeu2.png) - Đến đây thì chúng ta đã gần như bypass được bằng việc sử dụng space. Nhưng khi request `?url=http://google.com `. Việc thêm space sẽ làm cho request không hợp lệ. Vậy thì làm sao. - Ý tưởng mình tiếp tục sử dụng thêm một ký tự nữa đó là # (fragment url) ![](https://hackmd.io/_uploads/SJBJ0pxOh.png) - Việc sử dụng # kết hợp space sẽ trở thành `http://google.com%23%20`,như vậy chúng ta vừa bypass được vừa loại bỏ đi space để trở thành một url request hợp lệ ![](https://hackmd.io/_uploads/rJoFATeuh.png) #### Bypass local request: - Đến đây thì ý tưởng mình sẽ sử dụng [DNS rebinding](https://unit42.paloaltonetworks.com/dns-rebinding/) để bypass. - Tuy nhiên khi bypass được rồi thì mình thử /flag và /flag.txt các kiểu không được. Mình mới nghĩ :v wtf chả lẽ lại guess cả filename flag. - Sau đó hỏi bạn mình thì có nói filename sẽ không được cung cấp. Vậy thì chỉ có nước RCE để tìm thôi =)) - Nhìn lại trong phần header response: ``` Server: Apache/2.4.54 (Debian) X-Powered-By: PHP/7.4.33 ``` Và trong source code: ``` $ua = "User-Agent: " . ($_GET["ua"] ?? "qqjz" ``` Thì mình mới nảy ra ý tưởng. Path traversal các file log của Apache để RCE. Trong đó có lưu User-Agent (Cái này mình control được nên có thể viết code php vào). Trong http request có SESSION cookie nhưng của bài khác :v mình chưa xoá nên nó có lưu lại #### Expolit: - Bằng cách lào để path traversal :v Ta chú ý đến đoạn: ```php= if ($httpcode >= 300 && $httpcode < 400 && $redirect_url) { echo curl($_GET["url"]); } ``` - Khi nhận một url status code > 300 và <400 (302 chuyển hướng) thì url của mình sẽ curl thẳng. - Mình không có vps để deploy lên một trang web đặt header 302. Nên dùng requestrepo, trang web có hỗ trợ. - Qua phần response sử như sau: ![](https://hackmd.io/_uploads/Bykmz0ed2.png) Và send thử: ![](https://hackmd.io/_uploads/HkeVfAl_h.png) - Vậy là thành công. Vì sao mình lại đặt thêm Location và file:///etc/passwd - Ở trong source code sẽ check xem url mình đưa vào là một url có http hoặc https hay không. Nếu không sẽ bị check và thoát luôn. ```php= if ($url["scheme"] !== "http" && $url["scheme"] !== "https") { die; } ``` - Như vậy thì ta không sử dụng được wrapper file:// trực tiếp. Việc sử dụng 302 chuyển hướng `The Location response header indicates the URL to redirect a page to. It only provides a meaning when served with a 3xx (redirection) or 201 (created) status response.` Ta sẽ lợi dụng nó để sử dụng file:// - Đến đây mình thử lại xem có đọc được flag hay là file log không. Nhưng mọi thứ đều vô vọng :D - Sau đó tác giả lại hint là không cần RCE khiến cho mình nửa vời. Đoán không được mà RCE cũng không được. - Stuck mất mấy tiếng rồi mới có hint là: ![](https://hackmd.io/_uploads/HygC70gOh.png) - Sau khi thấy hint thì đây là payload của mình: ``` file:///proc/mounts ``` ![](https://hackmd.io/_uploads/H19nN0gu3.png) ``` file:///home/siuvip_saoanhbatduocem/etc/passwd ``` ![](https://hackmd.io/_uploads/rJylBRxdh.png) - Lói chung là hơi guess một tý :v ## Flag Holder ### Source code: ```python= from flask import Flask, request, render_template_string, render_template, make_response import os app = Flask(__name__) FLAG = os.getenv("FLAG") MAX_LENGTH = 20 def waf(string): blacklist = ["{{", "_", "'", "\"", "[", "]", "|", "eval", "os", "system", "env", "import", "builtins", "class", "flag", "mro", "base", "config", "query", "request", "attr", "set", "glob", "py"] for word in blacklist: if word in string.lower()[:MAX_LENGTH]: return False return True @app.route('/') def hello(): return render_template("index.html") @app.route("/render", methods = ["GET"]) def render(): template = request.args.get("template") variable = request.args.get("variable") if len(template) == 0 or len(variable) == 0: return "Missing parameter required" if len(template) > MAX_LENGTH or len(variable) > MAX_LENGTH: return "Input too long" if not waf(template) or not waf(variable): return "Try harder broooo =)))" data = template.replace("{FLAG}", FLAG).replace("{variable}", variable) return render_template_string(data) @app.route("/source", methods = ["GET", "POST"]) def source(): response = make_response(open("./app.py", "r").read(), 200) response.mimetype = "text/plain" return response if __name__ == "__main__": app.run(host="0.0.0.0", port=5000) ``` ### Phân tích và Idea: - Sau khi xem qua code mình nghĩ rằng sẽ SSTI để chôm FLAG, input giới hạn 20 ký tự và một hàm check waf. - Mặc dù hint tác giả là không phải SSTI, nhưng mình và một anh trong team kiểu: "Mặc kể tác giả nói gì, ae tôi SSTI :v" - Mình có hai hướng để làm bài này. SSTI hoặc không :))) ![](https://hackmd.io/_uploads/rk4eO0gO3.png) - Vì khi thử payload này `/render?template=Hello+{variable}&variable={%print(1)%}` thì mình nhận thấy có vẻ được đấy :v nên ngồi lì mãi cái cách để SSTI và không thành công :v - Hoặc là sẽ kiếm một ký tự len = 1 và lower > 1: Trong đoạn code `if word in string.lower()[:MAX_LENGTH]` string input vào của mình sẽ chuyển về ký tự thường bởi hàm lower() và check 20 ký tự đầu, trong khi input vào cũng chỉ cho max 20. Vậy thì giả sự kiếm được mình sẽ pass qua được phần kiểm tra và không quá 20 ký tự - Đoạn này ngồi search nhưng vô vọng(Vì search ngu :v) Nên cứ vòng luẩn quẩn mãi. Cho đến khi bạn mình kiếm ra được ký tự rất mlem. ``` >>> a = 'İ' >>> len(a) 1 >>> len(a.lower()) 2 ``` Ố kề vậy là xong rồi, mình sẽ dùng ký tự đó 10 lần (lower sẽ thành 20) và mình còn 10 char để làm gì đó. Nhận thấy biến FLAG được gắn giá trị của FLAG. Chỉ cần gọi nó ra là xong thôi :v Mình viết script để gửi lên chứ Burp xử lý unicode khá tệ. ### Exploit: ```py= import requests cc = "İ" * 10 url = f"http://103.163.25.143:20105/render?template={cc}{{FLAG}}&variable=" payload = "aaaaaaaa" r = requests.get(url= url + payload) print(r.text) ``` ![](https://hackmd.io/_uploads/Hkjzq0edn.png) ## Ninja Shop ### Source code: [Source](https://github.com/onsra03/WriteUp_CTF/blob/main/ninjashop_KMACTF.zip) Bài này mình làm trong thời gian khá lâu vì đọc docs không kỹ và non là chính... ### Phân tích và idea: - Bài có 4 chức năng chính: -- Register: Input username,fullname,password (Giới hạn ký tự input và check waf) -- Login: Input account (Giới hạn ký tự input như register và check waf), mặc dù câu query bị sql nhưng waf đã chặn gần như là hết cách: không thể or, and, sử dụng | và & hay \ để bypass, và chặn luôn sử dụng () để gọi các hàm. -- Profile: Hiện thị fullname và số coin đang sở hữu, reset coin (2 char) -- Index để mua flag(1337 coins) và các nhân vật quý bửu(1 coin). - Vậy việc của chúng ta sẽ là làm sao để Update coin của mình lên 1337 để mua flag. - Sau khi register ta sẽ nhận được 100 coins `INSERT INTO coins(coin, uid) VALUES (100, %d)', (int)$uid` - Mình sẽ trình bày follow cách làm đúng để không bị lan man. - Nhận thấy trong profile: ```php= $fullname = $connection->query(sprintf("SELECT fullname FROM users WHERE username='%s' limit 0,1", $_SESSION["username"])); if (gettype($fullname) !== "boolean") echo "<h2>Hello: ". $fullname->fetch_assoc()["fullname"] ."</h2>"; else { echo "<h2>Hello: Anonymous </h2>"; } ``` - Sau khi register thì username của mình sẽ được đưa vào query bên trong profile. Nhận thấy ở đây bị dính lỗi [Second order SQL](https://infosecwriteups.com/the-wrath-of-second-order-sql-injection-c9338a51c6d), mình tiến hành vào khai thác. - Nếu các bạn nghĩ đến dùng stack sql để inject vào username thì tạch rồi nhé. Câu query sẽ bị false và mặc dù thế cũng không đủ length và không thể bypass qua waf check. - Ý tưởng ở đây là lợi dụng reset coin để update lên 1337 coins, nhưng length 2 sẽ không đủ, và còn bị check. ```php= if ( isset($_GET["new_balance"]) and waf($_GET["new_balance"]) ) { if (strlen($_GET["new_balance"]) > 2) die("<strong>Only allow from 1 to 99</strong>"); else { $result = $connection->query(sprintf("UPDATE coins SET coin=%s WHERE uid=%d", $_GET["new_balance"], (int) $_SESSION['uid'])); if ($result) die("<strong>Your coin has been updated</strong>"); else die("<strong>0ops!!! Coin update has failed</strong>"); } } ``` - Hold up, input `new_balance` của mình đưa vào sẽ không bị convert về int, hehe vậy chỗ này sẽ rất mlem. - Ta sẽ đặt một variable = 1337 (Bằng cách lưu nó vào @a gì đấy, đây sẽ là một biến người dùng tự định nghĩa trong mysql, các bạn có thể search về nó để đọc thêm) - Dựa vào câu query có sẵn `SELECT fullname FROM users WHERE username='%s' limit 0,1", $_SESSION["username`, không thể dùng SET để lưu biến, vì như mình đã nói không thể stack query. - Vậy thì xem lại docs SELECT của nó một tý: ![](https://hackmd.io/_uploads/HJRfMkWdn.png) - Có một option sử dụng SELECT INTO để lưu giá trị (nice xừ), hình dung query sẽ là như sau: ``` SELECT '1337' FROM users WHERE username='cc' INTO @x ``` ![](https://hackmd.io/_uploads/BydxXkWu2.png) Như vậy thì mình sẽ lưu được giá trị 1337 vào @x, trong đó 1337 là fullname mà mình input, và username='cc' là một username đã tồn tại. Thì câu query đúng sẽ thực hiện và lưu giá trị. ### Exploit: - Đầu tiên như ý tưởng đã sẽ cần tạo một username sẵn trước đã. ![](https://hackmd.io/_uploads/H1Kim1-uh.png) - Tiếp đó sẽ tạo account thứ 2 có fullname=1337 và username là `db' INTO @x -- -` - Nhưng khoan, 1337 sẽ bị check, nếu sử dụng hex thì fullname sẽ là `X'31333337'` = 11 ký tự sẽ bị check, vì chỉ được input 10 ký tự ở fullname(tác giả tính cả rồi) - Vậy thì mình sẽ đặt là 1338 rồi mua một quí bửu, 1338-1 coin thì về đúng 1337 rồi :v ![](https://hackmd.io/_uploads/HJ6D41ZO2.png) - Sau khi register acc thứ 2 xong ta sẽ login và reset coin lại theo biến @x là được. ![](https://hackmd.io/_uploads/rJWTEk-Oh.png) F5 lại ![](https://hackmd.io/_uploads/rJipVkbO2.png) Hehe đi mua 1 thằng quý bửu thôi ![](https://hackmd.io/_uploads/SJi1Sk-Oh.png) - Coin = 1337 giờ mua flag ![](https://hackmd.io/_uploads/ByXzrJbun.png) ## Thanks for author Cảm ơn các tác giả với những thử thách trên, và trường KMA đã tạo ra một sân chơi CTF để bọn mình cơ cơ hội luyện tập. Cảm ơn ông bạn @Deku đã chia sẻ đề cho mình.