BinaryHub 개발

BinaryHub 개발기 ⑩ 클라가 모든 걸 박는 사이트의 방어선 — StudyAce 어뷰징 대응

4
BinaryHub 개발기 ⑩ 클라가 모든 걸 박는 사이트의 방어선 — StudyAce 어뷰징 대응

BinaryHub 개발기. 처음부터 다시 짤 수 없는 사이트는, 안에서 바깥으로 천천히 단단해진다.

문제는 갑자기 보였다

studyace 모든게 클라이언트 사이드에서 이루어지니까 js 넣어서 악용하는 사람이 있어. 이거 막아줘.

평소처럼 StudyAce 화면을 들여다보다가 의심스러운 패턴이 보였다. 점수, 칭호, 유물 — 게이미피케이션 데이터가 어딘가 자연스럽지 않게 박혀 있었다. 누군가 콘솔에서 localStorage.setItem(...) + sync 를 굴리면 거의 모든 게 통과되는 구조였던 것이다.

이건 사실 처음부터 알고 있던 문제다. StudyAce 는 시험 기간에 친구들과 같이 쓰던 학습 사이트로 출발했고, 모든 로직이 클라이언트 중심으로 짜여 있었다. 재미있게 만들기 위한 게이미피케이션 — 점수·티어·칭호·가챠·유물 — 이 다 클라에서 계산되고 서버는 그저 받아 적었다. 친구 다섯 명이면 신경 쓸 일이 없었지만, 사용자가 늘면서 누군가가 그 구조의 빈틈을 봤다.

두 단계로 가기로 했다

처음부터 다시 짜는 건 불가능했다. 학습 흐름·인벤토리·칭호·테마가 다 얽혀 있고, 이미 사용자 데이터가 있다.

단계 작업 목적
A. 즉시 — sanity check sync/leaderboard POST 의 delta·cap·화이트리스트 obvious cheat 70~80% 차단
B. 중기 — 핵심 endpoint 서버화 가챠 RNG·인벤토리·차감 서버로 가챠 어뷰징 0
C. 장기 — 전체 SoT 학습 흐름·채점도 서버 완전 차단

A·B 까지 가는 게 이번 라운드의 결정. C 는 따로 — 시험 끝나고.

A단계 — 화이트리스트와 cap

클라이언트는 어떤 문자열도 박을 수 있다

가장 빠른 어뷰징 방법은 단순했다.

// 콘솔에서
const recs = JSON.parse(localStorage.getItem('studyace_study_records_<email>'));
recs['equipped_title'] = 'challenger_ruler';   // 가장 높은 티어 칭호
localStorage.setItem('studyace_study_records_<email>', JSON.stringify(recs));
// sync 한 번 굴리면 서버에 박힘

서버는 그냥 받아 적기만 했다. users.title = ?임의의 문자열 이 들어가는 구조였다.

해결책 — 서버 측 화이트리스트.

// functions/_lib/whitelists.ts
export const TITLE_IDS: ReadonlySet<string> = new Set([
  "accuracy_85", "amethyst_sage", "challenger_ruler", /* ... 44개 */
]);
 
export const RELIC_IDS: ReadonlySet<string> = new Set([
  "relic_astronomy_map", "relic_bell", /* ... 28개 */
]);
 
export function isValidTitle(t: unknown): boolean {
  if (t === null || t === undefined || t === "") return true;
  return typeof t === "string" && TITLE_IDS.has(t);
}
 
export function sanitizeGachaInventory(raw: unknown): Record<string, number> | null {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
  const entries = Object.entries(raw as Record<string, unknown>);
  if (entries.length > RELIC_INVENTORY_MAX_KEYS) return null;
  const out: Record<string, number> = {};
  for (const [k, v] of entries) {
    if (!RELIC_IDS.has(k)) return null;
    const n = Number(v);
    if (!Number.isInteger(n) || n < 1 || n > RELIC_LEVEL_MAX) return null;
    out[k] = n;
  }
  return out;
}

ID 들은 클라(js/gamification.js)의 TITLES_LIST.idGACHA_ITEMS.id 에서 그대로 뽑아 옮겼다. 양쪽이 동기 안 되면 새 칭호 추가 시 못 박는 문제가 생기지만, 그건 변경 시 동기 필수 라고 코드에 주석으로 박았다.

sync 시점에 검증.

const rawTitle = study_records?.equipped_title;
const titleToSave = isValidTitle(rawTitle) ? (rawTitle ? String(rawTitle) : null) : null;
const titleIsKnownGood = isValidTitle(rawTitle);
 
const rawInventory = settings?.gacha_inventory;
const sanitizedInv = rawInventory ? sanitizeGachaInventory(rawInventory) : null;
 
if (titleIsKnownGood || (sanitizedInv !== null)) {
  await env.DB.prepare(
    "UPDATE users SET title = COALESCE(?, title), gacha_inventory = COALESCE(?, gacha_inventory) WHERE email = ?"
  )
    .bind(titleIsKnownGood ? titleToSave : null,
          sanitizedInv ? JSON.stringify(sanitizedInv) : null,
          email)
    .run();
}

COALESCE(?, title) — 새 값이 null 이면 기존 값 유지. 부분 부정행위가 통째로 거절되어도 정상 데이터는 살아남는다.

점수와 학습 카운트의 cap

점수 측면도 비슷한 빈틈. POST /api/leaderboard 가 받는 timeElapsed 값을 그대로 박았다. 화이트리스트는 의미가 없으니까 cap 으로 잡는다.

// 절대값 cap — 1,000,000 점이면 정상 사용자가 도달 불가능
if (timeElapsed > 1_000_000) return error("값이 허용 범위를 벗어났습니다.", 400);
 
// delta cap — 정상 학습 한 세션 200~500, cap 500
const MAX_SCORE_DELTA_PER_POST = 500;
const FIRST_PUSH_MARGIN = 1000;
const RECENT_WINDOW_MS = 10 * 60 * 1000;
 
if (isFirstPush) {
  if (timeElapsed > FIRST_PUSH_MARGIN) return error(...);
} else {
  const delta = timeElapsed - existingScore;
  // 최근 10분 내 push 면 한도 절반 — 매크로·도배 방지
  const wasRecent = Number.isFinite(lastUpdatedAtMs) &&
                    Date.now() - lastUpdatedAtMs < RECENT_WINDOW_MS;
  const cap = wasRecent ? Math.floor(MAX_SCORE_DELTA_PER_POST / 2) : MAX_SCORE_DELTA_PER_POST;
  if (delta > cap) return error(`점수 증가량이 비정상입니다 (1회 POST 최대 +${cap}).`, 400);
}

학습 진행도(study_records) 도 같은 맥락에서 cap.

const MAX_COUNT_PER_KEY = 200;  // 한 단원 200회는 비현실적
const MAX_TOTAL_DELTA = 300;    // 한 번 POST 의 총 증가량
// 30초 안 또 push 시 한도 절반 + 429

한 번의 false positive 학습

이 cap 들을 처음 잡았을 때, 부원이 PR 을 하나 올렸다 — "활동량이 많은 유저의 정상적인 동기화를 방해하는 문제가 발생하여 완전 제거".

내 cap 이 너무 빡센 게 아니라, 어떤 키는 누적 통화성 (gacha_points, gacha_spent, bonus_quest_xp 같은) 인 걸 빠뜨렸던 거였다. 정상 사용자가 학습 활동을 누적하면 gacha_points 가 수천이 된다. 그게 cap 200 에 걸렸다.

해결책은 전체 cap 제거 가 아니라 예외 키 명시.

const EXEMPT_COUNT_KEYS = new Set([
  "gacha_points", "gacha_spent",
  "bonus_quest_xp", "bonus_login_xp",
  "login_count", "login_streak",
  "quest_claimed_count", "wrong_note_solved_count",
  "equipped_title", "equipped_title_name", "equipped_theme",
]);
 
for (const [k, v] of Object.entries(study_records)) {
  if (k.startsWith("last_")) continue;
  if (EXEMPT_COUNT_KEYS.has(k)) continue;
  if (Array.isArray(v)) continue;
  if (typeof v === "string") continue;
  // ... cap 검증
}

false positive 보고가 들어왔다고 방어선을 통째로 빼는 건 같은 함정을 다시 파게 된다. 예외를 명시하는 게 옳다.

B단계 — 가챠 서버화

A 단계가 끝나도 가장 큰 어뷰징 한 가지가 남아 있었다.

// gamification.js 의 옛 pullGachaItem
const roll = Math.random();   // 클라 RNG
let targetRarity = 'COMMON';
if (roll < 0.003) targetRarity = 'ETERNAL';   // 0.3%
// ...
 
let inv = getGachaInventory();
inv[pulledItem.id] = Math.min(10, oldLevel + 1);  // 클라 localStorage
saveGachaInventory(inv);

Math.random 의 결과를 클라가 결정한다. 콘솔에서 Math.random = () => 0.001 한 줄이면 매번 ETERNAL. 인벤토리도 클라가 직접 박는다. A단계의 화이트리스트가 임의 ID 박는 건 막지만, 합법적 ID 로 ETERNAL 박는 건 다 통과한다.

가챠 자체를 서버로 옮겼다.

functions/api/gacha-pull.ts

export const onRequestPost: PagesFunction = async ({ request, env }) => {
  const payload = await authFromRequest(request, env.JWT_SECRET);
  if (!payload) return error("로그인이 필요합니다.", 401);
  const email = payload.sub;
 
  const row = await env.DB.prepare(
    "SELECT study_records, settings, updated_at FROM user_progress WHERE user_email = ?"
  ).bind(email).first();
  if (!row) return error("진행도 데이터가 없습니다.", 404);
 
  const records = JSON.parse(row.study_records) || {};
  const settings = JSON.parse(row.settings || "{}");
 
  // 빈도 cap — 1초 안 재호출 차단
  const now = Date.now();
  const lastGachaAt = Number(records["last_gacha_at"] || 0);
  if (now - lastGachaAt < MIN_INTERVAL_MS) {
    return error("가챠가 너무 빠릅니다.", 429);
  }
 
  // 비용 계산 — 클라가 보낸 cost 는 신뢰 X. 서버가 records 에서 직접
  const equippedTitleId = String(records["equipped_title"] || "");
  const unlockedTitles = Array.isArray(records["unlocked_titles"])
    ? records["unlocked_titles"].map(String) : [];
  const cost = computeCost({ equippedTitleId, unlockedTitles, dayOfWeek: new Date().getUTCDay() });
 
  const earned = Number(records["gacha_points"] || 0);
  const spent = Number(records["gacha_spent"] || 0);
  if (earned - spent < cost) return error(`포인트가 부족합니다.`, 402);
 
  // 굴림 — crypto.getRandomValues 는 Math.random 보다 예측 불가
  const pulled = rollGacha();
 
  // 인벤토리 갱신 — base 도 sanitize 통과한 값만 사용
  const baseInv = sanitizeGachaInventory(settings["gacha_inventory"]) ?? {};
  const oldLevel = baseInv[pulled.id] || 0;
  const newLevel = Math.min(RELIC_LEVEL_MAX, oldLevel + 1);
  const newInv = { ...baseInv, [pulled.id]: newLevel };
 
  // 차감 + 시점 기록
  records["gacha_spent"] = spent + cost;
  records["last_gacha_at"] = now;
  settings["gacha_inventory"] = newInv;
 
  await env.DB.prepare(
    "UPDATE user_progress SET study_records = ?, settings = ?, updated_at = ? WHERE user_email = ?"
  ).bind(JSON.stringify(records), JSON.stringify(settings), now, email).run();
  await env.DB.prepare("UPDATE users SET gacha_inventory = ? WHERE email = ?")
    .bind(JSON.stringify(newInv), email).run();
 
  return json({ success: true, result: { id: pulled.id, rarity: pulled.rarity, newLevel, isNew: oldLevel === 0, isMaxedOut: oldLevel >= RELIC_LEVEL_MAX },
                cost, payback, newPoints: earned - spent - cost, newInventory: newInv, updated_at: now });
};

RNG 가 crypto.getRandomValues 다. Workers 의 standard Web Crypto. Math.random 처럼 예측 가능한 PRNG 가 아니라 cryptographically secure.

비용 계산도 클라가 보낸 숫자 가 아니라 서버가 records 에서 읽은 상태 로. 클라가 "이미 할인된 50pt 비용" 이라고 주장해도 서버는 equippedTitleIdunlockedTitles 를 직접 보고 다시 계산.

updated_at = now 가 한 가지 더 한다. 다음 sync-progress 호출 시 클라의 last_synced_at 이 서버 updated_at 보다 작아져서 자동으로 409 server-newer 응답을 받는다. 그러면 클라가 서버 데이터로 localStorage 를 통째 덮어쓰기 한다. 가챠 직후 양쪽 정합성 자동 회복.

클라이언트는 UI 만

async function pullGachaItem(times = 1) {
  // 멀티는 서버 미지원 — 단일 강제
  if (times > 1) {
    alert('현재 단일 가챠만 지원됩니다.');
    return;
  }
  // ... 애니메이션 시작
 
  const res = await fetch(API_BASE + '/api/gacha-pull', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
  });
  const data = await res.json();
  if (!res.ok) { /* 에러 alert + 애니메이션 정리 */ return; }
 
  // 서버 응답으로 로컬 갱신 (서버가 권위)
  localStorage.setItem(`studyace_gacha_inventory_levels_${userKey}`,
    JSON.stringify(data.newInventory));
  const records = getStudyRecords();
  records['gacha_spent'] = (records['gacha_spent'] || 0) + data.cost;
  if (data.payback > 0) records['bonus_quest_xp'] = (records['bonus_quest_xp'] || 0) + data.payback;
  localStorage.setItem(`studyace_study_records_${userKey}`, JSON.stringify(records));
 
  // ... 결과 카드 렌더링 (서버가 결정한 item.rarity·newLevel 표시)
}

Math.random, 클라 측 차감, saveGachaInventory 직접 호출 — 다 사라졌다. 클라는 그냥 결과를 받아 보여주기만 한다.

안 막은 것들 (C단계가 필요한 이유)

여기까지로도 큰 어뷰징은 다 잡혔지만 완전하진 않다.

  • gacha_points 누적이 클라 트러스트records['gacha_points'] += earnedXP 가 클라에서 일어난다. 가짜 학습 활동으로 점수를 부풀려서 가챠를 합법적으로 더 굴릴 수 있다
  • 퀴즈 정답 검증이 클라 — 답을 클라가 채점한다. 서버는 결과(맞춤 횟수)만 받는다. 매크로가 모든 문제 정답 처리를 박을 수 있다
  • 칭호 unlock 조건이 클라 — 화이트리스트는 형식 만 본다. score 0 인데 challenger_ruler 박는 건 막지만, score 만 부풀린 다음 칭호 박는 건 가능

C 단계 = 학습 흐름 자체를 서버로 옮기는 큰 refactor. 퀴즈 ID + 답을 서버에 보내고 서버가 채점·점수 부여. 시험 끝나고 따로.

사이드 효과 — 코드를 다시 읽게 된다

이 작업의 가장 큰 부산물은 내가 내 코드를 다시 깊게 읽었다 는 것. 어떤 함수가 어떻게 호출되고, 어떤 데이터가 어디서 결정되는지를 한 줄씩 따라가지 않으면 안전선을 못 긋는다. 그러는 동안 정리할 만한 dead code, 중복, 일회용 스크립트들도 자연스럽게 보였다.

클라이언트를 너무 믿고 만든 사이트는 운영 중에 안전해질 수 있다. 다만 그 사이트를 다시 읽기 시작하는 결정이 필요할 뿐.

관련 글