KMA CTF 2023 - Writeups

KMA CTF 2023 - Writeups

·

11 min read

1. Vào đây!

Analysis

Bài này cho ta src code như sau

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()))

Nhìn qua src thì biết được bài này chỉ cần bypass câu điều kiện if (req.body.user === 'admin') và khiến câu query trả về true với username là admin

Exploit

Để làm được điều đó ta cần biết mặc định fastify có thể xử lý JSON trong request, để bypass req.body.user === 'admin' đơn giản chỉ cần truyền para user là một mảng

Tiếp theo ta cần câu truy vấn select *, bio as flag from users where username = ? and password = ? limit 1 trả về True

Trong thư viện mysql2 mà chall sử dụng, nếu truyền vào một object thì mysql2 sẽ tự động chuyển thành nội dung câu truy vấn

Ví dụ

dbPromisePool.query(`select * from demo where id = ?`, req.body.id))
// Input : {"id": {"username":"endy"}}
// Câu query sẽ thành: select * from demo where id = username = "endy" 
//(Lưu ý username được xem như một column nên để câu query thực thi được thì đây phải là một column tồn tại trong table)
// Lúc này id = username sẽ trả về  0, tiếp tục so sánh 0 = "endy", mysql sẽ ép kiểu và trả về True

Payload cuối cùng sẽ là

{
"user"  : ["admin"],
"pass":{
    "username":"endyyy"}
    }
}

2. Jo`in Le'm

Analysis

Đầu tiên bài này cho một đoạn PHP code nhưng đã được Obfuscated

<?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"]);

Dùng tool deobfuscate online ta thu được

<?php
gotoTwWdv;
kP1Xc:
    if ($url["scheme"] !== "http" && $url["scheme"] !== "https") {
        die;
    }
    gotodD_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 = "User-Agent: " . ($_GET["ua"] ? ? "vinhjaxt/1.0 (M vina Zai Zc V sn 69.0.1 nhanh tuyt c mo chn ng nm chu, tng thch op 5 CHM 0, nhanh ng bng ha dim sn, vi tc  nh sng b nc chn)");
                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;
            }
            gotozUkQD;
            SZSj0 : _ : gotoWuY9_;
            RzWUE:
                $ch = curl_init();
                gotoYlbNQ;
                dD_At:
                    if ($url["host"] === "127.0.0.1" || gethostbyname($url["host"]) === "127.0.0.1") {
                        die;
                    }
                    gotoRzWUE;
                    TwWdv:
                        show_source(__FILE__);
                        gotoB10vf;
                        zUkQD:
                            $url = parse_url($_GET["url"]);
                            gotokP1Xc;
                            YlbNQ:
                                if (curl_escape($ch, $_GET["url"]) === urlencode($_GET["url"])) {
                                    die;
                                }
                                gotoSZSj0;
                                WuY9_:
                                    echo curl($_GET["url"]); ?>

Đoạn code còn khá lộn xộn, mình tiếp tục format lại bằng cơm

<?php
// goto  TwWdv;
show_source(__FILE__);

    function curl($url) {
            $ch = curl_init($url);
            echo("Curling");
            _:
                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 Zc V sn 69.0.1 nhanh tuyt c mo chn ng nm chu, tng thch op 5 CHM 0, nhanh ng bng ha dim sn, vi tc  nh sng b nc chn)");
                var_dump($ua);
                curl_setopt($ch, CURLOPT_HTTPHEADER, array($ua));
                curl_setopt($ch, CURLOPT_URL, $url);
                $d = curl_exec($ch);
                var_dump($d);
                $redirect_url = curl_getinfo($ch, CURLINFO_REDIRECT_URL);
                echo($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;
    }
    $ch = curl_init();
    $url = parse_url($_GET["url"]);
    if ($url["host"] === "127.0.0.1" || gethostbyname($url["host"]) === "127.0.0.1") {
        echo("localhost block");
        die;
    }
    if ($url["scheme"] !== "http" && $url["scheme"] !== "https") {
        echo("schema block");
        die;
    }
    echo(curl_escape($ch, $_GET["url"]));
    echo("<br>");
    echo(urlencode($_GET["url"]));
    echo("<br>");

    if (curl_escape($ch, $_GET["url"]) === urlencode($_GET["url"])) {
        echo("escaped block");
        die;
    }

    echo curl($_GET["url"]); 
?>

Okie ta thấy ứng dụng nhận vào param url và check xem nó có hợp lệ không, nếu có sẽ gửi curl request đến url . Ngoài ra ta còn có thế thay đổi User-Agent của curl request thông qua param us , nếu không thì nó sẽ lấy giá trị mặc định

Ta thấy đầu tiên nó sẽ check host có phải là 127.0.0.1 không nếu không nó kiểm tra sheme có phải là http hoặc https . Hai bước check này không quan trọng lắm. Điểm cần lưu ý là bước kiểm tra thứ 3 là so curl_escape($ch, $_GET["url"]) === urlencode($_GET["url"])

Muốn gửi được curl request thì ta phải bypass được 2 hàm này

Exploit

Mình dùng burp instruder để fuzz từng ký tự, xem có ký tự nào mà 2 hàm cho ra 2 kết quả khác nhau không, kết quả là ở ký tự + 2 hàm sẽ cho kết quả khác nhau

Okie bây giờ chỉ cần thêm + vào url, tuy nhiên để không làm hỏng đường dẫn mình sẽ thêm dấu + vào phần credientals của đường dẫn, ví dụ

http://endy:en+dy@tpytl7b1.requestrepo.com

Request repo của mình đã nhận được request

Tiếp theo ta để ý tại hàm curl của ứng dụng, nếu respone status code nằm trong khoảng 300-400 và $redirect_url được set thì curl sẽ tiếp tục gửi request đến $redirect_url

Để khiến curl redirect ta chỉ cẩn chỉnh status code và thêm location header vào response của request repo

Kết quả

Tuy nhiên ta không biết được flag nằm ở đâu, mình cũng khá bí phần này cho đến khi có hint như thế này

Biết được có liên quan đến việc sử dụng volume để mount các tệp, mình thắc mắc là trong linux thì liệu có file nào chứa thông tin về các volume được mount không, sau khi search thì có file /proc/mounts sẽ chứa các thông tin này

Nội dung file /proc/mounts

Vậy là flag nằm ở /home/siuvip_saoanhbatduocem/etc/passwd

3. Flag Holder

Analysis

Ta có một ứng dụng như thế này

Ứng dụng cho phép ta nhập vào template và replace {variable} theo giá trị ta truyền vào

Source challage

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)

Ban đầu mình cứ nghĩ là SSTI tuy nhiên chỉ cần tìm cách bypass để template chứa ký tự {FLAG} từ đó sẽ lấy được flag

Ta thấy ứng dụng đã kiểm tra độ dài của parameter tại if len(template) > MAX_LENGTH or len(variable) > MAX_LENGTH tuy nhiên khi vào hàm waf() nó lại tiếp tục check if word in string.lower()[:MAX_LENGTH]

Vậy thì liệu rằng có ký tự nào khiến len(template) khác với len(template.lower()) hay không, khi đó ta có thể bypass được string.lower()[:MAX_LENGTH]

Exploit

Dùng đoạn script đơn giản sau để tìm ký tự đó

for i in range(1000):
    if(len(chr(i).lower()) > 1):
        print(i)
        break

Ta tìm được ký tự đó là İ , truyền İ vào template đến khi lấy được flag (nhớ URL encode)

4. Ninja Shop

Analysis

Bài này cho ta một trang web có thể đăng ký, đăng nhập, update coins và mua flag

Trọng tâm của bài này là làm sao update được coins bằng 1337 để mua flag, tuy nhiên tối đa ta chỉ có thể update được 99 coins

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>");
        }
    }

Ban đầu mình SQLi ở login và có thể kiếm soát được uid cũng như username, tuy nhiên tại chức năng login thì không thể nào khai thác để lấy flag được vì username không thể quá 26 ký tự và uid thì bị ép sang kiểu int khi đưa vào câu query

Mình dùng Instruder để fuzz các ký tự có thể in được trong ASCII để tìm xem một ký tự cho ra kết quả khác thường, cuối cùng mình tìm được một ký tự có thể giúp mình hack tiền

Exploit

Unintended

Ký tự mình tìm được là ~ , đây là toán tử bitwise trong mySQL cho phép đổi ngược giá trị từng bit, ví dụ

1 = b'00000000000000000000000000000001'
~1 = b'11111111111111111111111111111110' = 18446744073709551614

Khi ta truyền vào new_balance~1 thì ta sẽ update được số coin tối đa mà trường coin có thể chứa

Tiếp theo chỉ cần spam request mua liên tục để trừ coin xuống còn 1337 để lấy flag

Intended

Vậy thì còn cách intended thì sao, sau khi hỏi hint thì mình được gợi ý là sẽ SQLi để update coin

Để SQLi được thì trước tiên phải vượt qua được waf của chall

function waf($input) {
    // Prevent sqli -.-
    $blacklist = join("|", ["sleep", "benchmark", "order", "limit", "exp", "extract", "xml", "floor", "rand", "count", "or" ,"and", ">", "<", "\|", "&","\(", "\)", "\\\\" ,"1337", "0x539"]);
    if (preg_match("/${blacklist}/si", $input)) die("<strong>Stop! No cheat =))) </strong>");
    return TRUE;
}

Mọi untrusted data (trừ new_balance) đều được lọc qua hàm waf này, có thể thấy ta chỉ có thể dùng SQLi union based để exploit, tuy nhiên vấn đề không dừng lại ở đó

Ban đầu mình thấy tác giả dùng %s trong sprintf đối với giá trị của coin thay vì dùng %d

$result = $connection->query(sprintf("UPDATE coins SET coin=%s WHERE uid=%d", $_GET["new_balance"], (int) $_SESSION['uid']));

Nên mình nghỉ chỉ cần kiểm soát được %s bằng cách bypass strlen($_GET["new_balance"]) > 2 là win, tuy nhiên không có cách nào làm được điều này

Tiếp tục quay trở lại nhìn code mình phát hiện tại profile.php thì câu query sau dùng dấu ' cho chuỗi thay vì dùng " như các nơi khác của chương trình

// Dòng 10 profile.php
$fullname = $connection->query(sprintf("SELECT fullname FROM users WHERE username='%s' limit 0,1", $_SESSION["username"]));

// Những nơi khác
// login.php
$result = $connection->query(sprintf('SELECT * FROM users WHERE username="%s" AND password="%s" limit 0,1', $_POST["username"], md5($_POST["password"])))->fetch_assoc();
// resiter.php
$connection->query(sprintf('SELECT * FROM users WHERE username="%s" and password="%s"', $_POST["username"], md5($_POST["password"])))->fetch_assoc()["uid"]
....

Điều này gợi lên mình một ý tưởng đó chính là Second Order SQLi tại profile.php thông qua username, bởi vì tại đây thì chỉ select 1 column, do đó payload của ta có thể có nhiều ký tự hơn (vì username max chỉ được 26 ký tự 😢), thêm vào đó khi inject tại đây thì sẽ không làm ảnh hưởng đến logic của việc login.

Tìm được nơi SQLi hợp lý rồi thì làm sao để set coin là 1338 (vì 1337 nằm trong blacklist), mình thấy vì câu query này nằm trong cùng 1 scope với câu query update coin, vậy thì sẽ ra sao nếu ta second order để khai báo một biến có giá trị là 1338 trong mySQL, sau đó tại câu query update ta gọi đến biến đó để set giá trị ?

Okie bắt tay vào thực hành, việc đầu tiên là tạo một tài khoản với username là 'union select @a:=1338#

Tiếp theo đăng nhập với username đó

Khi đăng nhập thành công và truy cập vào profile.php, second order sẽ được trigger tạo ra biến @a với gái trị là 1338

Bây giờ chỉ cần update coin với new_balance=@a

Mua một món bất kỳ để tiền giảm xuống 1337, và việc cuối cùng là get falg