🌑

CVE-2021-3129

Laravel Debug mode에서 발견된 CVE-2021-3129 취약점을 분석해보겠습니다. Laravel은 자유/오픈 소스 PHP 웹 프레임워크 중 하나라고 합니다. 해당 취약점은 보안 연구원이 Laravel을 기반으로 한 웹 사이트에서 취약점 진단을 하다가 디버그 모드에서 발견했다고 합니다. 해당 사이트는 전체적으로 안전했지만 웹 프레임워크 취약점을 이용해서 RCE를 트리거 했습니다.

CVE ID Version CVSS
CVE-2021-3129 laravel <= 8.4.2 and Ignition <= 2.5.1 9.8


CVE-2021-3129는 Laravel의 <= V8.4.2 DEBUG MODE에서 모두 발생한다고 합니다. 2020년 11월 16일에 POC를 전달해주었고, 바로 다음 날 패치 버전을 발표했다고 합니다.


CVE-2021-3129

POST /_ignition/execute-solution HTTP/1.1
Host: 141.164.52.207:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://141.164.52.207:8888/_ignition/execute-solution
Content-Type: application/json
Content-Length: 320
Connection: close
Upgrade-Insecure-Requests: 1

{
	"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
	"parameters":{
		"variableName": "username",
		"viewFile": "/work/pentest/laravel/laravel/resources/views/hello.blade.php"
	}
}
1. Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution
2. /work/pentest/laravel/laravel/resources/views/hello.blade.php

위 요청은 username이라는 변수로 템플릿을 생성해주는 로직의 요청 헤더 값이고, solution의 값은 1번, viewFile의 값은 2번인 것을 볼 수 있습니다. 하지만 solution, viewFile의 값은 임의의 값으로 수정이 가능합니다.

<?php

namespace Facade\Ignition\Http\Controllers;

use Facade\Ignition\Http\Requests\ExecuteSolutionRequest;
use Facade\IgnitionContracts\SolutionProviderRepository;
use Illuminate\Foundation\Validation\ValidatesRequests;

class ExecuteSolutionController
{
    use ValidatesRequests;

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

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

        return response('');
    }
}

컨트롤러를 확인해봅시다. ExecuteSolutionController 클래스를 확인해 보면 run() 메서드를 실행하고, 인자 값으로는 parameters를 넘겨주는 것을 볼 수 있습니다. run() 메서드는 MakeViewVariableOptionalSolution::run()에 정의되어 있습니다.

<?php

namespace Facade\Ignition\Solutions;

use Facade\IgnitionContracts\RunnableSolution;
use Illuminate\Support\Facades\Blade;

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

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

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

위 요청 헤더에서 sollution이 가르키는 MakeViewVariableOptionalSolution 클래스를 보면 run() 메서드에서 makeOptional() 메서드를 호출하는 것을 볼 수 있고, makeOptional() 메서드에 값으로 $parameters를 그대로 넘겨주고 있는 것을 볼 수 있습니다.

(Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution)
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;
}

makeOptional() 메서드를 보면 file_get_contents() 메서드를 호출하는 데 이때 입력값인 viewFile(임의의 값을 넣어줄 수 있음)를 넘겨주고 있는 것을 볼 수 있습니다. 그리고 $originalContents에서 '$'.$parameters['variableName']'$'.$parameters['variableName']." ?? ''"로 replace 시키고, 2개의 값을 이용해서 토큰 2개를 생성하는 것을 볼 수 있습니다. 그리고 생성된 두개의 토큰이 다르 false를 반환하고, 같은 $newContents를 반환하는 것을 볼 수 있습니다.

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

$newContents가 리턴이 되었다면 $newContents의 값을 file_put_contents() 메서드를 이용해서 $parameters[‘viewFile’]로 값을 넣어 파일을 생성하고 . 감이 오셨나요? 여기서 file_get_contents(), file_put_contents() 메서드의 인자값에 대한 검증이 존재하지 않아 우리가 원하는 값을 마음대로 삽입할 수 있습니다.

연구원은 이를 이용해서 PHP Wrapper을 이용해 log 파일을 체이닝 시켜, 마지막에 phar Wrapper를 이용해서 역직렬화 RCE를 트리거 하였습니다. log 파일을 체이닝 할 때는 base64, utf-8, utf-16 등을 이용해서 체이닝을 해준 것으로 보입니다.


CVE-2021-3129 Patch

// /ignition-2.5.2/ignition-2.5.2/src/Solutions/MakeViewVariableOptionalSolution.php
(생략)

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

    protected function isSafePath(string $path): bool
    {
        if (! Str::startsWith($path, ['/', './'])) {
            return false;
        }
        if (! Str::endsWith($path, '.blade.php')) {
            return false;
        }

        return true;
    }

    public function makeOptional(array $parameters = [])
    {
        if (! $this->isSafePath($parameters['viewFile'])) {
            return false;
        }

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

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

CVE-2021-3129 Patch 노트가 따로 존재하지 않아 패치 버전인 Ignition 2.5.2의 코드를 직접 확인해보았습니다. makeOptional() 메서드를 보면 기존에는 존재하지 않던 $parameters['viewFile']의 값을 isSafePath() 메서드를 이용해서 검증하고 있는 것을 볼 수 있습니다.

protected function isSafePath(string $path): bool
{
    if (! Str::startsWith($path, ['/', './'])) {
        return false;
    }
    if (! Str::endsWith($path, '.blade.php')) {
        return false;
    }

    return true;
}
work/pentest/laravel/laravel/resources/views/hello.blade.php

isSafePath() 메서드를 보면 ‘/‘, ‘./‘로 시작하지 않거나, 마지막에 .blade.php가 아니면 false를 리턴하는 것을 볼 수 있습니다. 위 2개의 검증 로직이 추가되므로 항상 viewFile의 값이 위와 같을 때 정상 작동하게 되었고, 개발자가 의도하지 않은 값이 viewFile로 들어오게 되면 아무 일도 일어나지 않게 되며 CVE-2021-3129 취약점을 패치하였습니다.

매번 느끼지만 PHP에서는 끊임 없이 취약점이 나오고 있네요. 앞으로도 PHP를 사용하고 있는 웹 프레임워크나 여러 프로그램에서 취약점이 더 수두룩 나올 거 같습니다.


, — Jan 25, 2021