664 words
3 minutes
Whitehat Contest Final Write-Up

TL;DR#

화햇콘 본선에 갔다왔는데 웹이 생각보다 잘 안 풀렸네요..

Lemo (Web)#

파일 수정과 게시판 기능이 있는 서비스였고, 백엔드는 Deno + Fresh 프레임워크, 프록시는 NGINX 구성이었다.
Flag는 /flag 에 존재했다.


1. 취약점 분석#

1-1. SQL Injection#

누가봐도 SQLI가 터질거 같이 생긴 함수가 회원가입 기능에 존재한다.

export function createUser(username: string, password: string, role: Role = Role.USER) {
  const result = db.exec(`INSERT INTO users (username, password, role) VALUES ('${username}', '${password}', ${role})`);
  return result;
}

이를 통해 관리자 계정을 생성할 수 있다.

1-2. Nginx Filtering#

Nginx에서 url query를 sanitizing 하며 ip 파라미터가 존재하면 임의로 바꿔버린다.

function fix(r) {
    var out = [];
    var args = r.args;

    for (var k in args) {
        if (!Object.prototype.hasOwnProperty.call(args, k))
            continue;

        if (k === 'ip')
            continue;

        var v = args[k];
        if (Array.isArray(v)) {
            for (var i = 0; i < v.length; i++) {
                out.push(k + '=' + encodeURIComponent(v[i]));
            }
        } else {
            out.push(k + '=' + encodeURIComponent(v));
        }
    }

    var real_ip = r.variables.remote_addr || "";
    out.push('ip=' + real_ip);
    return out.join('&');
}

export default { fix };

1-3. Middleware#

import type { FreshContext } from '$fresh/server.ts';

export async function handler(req: Request, ctx: FreshContext) {
  ctx.state = ctx.state ?? {};

  const ip = ctx.state.ip;
  const NODE_ENV = Deno.env.get('NODE_ENV') ?? 'production';

  console.log(ip, NODE_ENV);
  if (ip !== '127.0.0.1' || NODE_ENV !== 'development')
    return new Response('403 Forbidden', { status: 403 });

  return await ctx.next();
}

관리자 페이지에서 임의의 파일을 쓰기 위해서는 ip가 127.0.0.1이어야하며, NODE_ENV 값이 development여야 한다.


Exploit#

SQLI로 admin 권한을 얻고 routes/에 존재하는 파일들을 읽어올 수 있다. 그러나 Deno는 sandboxing 되어있기에 routes를 벗어나 바로 flag를 읽을 수는 없다.

async function parseQuery(req: Request) {
  const url = new URL(req.url);

  const rawQS = url.search?.length ? url.search.slice(1) : '';

  const parsed = qs.parse(rawQS);

  console.log(parsed);

  return parsed;
}

export async function handler(req: Request, ctx: FreshContext) {
  ctx.state = ctx.state ?? {};
  ctx.state.query = await parseQuery(req);
  if (ctx.state.query.ip) {
    if (typeof ctx.state.query.ip !== 'string') {
      return new Response('400 Bad Request', { status: 400 });
    }
    ctx.state.ip = ctx.state.query.ip;
  } else {
    ctx.state.ip = '127.0.0.1';
  }

  return await ctx.next();
}

전역적으로 적용된 미들웨어를 보면 수상하게 qs.parse를 사용한다. qs모듈에는 maxQueryLimit = 1000이 기본적으로 설정되어 있어 쿼리를 1000개 넘게 주면 뒤에가 잘리게 된다. 이를 통해 nginx단에서 붙여주는 ip를 날리고 127.0.0.1을 집어넣을 수 있다.

NODE_ENV 우회#

이걸 생각을 못해서 못풀었다. SQLI를 admin만 따고 끝내는게 아니라,

ATTACH DATABASE '.env' AS env;
CREATE TABLE env.t(t TEXT);
INSERT INTO env.t VALUES('NODE_ENV=development' || CAST(X'0A' AS TEXT));

이런식으로 .env를 생성해 NODE_ENV를 덮을 수 있다.

이러면 writeTextFile()을 사용할 수 있게된다.

FFI#

"preview": "deno run --allow-net --allow-ffi --allow-env --allow-read=. --allow-write=routes/ --watch=routes/ main.ts",

allow-ffi가 적용되어 있어 ffi로 플래그를 읽을 수 있었다.

Whitehat Contest Final Write-Up
https://itznullbyte.github.io/posts/2025_withcon_final/
Author
nullbyte
Published at
2025-11-22
License
CC BY-NC-SA 4.0