웹 서비스에 접속하면 화면 상단에는 MongoBoard라는 제목이 표시되며, 그 아래에는 no, Title, Author, publish_date로 구성된 게시판 형태의 목록이 나타납니다.
즉, 기본적인 게시판 구조의 관리 페이지 초기 화면임을 확인할 수 있습니다.

이번 화면은 글 작성 페이지로, 상단에는 Title, Author, Contents 입력란이 제공되며, 필요 시 Secret 옵션을 체크할 수도 있습니다.
하단에는 Save와 Cancel 버튼이 있어 글을 저장하거나 취소할 수 있습니다.


글을 등록하면 목록에 새로운 항목이 추가되며, no 부분에는 랜덤한 해시값이 자동 생성되는 것을 확인할 수 있습니다.

자세히 보기 위해서 코드를 살펴보겠습니다.
//app.js
var express = require('express');
var app = express();
var bodyParser = require('body-parser');
var mongoose = require('mongoose');
var path = require('path');
// Connect to MongoDB
var db = mongoose.connection;
db.on('error', console.error);
db.once('open', function(){
console.log("Connected to mongod server");
});
mongoose.connect('mongodb://localhost/mongoboard');
// model
var Board = require('./models/board');
// app Configure
app.use('/static', express.static(__dirname + '/public'));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.all('/*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT");
res.header("Access-Control-Allow-Headers", "Content-Type");
next();
});
// router
var router = require(__dirname + '/routes')(app, Board);
app.get('/', function(req, res) {
res.sendFile(path.join(__dirname + '/index.html'));
});
// run
var port = process.env.PORT || 8080;
var server = app.listen(port, function(){
console.log("Express server has started on port " + port)
});
//board.js
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var boardSchema = new Schema({
title: {type:String, required: true},
body: {type:String, required: true},
author: {type:String, required: true},
secret: {type:Boolean, default: false},
publish_date: { type: Date, default: Date.now }
}, {versionKey: false });
module.exports = mongoose.model('board', boardSchema);
//index.js
module.exports = function(app, MongoBoard){
app.get('/api/board', function(req,res){
MongoBoard.find(function(err, board){
if(err) return res.status(500).send({error: 'database failure'});
res.json(board.map(data => {
return {
_id: data.secret?null:data._id,
title: data.title,
author: data.author,
secret: data.secret,
publish_date: data.publish_date
}
}));
})
});
app.get('/api/board/:board_id', function(req, res){
MongoBoard.findOne({_id: req.params.board_id}, function(err, board){
if(err) return res.status(500).json({error: err});
if(!board) return res.status(404).json({error: 'board not found'});
res.json(board);
})
});
app.put('/api/board', function(req, res){
var board = new MongoBoard();
board.title = req.body.title;
board.author = req.body.author;
board.body = req.body.body;
board.secret = req.body.secret || false;
board.save(function(err){
if(err){
console.error(err);
res.json({result: false});
return;
}
res.json({result: true});
});
});
}
- 게시글 전체 조회 (GET /api/board)
- 데이터베이스에 저장된 모든 글을 불러옵니다.
- secret 옵션이 켜진 글은 _id가 null로 처리되어 외부에서 식별할 수 없게 반환됩니다.
- 특정 글 조회 (GET /api/board/:board_id)
- URL 파라미터로 전달된 _id 값을 기준으로 특정 글을 찾아 반환합니다.
- 존재하지 않으면 404, DB 에러 발생 시 500을 반환합니다.
- 글 작성 (PUT /api/board)
- 요청 바디에서 title, author, body, secret 값을 받아 새 글을 생성합니다.
- secret 값이 지정되지 않으면 기본적으로 false로 설정됩니다.
- 저장 성공 시 {result: true}, 실패 시 {result: false}를 응답합니다.
index.js 코드를 분석하여 실행해본 결과, 아래와 같이 /api/board 요청 시 JSON 형식으로 게시글 목록이 반환되는 것을 확인할 수 있었습니다.

_id 값을 확인해본 결과, 이는 MongoDB에서 문서를 구분하기 위해 자동 생성하는 ObjectId임을 알 수 있었습니다.
🔎 Object ID이란?
ObjectId는 MongoDB에서 각 문서를 식별하기 위해 사용되는 고유하고 불변한 식별자입니다. 관계형 데이터베이스의 AUTO_INCREMENT 값과 유사한 역할을 하지만, 훨씬 더 복잡하고 강력한 구조를 가지고 있습니다.
_id 필드에 값을 직접 지정하지 않는 경우, MongoDB는 자동으로 ObjectId를 생성하여 부여합니다. 이 값은 단순한 무작위 문자열이 아니라 12바이트(24자리 16진수)로 구성된 체계적인 구조를 가지며, 분산 환경에서도 충돌 없이 고유성을 보장합니다.
ObjectId의 구성 요소
ObjectId는 총 12바이트로 이루어져 있으며, 4개의 주요 구성 요소가 결합되어 생성됩니다. 16진수 문자열로 표현하면 총 24글자가 됩니다.
- 타임스탬프 (4바이트): ObjectId의 첫 4바이트(16진수 8자리)는 문서가 생성된 시점의 Unix Timestamp를 나타냅니다. 이 덕분에 ObjectId를 보면 문서가 언제 생성되었는지 알 수 있으며, 별도의 인덱스 없이도 시간 순으로 정렬할 수 있습니다.
- 머신 식별자 (3바이트): 다음 3바이트(16진수 6자리)는 문서를 생성한 서버의 고유 ID를 나타냅니다. 여러 서버에 MongoDB가 분산되어 있을 때, 어떤 서버에서 문서를 생성했는지 식별하는 데 사용됩니다.
- 프로세스 ID (2바이트): 다음 2바이트(16진수 4자리)는 해당 서버 내에서 문서를 생성한 프로세스의 ID입니다.
- 카운터 (3바이트): 마지막 3바이트(16진수 6자리)는 동일한 초 내에서 생성된 문서의 순서를 나타내는 카운터입니다. 이 카운터 덕분에 동일한 서버의 동일한 프로세스에서 1초에 여러 문서가 생성되어도 각각 고유한 ObjectId를 갖게 됩니다.
예시: 68bc2bd280d577a0a7d115cc라는 ObjectId를 살펴보겠습니다.
68bc2bd280d577a0a7d115cc
- 68bc2bd2 (앞 4바이트) → 생성 시점의 타임스탬프를 의미합니다.
- 80d577a0a7 (중간 5바이트) → 머신 및 프로세스 정보를 바탕으로 한 랜덤 값입니다.
- d115cc (마지막 3바이트) → 같은 초 안에서 증가하는 카운터 값입니다.
이처럼 ObjectId는 단순한 문자열이 아니라 시간 + 랜덤 값 + 카운터로 체계적으로 나뉘어 있음을 확인할 수 있습니다.
앞의 68bc2bd2 값을 16진수에서 10진수로 변환해 보면 1757162450이 나오며, 이는 유닉스 타임스탬프 값입니다. 이를 다시 변환기에 적용하면 2025-09-06 12:40:50이라는 날짜가 계산되고, 실제 게시판에 표시된 시간과 일치함을 확인할 수 있습니다.



그래서 저는 이 과정을 응용하여 FLAG 문서의 _id 값을 직접 추출해보려고 합니다. 앞에서 진행했던 변환 과정을 이번에는 역순으로 적용하여 _id를 얻어내는 방식을 시도하겠습니다.
2025-09-06T12:40:52.398Z라는 값을 타임스탬프 변환기에 넣어보면, 1757162452가 계산되는 것을 확인할 수 있습니다.

10진수 1757162452를 16진수로 변환한 결과 68BC2BD4라는 값이 나왔습니다. 이 값은 ObjectId의 앞 4바이트에 해당하는 것이라는 것을 알 수 있습니다.

중간 부분의 프로세스 ID 영역은 모든 ObjectId에서 동일하게 나타나는 것을 확인할 수 있습니다.
마지막 카운터 영역(6바이트 부분)은 게시판 데이터를 비교해 보면 뒤쪽 자리 수가 1씩 증가하는 형태를 띠고 있었으며, 이를 통해 최종적으로 FLAG 문서의 _id 값을 도출할 수 있었습니다.
따라서 최종적으로 도출된 FLAG 문서의 _id 값은 68BC2BD480d577a0a7d115ce임을 확인할 수 있습니다.
_id : 68BC2BD480d577a0a7d115ce
최종적으로 구한 _id를 API 요청에 직접 입력하여 호출한 결과, FLAG 문서를 조회할 수 있었고 그 안에서 플래그를 획득할 수 있었습니다.

'Dreamhack > 웹해킹' 카테고리의 다른 글
| [LEVEL 1] csrf-2 (1) | 2025.09.08 |
|---|---|
| [LEVEL 1] xss-2 (0) | 2025.09.07 |
| [LEVEL 1] php-1 (0) | 2025.09.06 |
| [LEVEL 1] simple-ssti (0) | 2025.09.04 |
| [LEVEL 1] image-storage (0) | 2025.09.04 |