Codegate 2025 quals - Cha's point writeup
markdown을 이용한 ppt 제작 기능을 지원하는 서비스다.
주어진 파일의 app.js에서 Global filter, routing을 확인할 수 있다.
app.use((req, res, next) => {
if (req.session.userid || req.path.startsWith("/auth/")) return next();
return res.redirect("/auth/login");
});
app.use((req, res, next) => {
if (req.method === "POST") {
for (const key in req.body) {
if (req.body[key] && typeof req.body[key] !== "string") {
return res.status(401).send("Invalid Data");
}
if (FILTER.exec(req.body[key])) { // FILTER : /\'|`|\.\.|\.\/|#|%|&|\?|<|>|\(|\)|script|onerror|src|\n/i;
req.body[key] = encode(req.body[key], { mode: "extensive" });
}
}
}
next();
});
app.use("/auth", auth);
app.use(["/view", "/_assets", "/css/highlight"], view);
app.use(edit);
POST data에 대해 정규식에 해당되는 값이 포함되어있으면 html-entities 라이브러리를 이용하여 html entity 형태로 인코딩하는 필터가 존재하며
그 외엔 /auth
, /view
, /_assets
, /css/highlight
, /edit/*
등 요청에 대해 각각 routes/auth.js
, routes/view.js
, routes/edit.js
로 매핑하고 있다.
router.get("/render", async (req, res) => {
try {
const userId = req.session.userid;
const configPath = path.join(UPLOAD_DIR, userId, "config", "config.md");
if (!fs.existsSync(configPath)) {
return res.redirect("/");
}
const slidePath = path.join(UPLOAD_DIR, userId, "slide", "default.md");
const useTemplate = !fs.existsSync(slidePath);
const configData = fs.readFileSync(configPath, "utf8").toString();
let data = configData;
if (useTemplate) {
data += default_template;
} else {
data += fs.readFileSync(slidePath, "utf8").toString();
}
const { render } = await getRevealMd();
const rendered = await render(data);
return res.send(rendered);
} catch {
return res.status(500).send("Error");
}
})
위의 함수는 routes/view.js
에 정의된 함수로 /view/render
로 접근할 시 실행된다.
해당 함수에선 session에 정의된 userid 값을 값을 바탕으로 config.md
, default.md
파일의 데이터를 읽어온 다음 revealMd
라이브러리의 render 함수를 이용하여 해당 데이터를 변환, 사용자에게 전달하고 있다.
해당 문제는 RCE를 획득하는 것이 목표이기 때문에 revealMD
라이브러리에서 취약점을 찾아 공격해야한다.
export const render = async (fullMarkdown, extraOptions = {}) => {
const { yamlOptions, markdown: contentOnlyMarkdown } = parseYamlFrontMatter(fullMarkdown);
const options = Object.assign(getSlideOptions(yamlOptions), extraOptions);
const { title } = options;
const themeUrl = getThemeUrl(options.theme, options.assetsDir, options.base);
const highlightThemeUrl = getHighlightThemeUrl(options.highlightTheme);
const scriptPaths = getScriptPaths(options.scripts, options.assetsDir, options.base);
const cssPaths = getCssPaths(options.css, options.assetsDir, options.base);
const revealOptions = Object.assign({}, getRevealOptions(options.revealOptions), yamlOptions.revealOptions);
const slidifyOptions = _.pick(options, Object.keys(slidifyAttributeNames));
let slidifyAttributes = [];
for (const [key, value] of Object.entries(slidifyOptions)) {
const escaped_value = value.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
slidifyAttributes.push(`${slidifyAttributeNames[key]}="${escaped_value}"`);
}
const preprocessorFn = await getPreprocessor(options.preprocessor);
const processedMarkdown = await preprocessorFn(contentOnlyMarkdown, options);
const revealOptionsStr = JSON.stringify(revealOptions);
const mermaidOptionsStr = options.mermaid === false ? undefined : JSON.stringify(options.mermaid);
const template = await getTemplate(options.template);
const context = Object.assign(options, {
title,
slidifyAttributes: slidifyAttributes.join(' '),
markdown: processedMarkdown,
themeUrl,
highlightThemeUrl,
scriptPaths,
cssPaths,
revealOptionsStr,
mermaidOptionsStr,
watch: getWatch()
});
const markup = Mustache.render(template, context);
return markup;
};
위의 코드는 reveal-md 라이브러리에 정의된 render 함수로 인자로 markdown 데이터, option 데이터를 받아 처리한 다음, Mustache template을 이용하여 데이터를 변환한다.
이때 parseYamlFrontMatter
함수를 이용하여 markdown 데이터에서 yaml 관련 옵션을 추출하는 것을 알 수 있는데 해당 함수는 다음과 같다.
export const parseYamlFrontMatter = content => {
const document = yamlFrontMatter.loadFront(content.replace(/^\uFEFF/, ''));
return {
yamlOptions: _.omit(document, '__content'),
markdown: document.__content || content
};
};
parseYamlFrontMatter
함수는 데이터를 다시 yamlFrontMatter
라이브러리의 loadFront 함수에 전달하여 yaml 데이터를 파싱한다.
reveal-md에서 사용하는 js-yaml-fromt-matter
라이브러리의 버전은 4.1.1로 정의된 loadFront 함수는 코드는 다음과 같다.
var jsYaml = require('js-yaml');
function parse(text, options, loadSafe) {
let contentKeyName = options && typeof options === 'string'
? options
: options && options.contentKeyName
? options.contentKeyName
: '__content';
let passThroughOptions = options && typeof options === 'object'
? options
: undefined;
let re = /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?([\w\W]*)*/
, results = re.exec(text)
, conf = {}
, yamlOrJson;
if ((yamlOrJson = results[2])) {
if (yamlOrJson.charAt(0) === '{') {
conf = JSON.parse(yamlOrJson);
} else {
if(loadSafe) {
conf = jsYaml.safeLoad(yamlOrJson, passThroughOptions);
} else {
conf = jsYaml.load(yamlOrJson, passThroughOptions);
}
}
}
conf[contentKeyName] = results[3] || '';
return conf;
};
export function loadFront (content, options) {
return parse(content, options, false);
};
export function safeLoadFront (content, options) {
return parse(content, options, true)
}
loadFront 함수는 loadSafe
값을 false로 하여 parse 함수를 호출한다.
---
test
---
이때 위의 마크다운 형식의 데이터에서 test 자리에 들어가는 값을 추출, 이후 jsYaml.load 함수로 전달하게 된다.
이 jsYaml.load 함수는 js-yaml:3.14.1
에 정의된 함수로 최신버전과 달리 js-yaml3
에선 Yaml 형식 데이터를 이용한 함수 생성이 가능하여 이를 기반으로 임의 코드 실행을 획득, 플래그를 읽으면 된다.
그러나 3.14.1 이전 버전에선 함수명을 toString으로 하여 코드 실행을 얻을 수 있었지만, 그 이후부턴 패치되어 불가능하다.
그래서 우린 다른 방식으로 함수를 실행해야하며 이는 reveal-md 라이브러리의 render 함수 내 코드를 활용하여 달성할 수 있다.
export const render = async (fullMarkdown, extraOptions = {}) => {
const { yamlOptions, markdown: contentOnlyMarkdown } = parseYamlFrontMatter(fullMarkdown);
const options = Object.assign(getSlideOptions(yamlOptions), extraOptions);
...
const revealOptionsStr = JSON.stringify(revealOptions);
...
};
이미 우린 yaml 파싱 기능을 활용하여 임의의 함수를 생성하는 것이 가능한데, JSON.stringify 함수로 전달된 객체에 toJSON이라는 key가 존재하며 해당 key의 값이 함수일 경우
이를 실행해주는 것을 이용하면 toString을 덮지 않고도 코드 실행을 획득할 수 있다. (이외에도 Object.entries 사용가능)
위의 취약점을 활용하기 위해선 config.md의 데이터를 조작할 수 있어야한다. 이는 edit.js에 정의된 기능들을 활용하면 가능하다.
router.post("/edit/add/config", (req, res) => {
const { title, theme, highlightTheme } = req.body;
if (typeof title !== "string" || typeof theme !== "string" || typeof highlightTheme !== "string") {
return res.json({ status: "error" });
}
return res.json({ status: set_config(req.session.userid, title, theme, highlightTheme) ? "success" : "error" });
});
/edit/add/config
로 전달된 post 요청에 대해, title, theme, highlightTheme 파라미터를 받아 set_config 함수로 전달한다. 이 함수는 utils/utils.js
에 정의되어있다.
const TEMPLATE = `---
title: "{TITLE}"
theme: {THEME}
highlightTheme: {HIGHLIGHT}
---`;
const encode = (text) => {
try {
return encodeURI(text.replace(/"/g, ""));
} catch {
return text;
}
};
const set_config = (uuid, title, theme, highlightTheme) => {
const userDir = path.join(UPLOAD_DIR, uuid);
const requiredFolders = ["config", "slide"];
if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir, { recursive: true });
}
requiredFolders.forEach((folder) => {
const folderPath = path.join(userDir, folder);
if (!fs.existsSync(folderPath)) {
fs.mkdirSync(folderPath);
}
});
const configPath = path.join(userDir, "config", "config.md");
try {
const content = TEMPLATE.replace("{TITLE}", encode(title))
.replace("{THEME}", encode(theme))
.replace("{HIGHLIGHT}", encode(highlightTheme));
fs.writeFileSync(configPath, content);
} catch {}
return fs.existsSync(configPath);
};
전달받은 데이터를 encodeURI 함수를 이용하여 url 인코딩 후 config.md에 작성한다.
취약점을 공격하기 위해선 임의의 속성을 추가해야하기 때문에 개행문자 및 특수문자를 사용해야하지만 encode 함수로 인해 불가능한 상황이다.
그렇기에 encodeURI에서 에러를 발생시켜 인코딩되지 않은 데이터가 그대로 반환되게 하여 이를 우회하면 된다.
또한 app.js에 정의된 필터로 인해 \n
을 사용하는 것이 불가능하지만 js-yaml의 코드를 분석해보면 readLineBreak 0x0d 또한 0x0a와 동일하게 사용 가능하기 때문에 이를 활용하면 된다.
이후 위의 분석을 토대로 exploit을 작성, 설정파일에 데이터를 삽입한 뒤 /view/render
에 접속하면 쉘을 획득할 수 있다.
플래그
codegate2025{97e237e450c9b45b57bb2a1030ff6ec4d186077c178de0cb451633638f4e7a37}