얼마 전 Threads에서 이런 글을 봤다.
조회 수가 4.7만 회쯤 되는 글이었다. 댓글에는 “몇십 개는 나오네요 ㅋㅋ” 같은 반응도 있었고, “Penetration Test 해달라고 하면 되겠네요!”라는 말도 달려 있었다.
그걸 보고 바로 이 생각이 들었다.
내 블로그는 괜찮을까?
이 블로그는 Astro로 만든 정적 사이트다. 별도의 백엔드 서버는 없고, 댓글과 방명록, 방문자 통계는 Firebase Realtime Database에 저장한다. 서버를 직접 운영하지 않아도 되니 편하지만, 반대로 말하면 보안의 대부분이 Firebase Security Rules에 걸려 있다는 뜻이다.
Rules를 나름 꼼꼼히 작성했다고 생각했지만, 공격자 입장에서 실제로 찔러본 적은 없었다. 그래서 자동화 스크립트를 하나 만들고, 내 사이트를 대상으로 간단한 침투 테스트를 해보기로 했다.
공격 표면 분석 (Attack Surface)
Firebase 웹 SDK를 쓰면 firebaseConfig는 클라이언트 번들에 포함된다. 이건 Firebase 구조상 정상이다. API Key가 노출되는 것 자체가 문제가 아니라, 그 키로 접근했을 때 Security Rules가 제대로 막아주느냐가 중요하다.
// src/lib/firebase/client.ts - 클라이언트 코드에 그대로 노출됨
const firebaseConfig = {
apiKey: 'AIzaSyCNkdX0A83XXEK-FIjqPt7b8iZxJ5mlw_4',
databaseURL: 'https://blog-stats-3b3fd-default-rtdb.firebaseio.com',
// ...
};
이 정보만 있으면 Firebase REST API로 DB에 직접 요청할 수 있다. 브라우저나 SDK가 꼭 필요한 것도 아니다. curl이나 fetch만 있어도 충분하다.
이번에 확인한 DB 구조는 대략 이렇다.
/admins/{uid} → 관리자 여부 (true/false)
/visits/{date} → 날짜별 방문자 수
/comments/{postSlug}/{commentId} → 댓글

테스트 설계
공격 시나리오는 9개로 나눴다. 읽으면 안 되는 데이터를 읽을 수 있는지, 쓰면 안 되는 경로에 쓸 수 있는지, 정상 입력처럼 보이지만 악용 가능한 값이 들어가는지 확인하는 식이다.
스크립트는 pentest/run-tests.js로 작성했다. Firebase REST API를 직접 호출하는 방식이라 별도 설정 없이 실행할 수 있다. 테스트 데이터는 comments/__pentest__와 visits/2099-12-31에 쓰고, 테스트가 끝나면 자동으로 지운다.
Before - 수정 전 테스트 결과
node pentest/run-tests.js
════════════════════════════════════════════════════════
Firebase Realtime DB - Penetration Test
Target : https://blog-stats-3b3fd-default-rtdb.firebaseio.com
Date : 2026-04-13T09:22:52.565Z
════════════════════════════════════════════════════════
[T01] DB 루트 읽기
✅ PROTECTED 루트 노드 읽기 차단됨.
[T02] admins/ 노드 읽기
✅ PROTECTED admins/ 읽기 차단됨.
[T03] admins/ 쓰기 - 권한 상승
✅ PROTECTED admins/ 쓰기 차단됨. 권한 상승 불가.
[T04] 방문자 수 조작
❌ VULNERABLE 10/10회 증가 성공. 최종값: 11
→ 누구든 방문자 통계를 임의로 부풀릴 수 있음.
[T05] 유효하지 않은 날짜 visits/9999-99-99
❌ VULNERABLE visits/9999-99-99 기록 성공.
[T06] 정상 댓글 쓰기 (기대값: 허용)
ℹ INFO 정상 댓글 쓰기 허용됨. (정상 동작)
[T07] 미래 타임스탬프 (year 2286)
❌ VULNERABLE createdAt=9999999999999 허용됨.
→ 날짜순 정렬 시 최상단 고정, 리스트 조작 가능.
[T08] 임의 필드 주입 (isAdmin:true, 가짜 parentId)
❌ VULNERABLE isAdmin:true, role:owner, 가짜 parentId 삽입 성공.
→ 프론트엔드가 이 필드를 신뢰하면 렌더링 조작 가능.
[T09] 리포트 폭탄
❌ VULNERABLE 10/10회 reportCount 증가 성공. 최종값: 10
→ 관리자 대시보드를 허위 신고로 도배할 수 있음.
════════════════════════════════════════════════════════
PENTEST SUMMARY
Protected (4/9): T01, T02, T03, T06
Vulnerable (5/9): T04, T05, T07, T08, T09
→ 5개 취약점 발견.
════════════════════════════════════════════════════════
결과는 생각보다 좋지 않았다. 9개 중 5개가 뚫렸다.
발견한 문제들
T04 - 방문자 수 조작 (Visit Count Inflation)
방문자 수는 이런 형태의 Rules로 보호하고 있었다.
"visits": {
"$date": {
".read": true,
".write": "... newData.val() == data.val() + 1"
}
}
한 번에 +1씩만 증가할 수 있게 해두면 충분하다고 생각했다. 하지만 문제는 횟수 제한이 없다는 점이었다. 인증도 필요 없어서, 아래처럼 반복문을 돌리면 방문자 수를 원하는 만큼 올릴 수 있다.
// 아무나 이 코드를 반복 실행하면 방문자 수가 계속 오른다
let current = (await dbGet('visits/2026-04-13')).data;
for (let i = 0; i < 1000; i++) {
await dbSet('visits/2026-04-13', current + 1);
current++;
}
Firebase Rules만으로는 IP별, 사용자별 rate limiting을 구현하기 어렵다. 이 문제는 결국 Cloud Functions로 옮겨야 제대로 막을 수 있다. 다만 첫 번째 수정에서는 날짜 유효성 검증부터 보강했다.
T05 - 유효하지 않은 날짜
날짜 경로는 정규식으로 검사하고 있었다.
"$date.matches(/^\\d{4}-\\d{2}-\\d{2}$/)"
겉보기에는 YYYY-MM-DD 형식만 허용하는 것처럼 보인다. 하지만 실제로는 자리수만 검사한다. 그래서 9999-99-99나 2026-13-45 같은 값도 통과한다.
# 이 날짜들이 모두 통과했다
visits/9999-99-99 ✓
visits/2026-13-45 ✓
visits/2000-00-00 ✓
완벽한 날짜 검증은 아니더라도, 최소한 월과 일의 범위는 막아야 했다. 그래서 월은 01~12, 일은 01~31만 허용하도록 정규식을 바꿨다.
T07 - 미래 타임스탬프 조작
댓글의 createdAt은 숫자인지만 검사하고 있었다.
"newData.child('createdAt').isNumber()"
숫자면 통과하니 9,999,999,999,999 같은 값도 들어간다. 대략 2286년에 해당하는 타임스탬프다.
// 이 댓글은 날짜순 정렬에서 계속 최상단에 남는다
await push(commentsRef, {
author: "Attacker",
content: "핀 고정된 스팸",
createdAt: 9_999_999_999_999, // year 2286
// ...
});
댓글을 최신순으로 정렬한다면, 이런 값 하나로 특정 댓글을 사실상 최상단에 고정할 수 있다.
Firebase Rules에는 서버 현재 시각을 나타내는 now 변수가 있다. createdAt < now + 60000 조건을 추가해서 1분 이상 미래의 타임스탬프를 막고, 너무 오래된 과거 값도 함께 차단했다.
T08 - 임의 필드 주입
댓글 생성 시 필수 필드는 hasChildren()으로 확인하고 있었다.
"newData.hasChildren(['author', 'content', 'createdAt', 'postSlug', 'reportCount', 'lastReportedAt'])"
여기서 놓친 부분이 있었다. hasChildren()은 지정한 필드가 있는지만 확인한다. 지정하지 않은 필드가 추가로 들어오는지는 막지 않는다.
// 이 데이터가 그대로 DB에 저장됐다
{
author: "Normal User",
content: "안녕하세요",
createdAt: Date.now(),
postSlug: "some-post",
reportCount: 0,
lastReportedAt: 0,
// 주입된 필드
isAdmin: true,
role: "owner",
parentId: "victim-comment-id-123",
}
지금 프론트엔드가 곧바로 isAdmin을 신뢰하는 구조는 아니지만, 이런 필드를 DB에 허용하는 건 좋지 않다. 나중에 UI가 바뀌면서 해당 필드를 렌더링에 쓰게 되면 문제가 생길 수 있다. parentId 같은 값도 답글 구조가 생겼을 때 악용될 여지가 있다.
그래서 isAdmin, role, adminNote 같은 민감한 필드는 명시적으로 거부하도록 했다.
T09 - 리포트 폭탄 (Report Bombing)
신고 수는 한 번에 1씩만 증가하도록 제한해두었다.
"newData.child('reportCount').val() == data.child('reportCount').val() + 1"
이것도 T04와 비슷한 문제였다. 한 번에 1씩만 올릴 수 있어도, 반복해서 호출하면 결국 원하는 만큼 올릴 수 있다.
// 특정 댓글의 신고 수를 9999로 만들기
for (let i = 0; i < 9999; i++) {
const snap = await get(commentRef);
const cur = snap.val();
await set(commentRef, { ...cur, reportCount: cur.reportCount + 1 });
}
관리자 화면은 신고 수가 높은 댓글을 우선 보여준다. 정상 댓글에 허위 신고를 쌓으면 대시보드가 오염될 수 있다.
완전한 신고 중복 방지는 사용자 식별이 필요하지만, 우선 reportCount <= 50 상한선을 넣었다. 50회 이상 신고된 댓글은 어차피 관리자가 확인해야 하고, 그 이상 숫자가 커질 필요는 없다.
Rules 수정
변경한 핵심 부분만 정리하면 이렇다.
visits - 날짜 정규식 강화
- "$date.matches(/^\\d{4}-\\d{2}-\\d{2}$/)"
+ "$date.matches(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/)"
월은 01~12, 일은 01~31만 허용한다.
comments - 신규 댓글 검증 강화
newData.child('createdAt').isNumber()
+ && newData.child('createdAt').val() > 1600000000000
+ && newData.child('createdAt').val() < now + 60000
&& newData.child('postSlug').isString()
...
+ && !newData.hasChildren(['isAdmin', 'role', 'adminNote'])
> 1600000000000: 2020년 이전의 비정상 타임스탬프 차단< now + 60000: 1분을 초과하는 미래 타임스탬프 차단!newData.hasChildren([...]): 민감한 필드 주입 차단
comments - 신고 상한선 추가
newData.child('reportCount').val() == data.child('reportCount').val() + 1
+ && newData.child('reportCount').val() <= 50
After - 수정 후 테스트 결과
Firebase Console에서 Rules를 pentest/firebase-rules-fixed.json 내용으로 교체한 뒤 다시 실행했다.
T09는 상한선이 50이라서, 10번만 시도하면 차단 여부를 확인할 수 없다. 그래서 이 케이스는 55번 시도하도록 바꿔서 다시 검증했다.
════════════════════════════════════════════════════════
Firebase Realtime DB - Penetration Test
Date : 2026-04-13T09:30:55.478Z
════════════════════════════════════════════════════════
[T01] DB 루트 읽기
✅ PROTECTED 루트 노드 읽기 차단됨.
[T02] admins/ 노드 읽기
✅ PROTECTED admins/ 읽기 차단됨.
[T03] admins/ 쓰기 - 권한 상승
✅ PROTECTED admins/ 쓰기 차단됨. 권한 상승 불가.
[T04] 방문자 수 조작 (테스트 날짜: 2026-04-13)
❌ VULNERABLE 10/10회 증가 성공. (rate limiting 없음)
→ 방문자 통계를 임의로 부풀릴 수 있음. 완전 해결은 Cloud Functions 필요.
[T05] 유효하지 않은 날짜 visits/9999-99-99
✅ PROTECTED 유효하지 않은 날짜 거부됨.
[T06] 정상 댓글 쓰기 (기대값: 허용)
ℹ INFO 정상 댓글 쓰기 허용됨. (정상 동작)
[T07] 미래 타임스탬프 (year 2286)
✅ PROTECTED 미래 타임스탬프 거부됨.
[T08] 임의 필드 주입 (isAdmin:true, 가짜 parentId)
✅ PROTECTED 추가 필드 주입 거부됨.
[T09] 리포트 폭탄 (55회 시도)
✅ PROTECTED 50회 후 차단됨. 최종 reportCount: 50 (cap ≤ 50 적용)
════════════════════════════════════════════════════════
PENTEST SUMMARY
Protected (8/9): T01, T02, T03, T05, T06, T07, T08, T09
Vulnerable (1/9): T04
→ 1개 잔존 취약점 (rate limiting - Rules 범위 외)
════════════════════════════════════════════════════════
5개였던 취약점이 1개로 줄었다. 남은 건 T04, 방문자 수 rate limiting이었다. 이건 Firebase Rules만으로는 제대로 해결할 수 없어서 Cloud Functions로 옮기기로 했다.
여기서 테스트 설계도 하나 배웠다. 처음에는 T09를 10번만 시도하고 “아직 취약하다”고 볼 뻔했다. 하지만 상한선이 50이면 10번 시도는 당연히 성공한다. 방어 로직을 검증하려면 테스트 횟수도 그 경계값을 넘어야 한다.
T04 완전 해결 - Cloud Functions 이관
방문자 수 증가는 클라이언트에서 DB에 직접 쓰는 구조였다.
Before: 클라이언트 → visits/{date} 직접 쓰기
After: 클라이언트 → trackVisit Cloud Function → Admin SDK로 쓰기
바꾼 뒤에는 클라이언트가 더 이상 visits/에 직접 쓸 수 없다. Rules에서 ".write": false로 막고, 쓰기는 Cloud Function만 할 수 있게 했다.
Firebase Cloud Functions 설정
처음 배포할 때 한 번 막혔다. Firebase Functions는 내부적으로 Google Cloud Build를 사용하는데, Blaze 플랜으로 올린 직후에는 Cloud Build 서비스 계정 권한이 제대로 붙지 않아 빌드가 실패했다.
Error: Could not build the function due to a missing permission
on the build service account.
Google Cloud Console의 IAM에서 {PROJECT_NUMBER}@cloudbuild.gserviceaccount.com 계정에 Cloud Build 서비스 계정 역할을 추가해 해결했다.
Cloud Function 코드
// functions/src/index.ts
export const trackVisit = onCall(
{ region: 'asia-northeast3' },
async (request) => {
const date = request.data?.date; // 클라이언트가 KST 날짜 전달
if (!DATE_REGEX.test(date)) return { counted: false };
const db = getDatabase();
// IP를 직접 저장하지 않고 16자리 해시만 보관
const ip = request.rawRequest.headers['x-forwarded-for'] ?? '...';
const ipHash = createHash('sha256').update(ip).digest('hex').slice(0, 16);
// 오늘 이 IP가 이미 카운트됐으면 스킵
const rateLimitRef = db.ref(`_rateLimit/visits/${date}/${ipHash}`);
if ((await rateLimitRef.get()).exists()) {
return { counted: false, current: ... };
}
// atomic 증가
const result = await db.ref(`visits/${date}`).transaction(v => (v ?? 0) + 1);
await rateLimitRef.set(true);
return { counted: true, current: result.snapshot.val() };
}
);
핵심은 두 가지다. 클라이언트 직접 쓰기를 막고, IP 해시 기준으로 하루에 한 번만 카운트한다. IP 원문은 저장하지 않는다.
최종 결과
Rules 적용과 Cloud Function 배포 후 pentest를 다시 돌렸다.
════════════════════════════════════════════════════════
Firebase Realtime DB - Penetration Test
Date : 2026-04-13T09:57:18.143Z
════════════════════════════════════════════════════════
[T01] DB 루트 읽기 ✅ PROTECTED 루트 노드 읽기 차단됨.
[T02] admins/ 노드 읽기 ✅ PROTECTED admins/ 읽기 차단됨.
[T03] admins/ 쓰기 ✅ PROTECTED 권한 상승 불가.
[T04] 방문자 수 조작 ✅ PROTECTED visits/ 쓰기 차단됨 (status 401).
[T05] 유효하지 않은 날짜 ✅ PROTECTED 유효하지 않은 날짜 거부됨.
[T06] 정상 댓글 쓰기 ℹ INFO 정상 허용. (정상 동작)
[T07] 미래 타임스탬프 ✅ PROTECTED 미래 타임스탬프 거부됨.
[T08] 임의 필드 주입 ✅ PROTECTED 추가 필드 주입 거부됨.
[T09] 리포트 폭탄 ✅ PROTECTED 50회 cap 적용.
Protected (9/9): All tests passed. No vulnerabilities found.
════════════════════════════════════════════════════════
최종 결과는 9/9. 처음에 발견된 취약점은 모두 막혔다.
회고
Firebase Security Rules는 코드처럼 보이지만, 실제로는 작은 정책 언어에 가깝다. 그래서 “이 조건을 넣었으니 막히겠지”라고 생각하기 쉽다. 하지만 직접 공격 시나리오를 돌려보니, 의도와 실제 동작 사이에 빈틈이 꽤 있었다.
가장 기억에 남는 건 hasChildren()이었다. 필수 필드를 확인하는 것과 추가 필드를 금지하는 것은 완전히 다른 문제다. Rules만 눈으로 읽었다면 놓쳤을 가능성이 높다.
이번에 만든 자동화 스크립트 덕분에 앞으로는 Rules를 바꿀 때마다 바로 검증할 수 있게 됐다. 이제 Firebase Rules를 수정하면 먼저 node pentest/run-tests.js를 돌릴 생각이다.
Community
Comments
Comments appear immediately. Use report if something needs review.
No comments yet.