
이미지 출처 : Freepik
KISA 한국인터넷진흥원에 안내되어 있는 JavaScript 시큐어코딩 가이드는 다양한 보안 위협 사항들과 그에 따른 안전한 코딩 방법에 대해 소개합니다. 지난 1절에 이어 2절 보안기능에 대한 내용을 정리해 보겠습니다.
🎬 본 가이드에서는 자바스크립트로 클라이언트, 서버 모두 개발이 가능한 점을 고려해 풀스택 상황에서의 보안 가이드를 제시하는 것을 목표로 합니다.
클라이언트측에서 다양한 라이브러리를 사용해 인증 기능을 구현할 수 있지만 궁극적으로는 서버측에서 안전한 인증이 지원되지 않으면 보안 문제가 발생할 수 있다.
📌 안전한 코딩기법
- 클라이언트의 보안 검사를 우회하여 서버에 접근하지 못하도록 설계
- 중요한 정보가 있는 페이지는 재인증 적용
- 안전하다고 검증된 라이브러리나 프레임워크를 사용
❌ 안전하지 않은 코드 예시
패스워드 재확인 절차가 생략된 코드 예시
const express = require('express');
const crypto = require("crypto");
router.post("/vuln", (req, res) => {
const newPassword = req.body.newPassword;
const user = req.session.userid;
const hs = crypto.createHash(“sha256”)
const newHashPassword = hs.update(newPassword).digest("base64");
// 현재 패스워드와 일치 여부를 확인하지 않고 업데이트
updatePasswordFromDB(user, newHashPassword);
return res.send({message: "패스워드가 변경되었습니다.", userId, password, hashPassword});
});
🟢 안전한 코드 예시
패스워드 일치 재확인 후 DB를 수정해 안전하게 코드를 적용할 수 있다.
const express = require('express');
const crypto = require("crypto");
router.post("/patched", (req, res) => {
const newPassword = req.body.newPassword;
const user = req.session.userid;
const oldPassword = getPasswordFromDB(user);
const salt = crypto.randomBytes(16).toString('hex');
const hs = crypto.createHash("sha256“)
const currentPassword = req.body.currentPassword;
const currentHashPassword = hs.update(currentPassword + salt).digest("base64");
// 현재 패스워드 확인 후 사용자 정보 업데이트
if (currentHashPassword === oldPassword) {
const newHashPassword = hs.update(newPassword + salt).digest("base64");
updatePasswordFromDB(user, newHashPassword);
return res.send({ message: "패스워드가 변경되었습니다." });
} else {
return res.send({ message: "패스워드가 일치하지 않습니다." });
}
});
사용자가 접근 가능한 경로에 대해서 접근 제어를 정확히 처리하지 않거나 불완전하게 검사하는 경우 공격자는 접근 가능한 실행경로를 통해 정보를 유출할 수 있다.
📌 안전한 코딩기법
- 정보와 기능이 가지는 역할에 맞게 분리 개발함으로써 공격자에게 노출되는 공격 노출면(Attack Surface) 최소화
- 사용자의 권한에 따른 ACL(Access Control List)을 관리
❌ 안전하지 않은 코드 예시
권한 확인을 위한 별도의 통제 없이 사용자 입력값에 따라 삭제를 수행하는 예시
const express = require('express');
function deleteContentFromDB(contentId) { ... }
router.delete("/vuln", (req, res) => {
const contentId = req.body.contentId;
// 작업 요청을 하는 사용자의 권한 확인 없이 삭제 작업 수행
deleteContentFromDB(contentId);
return res.send("삭제가 완료되었습니다.");
});
🟢 안전한 코드 예시
세션에 저장된 사용자 정보를 통해 해당 사용자가 수행할 작업에 대한 권한이 있는지 확인한 후 권한이 있는 경우에만 작업을 수행한다.
const express = require('express');
function deleteContentFromDB(contentId) { ... }
router.delete("/patched", (req, res) => {
const contentId = req.body.contentId;
const role = req.session.role;
// 삭제 기능을 수행할 권한이 있는 경우에만 삭제 작업 수행
if (role === "admin") {
deleteContentFromDB(contentId);
return res.send("삭제가 완료되었습니다.");
} else {
return res.send("권한이 없습니다.");
}
});
중요한 보안관련 자원에 대해 읽기 또는 수정하기 권한을 의도하지 않게 허가할 경우 권한을 갖지 않은 사용자가 해당 자원을 사용하게 된다.
📌 안전한 코딩기법
- 설정 파일, 실행 파일, 라이브러리 등은 관리자에 의해서만 읽고 쓰기가 가능하도록 설정
- 설정 파일과 같이 중요한 자원을 사용하는 경우 허가 받지 않은 사용자가 중요한 자원에 접근 가능한지 검사
❌ 안전하지 않은 코드 예시
/root/system_config 파일에 대해서 모든 사용자가 읽기, 쓰기, 실행 권한을 가지는 상황에 대한 예제
const fs = require("fs");
function writeFile() {
// 모든 사용자가 읽기, 쓰기, 실행 권한을 가지게 됨
fs.chmodSync("/root/system_config", 0o777);
fs.open("/root/system_config", "w", function(err,fd) {
if (err) throw err;
});
fs.writeFile("/root/system_config", "your config is broken", function(err) {
if (err) throw err;
console.log('write end');
});
}
🟢 안전한 코드 예시
주요 파일에 대해서는 최소 권한만 할당해야 한다. 파일의 소유자라고 하더라도 기본적으로 읽기 권한만 부여해야 하며 부득이하게 쓰기 권한이 필요한 경우에만 제한적으로 쓰기 권한을 부여해야 한다.
const fs = require("fs");
function writeFile() {
// 소유자 이외에는 권한을 가지지 않음
fs.chmodSync("/root/system_config", 0o700);
fs.open("/root/system_config", "w", function(err,fd) {
if (err) throw err;
});
fs.writeFile("/root/system_config", "your config is broken", function(err) {
if (err) throw err;
console.log('write end');
})
}
정보보호 측면에서 취약하거나 위험한 암호화 알고리즘을 사용하면 공격자가 알고리즘을 분석해 무력화시킬 수 있는 가능성을 높일 수 있기 때문에 사용해서는 안 된다.
📌 안전한 코딩기법
- 자신만의 암호화 알고리즘을 개발하는 것은 위험하며, 학계 및 업계에서 이미 검증된 표준화된 알고리즘을 사용
- 기존에 취약하다고 알려진 DES, RC5 등의 암호알고리즘을 대신하여 3TDEA, AES, SEED 등의 안전한 암호 알고리즘으로 대체하여 사용
- 기존에 취약하다고 알려진 MD5 해쉬 함수 등을 대신하여 sha-256 해쉬 함수 등을 적용
- 업무관련 내용, 개인정보 등에 대한 암호 알고리즘 적용 시 안전한 암호화 알고리즘을 사용
❌ 안전하지 않은 코드 예시
취약한 DES 알고리즘으로 암호화하는 예시
❗ DES 알고리즘 : 키 길이 부족 (56비트 키), 단일 키 암호화 구조 등...에 의해 취약하다.
const crypto = require("crypto");
function getEncText(plainText, key) {
// 취약한 알고리즘인 DES를 사용하여 안전하지 않음
const cipherDes = crypto.createCipheriv('des-ecb', key, '');
const encryptedData = cipherDes.update(plainText, 'utf8', 'base64');
const finalEnctypedData = cipherDes.final('base64');
return encryptedData + finalEnctypedData;
}
🟢 안전한 코드 예시
안전한 AES 암호화 알고리즘을 사용해야 한다.
❗AES 암호화 알고리즘 : 128비트 블록 단위로 동작하는 대칭키 암호화 알고리즘으로, 128/192/256비트 키를 지원한다. 더 긴 키 길이와 안전한 구조 덕분에 DES보다 훨씬 강력하고 안정성이 높다.
const crypto = require("crypto");
function getEncText(plainText, key, iv) {
// 권장 알고리즘인 AES를 사용하여 안전함
const cipherAes = crypto.createCipheriv('aes-256-cbc', key, iv);
const encryptedData = cipherAes.update(plainText, 'utf8', 'base64');
const finalEnctypedData = cipherAes.final('base64');
return encryptedData + finalEnctypedData;
}
많은 응용 프로그램은 메모리나 디스크 상에서 중요한 정보(개인정보, 인증정보, 금융정보 등)를 처리한다. 사용자 또는 시스템의 중요 정보가 포함된 데이터를 평문으로 송·수신 또는 저장 시 인가되지 않은 사용자에게 민감한 정보가 노출될 수 있다.
📌 안전한 코딩기법
- 개인정보(주민등록번호, 여권번호 등), 금융정보(카드번호, 계좌번호 등), 패스워드 등 중요정보를 저장하거나 통신채널로 전송할 때는 반드시 암호화 과정을 거쳐야 함
- 중요정보를 읽거나 쓸 경우에 권한인증 등을 통해 적합한 사용자만 중요정보에 접근하도록 해야함
- 가능하다면 SSL 또는 HTTPS 등과 같은 보안 채널을 사용
- 보안 채널을 사용하지 않고 브라우저 쿠키에 중요 데이터를 저장하는 경우 쿠키 객체에 보안속성을 설정해(secure = True) 중요 정보의 노출을 방지
❌ 안전하지 않은 코드 예시
사용자로부터 전달받은 패스워드 암호화를 누락한 예제
function updatePass(dbconn, password, user_id) {
// 암호화되지 않은 비밀번호를 DB에 저장하는 경우 위험함
const sql = 'UPDATE user SET password=? WHERE user_id=?';
const params = [password, user_id];
dbconn.query(sql, params, function(err, rows, fields){
if (err) console.log(err);
})
}
🟢 안전한 코드 예시
해쉬 알고리즘을 이용하여 단방향 암호화 이후에 패스워드를 저장한다.
이 때 해쉬 함수 또한 SHA256과 같이 안정성이 검증된 알고리즘을 사용해야 한다.
const crypto = require("crypto");
function updatePass(dbconn, password, user_id, salt) {
// 단방향 암호화를 이용하여 비밀번호를 암호화
const sql = 'UPDATE user SET password=? WHERE user_id=?';
const hashPw = crypto.createHash('sha256').update(password + salt, 'utf-8').digest('hex');
const params = [hashPw, user_id];
dbconn.query(sql, params, function(err, rows, fields){
if (err) console.log(err);
})
}
프로그램 코드 내부에 하드코드된 중요정보를 통해 내부 인증에 사용하거나 외부와 통신을 하는 경우 관리자의 정보가 노출될 수 있어 위험하다. 또한 하드코드된 암호화 키를 사용해 암호화를 수행하면 암호화된 정보가 유출될 가능성이 높아진다. 암호키의 해쉬를 계산해 저장하더라도 역계산이 가능해 무차별 대입 공격에는 취약할 수 있다.
❗ 무차별 대입 공격(Brute-Force) : 가능한 모든 값들을 대입하여 특정 암호나 키를 해독하려는 공격 방식
📌 안전한 코딩기법
- 패스워드는 암호화 후 별도의 파일에 저장하여 사용
- 중요 정보 암호화 시 상수가 아닌 암호화 키를 사용
- 암호화가 잘 되었더라도 코드 내부에 상수 형태의 암호화 키를 주석으로 달거나 저장하지 않도록 함
❌ 안전하지 않은 코드 예시
소스코드에 중요 정보를 하드코딩 하는 경우 중요 정보가 노출될 수 있어 위험하다.
const express = require('express');
const mysql = require("mysql");
const crypto = require("crypto");
const dbQuery = "SELECT email, name FROM user WHERE name = 'test'";
router.get("/vuln", (req, res) => {
// 데이터베이스 연결에 필요한 인증 정보가 평문으로 하드코딩되어 있음
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'root1234',
database: 'javascript',
port: 3306,
});
connection.query(
dbQuery, (err, result) => {
if (err) return res.send(err);
return res.send(result);
})
});
🟢 안전한 코드 예시
중요 정보는 안전한 암호화 방식으로 암호화 후 별도의 분리된 공간(파일)에 저장해야 하며 암호화된 정보 사용 시 복호화 과정을 거친 후 사용해야 한다.
const express = require('express');
const mysql = require("mysql");
const crypto = require("crypto");
const dbQuery = "SELECT email, name FROM user WHERE name = 'test'";
const key = getCryptKey(); // 32bytes
const iv = getCryptIV(); // 16bytes
router.get("/patched", (req, res) => {
// 설정파일에 암호화 되어 있는 user, password 정보를 가져와 복호화 한 후 사용
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const user = decipher.update(process.env.USER, 'base64', 'utf8') + decipher.final('utf8');
const decipher2 = crypto.createDecipheriv('aes-256-cbc', key, iv);
const password = decipher2.update(process.env.PASSWORD, 'base64', 'utf8') + decipher2.final
('utf8');
// DB 연결 정보도 설정파일에서 가져와 사용
const connection = mysql.createConnection({
host: process.env.DB_HOST,
user: user,
password: password,
database: process.env.DB_NAME,
port: process.env.PORT,
});
...
짧은 길이의 키를 사용하는 것은 암호화 알고리즘을 취약하게 만들 수 있다. 키 길이가 충분히 길지 않으면 짧은 시간 안에 키를 찾아낼 수 있고 이를 이용해 공격자가 암호화된 데이터나 패스워드를 복호화 할 수 있게 된다.
📌 안전한 코딩기법
- RSA 알고리즘은 적어도 2,048 비트 이상의 길이를 가진 키와 함께 사용
- 대칭 암호화 알고리즘(Symmetric Encryption Algorithm)의 경우에는 적어도 보안 강도 112비트 이상을 지원하는 알고리즘을 사용
❌ 안전하지 않은 코드 예시
보안성이 강한 RSA 알고리즘을 사용하는 경우에도 키 크기를 작게 설정하면 프로그램의 보안약점이 될 수 있다.
const crypto = require("crypto");
function vulnMakeRsaKeyPair() {
// RSA키 길이를 1024 비트로 설정하는 경우 안전하지 않음
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa',
{
modulusLength: 1024,
publicKeyEncoding: { type: "spki", format: 'pem' },
privateKeyEncoding: { type: "pkcs8", format: 'pem' }
});
return { PRIVATE: publicKey, PUBLIC: privateKey }
}
function vulnMakeEcc() {
// ECC 키 길이가 224비트 이하이면 안전하지 않음
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'secp192k1',
publicKeyEncoding: { type: 'spki', format: 'der' },
privateKeyEncoding: { type: 'pkcs8', format: 'der' }
});
return { PRIVATE: publicKey.toString('hex'), PUBLIC: privateKey.toString('hex') }
}
🟢 안전한 코드 예시
RSA, DSA의 경우 키의 길이는 적어도 2048 비트를, ECC의 경우 224 비트 이상으로 설정해야 안전하다.
const crypto = require("crypto");
function vulnMakeRsaKeyPair() {
// RSA키 길이를 2048 비트로 설정해 안전함
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa',
{
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: 'pem' },
privateKeyEncoding: { type: "pkcs8", format: 'pem' }
});
return { PRIVATE: publicKey, PUBLIC: privateKey }
}
function vulnMakeEcc() {
// ECC 키 길이를 256 비트로 설정해 안전함
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'secp256k1',
publicKeyEncoding: { type: 'spki', format: 'der' },
privateKeyEncoding: { type: 'pkcs8', format: 'der' }
});
return { PRIVATE: publicKey.toString('hex'), PUBLIC: privateKey.toString('hex') }
}
예측 불가능한 숫자가 필요한 상황에서 예측 가능한 난수를 사용한다면 공격자가 생성되는 다음 숫자를 예상해 시스템을 공격할 수 있다.
📌 안전한 코딩기법
- 난수 발생기에서 시드(Seed)를 사용하는 경우에는 고정된 값을 사용하지 않고 예측하기 어려운 방법으로 생성된 값을 사용
- NodeJS 엔진에서는
crypto.getRandomBytes를 사용해 암호학적으로 안전한 의사 난수 바이트를 생성 가능(생성한 바이트를 숫자로 변환하게 되면 안정성이 깨질 수도 있으므로 주의)- 브라우저에서는
RandomSource.getRandomValues를 사용해 암호학적으로 안전한 의사 랜덤 숫자를 생성 가능
❌ 안전하지 않은 코드 예시
암호학적으로 안전하지 않은 Math.random 함수를 사용해 난수를 생성하는 예제
function getOtpNumber() {
let randomStr = '';
// Math.random 라이브러리는 보안기능에 사용하면 위험함
for (let i = 0; i < 6; i++) {
randomStr += String(Math.floor(Math.random() * 10))
}
return randomStr;
}
🟢 안전한 코드 예시
NodeJS 환경에서 crypto 라이브러리를 사용해 암호학적으로 안전한 난수 값을 생성하는 예제
const crypto = require("crypto");
function getOtpNumber() {
// 보안기능에 적합한 난수 생성용 crypto 라이브러리 사용
const array = new Uint32Array(1);
// 브라우저에서는 crypto 대신에 window.crypto를 사용
const randomStr = crypto.getRandomValues(array);
let result;
for (let i = 0; i < randomStr.length; i++) {
result = array[i];
}
return String(result).substring(0, 6);
}
강한 패스워드 조합 규칙을 사용하도록 강제하지 않으면 패스워드 공격으로부터 사용자 계정이 위험에 빠질 수 있다.
📌 안전한 코딩기법
- 「패스워드 선택 및 이용 안내서」에서 제시하는 패스워드 설정 규칙을 적용
- 패스워드 생성 시 강한 조건 검증을 수행 (숫자와 영문자, 특수문자 등을 혼합)
- 주기적으로 변경하여 사용하도록함
❌ 안전하지 않은 코드 예시
사용자가 입력한 패스워드에 대한 복잡도 검증 없이 가입 승인 처리를 수행
const express = require('express');
const mysql = require("mysql");
const connection = mysql.createConnection( ... );
router.post("/vuln", (req, res) => {
const con = connection;
const { email, password, name } = req.body;
// 패스워드 생성 규칙 검증 없이 회원 가입 처리
con.query(
"INSERT INTO user (email, password, name) VALUES (?, ?, ?)",
[email, password, name],
(err, result) => {
if (err) return res.send(err);
return res.send("회원가입 성공");
});
});
🟢 안전한 코드 예시
사용자 계정 보호를 위해 회원가입 시 패스워드 복잡도와 길이를 검증 후 가입 승인처리를 수행
const express = require('express');
const mysql = require("mysql");
const connection = mysql.createConnection(...);
router.post("/patched", (req, res) => {
const con = connection;
const { email, password, name } = req.body;
function checkPassword(password) {
// 2종 이상 문자로 구성된 8자리 이상 비밀번호 검사 정규식
const pt1 = /^(?=.*[A-Z])(?=.*[a-z])[A-Za-z\d!@#$%^&*]{8,}$/;
const pt2 = /^(?=.*[A-Z])(?=.*\d)[A-Za-z\d!@#$%^&*]{8,}$/;
const pt3 = /^(?=.*[A-Z])(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/;
const pt4 = /^(?=.*[a-z])(?=.*\d)[A-Za-z\d!@#$%^&*]{8,}$/;
const pt5 = /^(?=.*[a-z])(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/;
const pt6 = /^(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/;
// 문자 구성 상관없이 10자리 이상 비밀번호 검사 정규식
const pt7 = /^[A-Za-z\d!@#$%^&*]{10,}$/;
for (let pt of [pt1, pt2, pt3, pt4, pt5, pt6, pt7]) {
console.log(pt.test(password));
if (pt.test(password)) return true;
}
return false;
}
if (checkPassword(password)) {
con.query(
"INSERT INTO user (email, password, name) VALUES (?, ?, ?)",
[email, password, name],
(err, result) => {
if (err) return res.send(err);
return res.send("회원가입 성공");
}
);
} else {
return res.send("비밀번호는 영문 대문자, 소문자, 숫자, 특수문자 조합 중 2가지 이상 8자리이거나 문자 구성 상관없이 10자리 이상이어야 합니다.");
}
});
프로그램, 라이브러리, 코드의 전자서명에 대한 유효성 검증이 적절하지 않아 공격자의 악의적인 코드가 실행 가능한 보안약점으로, 클라이언트와 서버 사이의 주요 데이터 전송, 파일 다운로드 시 발생할 수 있다.
📌 안전한 코딩기법
- 주요 데이터 전송 또는 다운로드 시 데이터에 대한 전자서명을 함께 전송하고 수신측에서는 전달 받은 전자 서명을 검증해 파일의 변조 여부를 확인
- 단순히 해쉬 기반 검증만 사용할 경우 해쉬 자체를 변조해 악성코드를 전달할 수 있지만 전자서명을 사용하게 되면 원문 데이터에 대한 해쉬 자체도 안전하게 보호
❌ 안전하지 않은 코드 예시
전달한 전자서명을 수신측에서 별도로 처리하지 않고 그대로 신뢰해 코드를 실행하는 예시
const express = require('express');
const crypto = require("crypto");
const fs = require(‘fs’);
router.post("/vuln", (req, res) => {
// 클라이언트로부터 전달받은 데이터(전자서명을 수신 처리 하지 않음)
const { encrypted_msg } = req.body;
let secret_key;
fs.readFile(‘/keys/secret_key.out’, ‘utf8’, (err, data) => {
if (err) {
console.error(err);
return;
}
secret_key = data;
}
// 대칭키로 클라이언트가 전달한 코드 복호화
// (decrypt_with_symmetric_key 함수는 임의의 함수명으로 세부적인 복호화 과정은 생략함)
const decrypted = decrypt_with_symmetric_key(encrypted_msg, secret_key);
// 클라이언트로부터 전달 받은 코드 실행
eval(decrypted);
res.send('요청한 코드를 실행했습니다');
});
🟢 안전한 코드 예시
송수신측 언어가 다른 경우 사용한 암호 라이브러리에 따라 데이터 인코딩 방식에 차이가 있으니 서명 검증에 필요한 복호화 과정이 정상적으로 잘 처리되는지 검증해야 한다.
// 전자서명 검증에 사용한 코드는 의존한 패키지 및 송신측 언어에 따라
// 달라질 수 있으며, 서명을 제공한 서버의 공개키로 복호화한 전자서명과 원본 데이터 해쉬값의
// 일치 여부를 검사하는 코드를 포함
const express = require('express');
const crypto = require("crypto");
router.post("/patched", (req, res) => {
const { encrypted_msg, encrypted_sig, client_pub_key } = req.body;
let secret_key;
fs.readFile(‘/keys/secret_key.out’, ‘utf8’, (err, data) => {
if (err) {
console.error(err);
return;
}
secret_key = data;
}
// 대칭키로 클라이언트 데이터 및 전자서명 복호화
const decrypted_msg = decrypt_with_symmetric_key(encrypted_msg);
const decrypted_sig = decrypt_with_symmetric_key(encrypted_sig);
// 전자서명 검증에 통과한 경우에만 데이터 실행
if (verify_digit_signature(decrypted_msg, decrypted_sig, clint_pub_key)) {
eval(decrypted_msg);
res.send('요청한 코드를 실행했습니다');
} else {
res.send('[!] 에러 - 서명이 올바르지 않습니다.');
}
});
인증서가 유효하지 않거나 악성인 경우 공격자가 호스트와 클라이언트 사이의 통신 구간을 가로채 신뢰하는 엔티티인 것처럼 속일 수 있다.
📌 안전한 코딩기법
- 데이터 통신에 인증서를 사용하는 경우 송신측에서 전달한 인증서가 유효한지 검증한 후 데이터를 송수신
- 기본 검증 함수가 존재하지 않거나 일반적이지 않은 방식으로 인증서를 생성한 경우 암호화 패키지를 사용해 별도의 검증 코드를 작성
❌ 안전하지 않은 코드 예시
SSL 기반 웹 서버 연결 예시로 클라이언트 측에서 통신 대상 서버를 인증하지 않고 접속하는 예제이다.
이 경우 서버를 신뢰할 수 없으며 클라이언트 시스템에 영향을 주는 악성 데이터를 수신할 수 있다.
const https = require('https');
const getServer = () => {
const options = {
hostname: "dangerous.website",
port: 443,
method: "GET",
path: "/",
// 유효하지 않은 인증서를 가지고 있어도 무시하는 옵션으로 안전하지 않음
rejectUnauthorized: false
};
https.request(
options, (response) => {
console.log('response - ', response.statusCode);
}
);
});
🟢 안전한 코드 예시
rejectUnauthorized 옵션을 true로 설정하면 정상적이지 않은 인증서를 가진 서버 연결 시 연결이 수립되지 않아 클라이언트를 보호할 수 있다.
const express = require('express');
const https = require('https');
const getServer = () => {
const options = {
hostname: "dangerous.website",
port: 443,
method: "GET",
path: "/",
// 유효하지 않은 인증서 발견 시 예외 발생
rejectUnauthorized: true
};
const hreq = https.request(
options, (response) => {
console.log('response - ', response.statusCode);
}
);
hreq.on('error', (e) => {
console.error('에러발생 - ', e);
});
});
대부분의 웹 응용프로그램에서 쿠키는 메모리에 상주하며, 브라우저가 종료되면 사라진다.
브라우저 세션에 관계없이 지속적으로 쿠키 값을 저장하도록 설정도 가능하며, 이 경우 정보는 디스크에 기록되고 다음 브라우저 세션 시작 시 메모리에 로드 된다.
개인정보, 인증 정보 등이 이와 같은 영속적인 쿠키(Persistent Cookie)에 저장된다면 공격자는 쿠키에 접근할 수 있는 보다 많은 기회를 가지게 되며, 이는 시스템을 취약하게 만든다.
📌 안전한 코딩기법
- 쿠키의 만료시간은 세션 지속 시간을 고려하여 최소한으로 설정
- 영속적인 쿠키에는 사용자 권한 등급, 세션 ID 등 중요 정보가 포함되지 않도록함
❌ 안전하지 않은 코드 예시
쿠키의 만료시간을 과도하게 길게 설정하면 사용자 하드 디스크에 저장된 쿠키가 도용될 수 있다.
const express = require('express');
router.get("/vuln", (req, res) => {
// 쿠키의 만료 시간을 1년으로 과도하게 길게 설정하고 있어 안전하지 않다
res.cookie('rememberme', '1', {
expires: new Date(Date.now() + 365*24*60*60*1000)
});
return res.send("쿠키 발급 완료");
});
🟢 안전한 코드 예시
만료 시간은 기능에 맞춰 최소로 설정하고 HTTPS를 통해서만 쿠키를 전송하도록 secure 속성값을 True(기본값 False)로 사용할 수 있다.
클라이언트 측에서 쿠키에 접근하지 못하도록 제한하고자 할 경우엔 httpOnly 속성을 True(기본값 False)로 설정한다.
const express = require('express');
router.get("/patched", (req, res) => {
// 쿠키의 만료 시간을 적절하게 부여하고 secure 옵션을 활성화
res.cookie('rememberme', '1', {
expires: new Date(Date.now() + 60*60*1000),
secure: true,
httpOnly: true
});
return res.send("쿠키 발급 완료");
});
편의를 위해서 주석문에 패스워드를 적어둔 경우 소프트웨어가 완성된 후에는 그것을 제거하는 것이 매우 어렵게 된다. 만약 공격자가 소스코드에 접근할 수 있다면 시스템에 손쉽게 침입할 수 있다.
📌 안전한 코딩기법
주석에는 아이디, 패스워드 등 보안과 관련된 내용을 기입하지 않는다.
❌ 안전하지 않은 코드 예시
중요정보를 주석문 안에 작성 후 지우지 않는 경우 정보 노출되는 예시
const express = require('express');
router.post("/vuln", (req, res) => {
// 주석문에 포함된 중요 시스템의 인증 정보
// id = admin
// password = 1234
const result = login(req.body.id, req.body.password);
return res.send(result);
});
🟢 안전한 코드 예시
주석문 등에 남겨놓은 민감한 정보는 개발 완료 후 확실하게 삭제해야 한다.
const express = require('express');
router.post("/vuln", (req, res) => {
// 주석문에 포함된 민감한 정보는 삭제
const result = login(req.body.id, req.body.password);
return res.send(result);
});
패스워드와 같이 중요정보를 솔트없이 일방향 해쉬 함수를 사용해 저장한다면 공격자는 미리 계산된 레인보우 테이블을 이용해 해쉬값을 알아낼 수 있다.
중요정보를 저장할 경우 가변 길이 데이터를 고정된 크기의 해쉬값으로 변환해주는 일방향 해쉬 함수를 이용해 저장할 수 있다.
❗ 솔트(Salt) 값 : 중요 정보를 해시하기 전에 추가하는 임의의 랜덤한 데이터
❗ 레인보우 테이블 : 해시 함수를 사용하여 변환 가능한 모든 해시 값을 저장시켜 놓은 표
📌 안전한 코딩기법
- 중요 정보를 저장할 경우 임의의 길이인 데이터를 고정된 크기의 해쉬값으로 변환해주는 일방향 해쉬 함수를 이용하여 저장
- 솔트값은 사용자별로 유일하게 생성해야 하며, 이를 위해 사용자별 솔트 값을 별도로 저장하는 과정이 필요
- 자바스크립트 NodeJS에서는 crypto 패키지를 사용해 해쉬값 생성 및 솔트(randomBytes)를 생성
❌ 안전하지 않은 코드 예시
솔트 없이 길이가 짧은 패스워드를 해쉬 함수에 전달해 원문이 공격자에 의해 쉽게 유추되는 예시
const crypto = require("crypto");
function getHashFromPwd(pw) {
// salt가 없이 생성된 해쉬값은 강도가 약해 취약
const hash = crypto.createHash('sha256').update(pw).digest('hex');
return hash;
}
🟢 안전한 코드 예시
강도 높은 해쉬값을 생성하기 위해 솔트 값을 함께 전달한다.
const crypto = require("crypto");
function getHashFromPwd(pw) {
// 솔트 값을 사용하면 길이가 짧은 패스워드로도 고강도의 해쉬를 생성할 수 있음
// 솔트 값은 사용자별로 유일하게 생성해야 하며, 패스워드와 함께 DB에 저장해야 함
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.createHash('sha256').update(pw + salt).digest('hex');
return { hash, salt };
}
원격지에 위치한 소스코드 또는 실행 파일을 무결성 검사 없이 다운로드 후 실행하면 호스트 서버의 변조, DNS 스푸핑(Spoofing) 또는 전송 시의 코드 변조 등의 방법을 이용해 위협에 노출시킬 수 있다.
📌 안전한 코딩기법
- 무결성을 보장하기 위해 해쉬를 사용하고 가능하면 적절한 코드 서명 인증서를 사용하는 것이 안전함
- DNS 스푸핑을 방어할 수 있는 DNS lookup을 수행하고 코드 전송 시 신뢰할 수 있는 암호 기법을 이용해 코드를 암호화
❗ DNS 스푸핑(Spoofing) : 웹사이트 접속 시 DNS 서버로 보내는 질문을 가로채고 주소를 변조해서 악성 사이트로 유도- 다운로드한 코드는 작업 수행을 위해 필요한 최소한의 권한으로 실행
- 소스코드는 신뢰할 수 있는 사이트에서만 다운로드해야 하고 파일의 인증서 또는 해쉬값을 검사해 변조되지 않은 파일인지 확인
❌ 안전하지 않은 코드 예시
원격에서 파일을 다운로드한 뒤 무결성 검사를 수행하지 않아 파일 변조 등으로 인한 피해가 발생하는 예제
const express = require('express');
const fs = require("fs");
const http = require("http");
router.get("/vuln", (req, res) => {
// 신뢰할 수 없는 사이트에서 코드를 다운로드
const url = "https://www.somewhere.com/storage/code.js";
// 원격 코드 다운로드
http.get(url, (res) => {
const path = "./temp/sample1.js"
const writeStream = fs.createWriteStream(path);
res.pipe(writeStream);
writeStream.on("finish", () => {
writeStream.close();
});
});
// 무결성 검증 없이 파일 사용
fs.readFile("./temp/sample1.js", "utf8", function (err, buf) {
res.end(buf);
})
});
🟢 안전한 코드 예시
다운로드한 파일로 계산한 해쉬값과 파일과 함께 전송된(또는 이미 저장된) 해쉬값 비교를 통한 무결성 검사를 거친 후 코드를 실행해야 한다.
const express = require('express');
const fs = require("fs");
const http = require("http");
const crypto = require("crypto");
router.get("/patched", async (req, res) => {
const url = "https://www.somewhere.com/sto
const codeHash = req.body.codeHash;
http.get(url, (res) => {
const path = "./temp/sample1.js"
const writeStream = fs.createWriteStream(path);
res.pipe(writeStream);
writeStream.on("finish", () => {
writeStream.close();
});
});
const hash = crypto.createHash('sha256');
const input = fs.createReadStream("./temp/sample1.js");
let promise = new Promise ((resolve, reject) => {
input.on("readable", () => {
const data = input.read();
if (data) { hash.update(data); }
else { resolve(); }
})
});
await promise;
const fileHash = hash.digest('hex');
# 무결성 검증에 통과할 경우에만 파일 사용
if (fileHash === codeHash) {
fs.readFile("./temp/sample1.js", "utf8", function (err, buf) {
res.end(buf);
})
} else {
return res.send("파일이 손상되었습니다.")
}
});
일정 시간 내에 여러 번의 인증 시도 시 계정 잠금 또는 추가 인증 방법 등의 충분한 조치가 수행되지 않는 경우 공격자는 인증에 성공할 가능성이 높은 계정과 패스워드들을 무차별 대입하여 로그인 성공 및 권한 획득이 가능하다.
📌 안전한 코딩기법
- 악의적인 반복된 인증 시도 자체를 클라이언트측에서 제한하거나 서버와의 연계를 통해 어느 정도 차단 효과를 기대할 수 있지만, 궁극적으로는 안전한 서버측 코드가 구현되어야만 무작위 대입 공격에 대응 가능
- 최대 인증시도 횟수를 적절한 횟수로 제한
- 설정된 인증 실패 횟수를 초과할 경우 계정을 잠금 하거나 추가적인 인증 과정을 거쳐서 시스템에 접근이 가능하도록 설정
- 인증 시도 횟수를 제한하는 방법 외에 CAPTCHA나 Two-Factor 인증 방법도 설계 시부터 고려
❌ 안전하지 않은 코드 예시
사용자 로그인 시도에 횟수를 제한하지 않는 예시
const express = require('express');
const crypto = require('crypto');
router.post("/vuln", (req, res) => {
const id = req.body.id;
const password = req.body.password;
const hashPassword = crypto.createHash("sha512").update(password).digest("base64");
const currentHashPassword = getUserPasswordFromDB(id);
// 인증 시도에 따른 제한이 없어 반복적인 인증 시도가 가능
if (hashPassword === currentHashPassword) {
return res.send("login success");
} else {
return res.send("login fail")
}
});
🟢 안전한 코드 예시
로그인 시도에 대한 횟수를 제한하여 무차별 공격에 대응
const express = require('express');
const crypto = require('crypto');
const LOGIN_TRY_LIMIT = 5;
router.post("/patched", (req, res) => {
const id = req.body.id;
const password = req.body.password;
// 로그인 실패기록 가져오기
const loginFailCount = getUserLoginFailCount(id);
// 로그인 실패횟수 초과로 인해 잠금된 계정에 대한 인증 시도 제한
if (loginFailCount >= LOGIN_TRY_LIMIT) {
return res.send("account lock(too many failed)")
}
// 해시 생성시 솔트를 사용하는 것이 안전하나, 코드의 복잡성을 피하기 위해 생략
const hashPassword = crypto.createHash("sha512").update(password).digest("base64");
const currentHashPassword = getUserPasswordFromDB(id);
if (hashPassword === currentHashPassword) {
deleteUserLoginFailCount(id);
return res.send("login success");
} else {
updateUserLoginFailCount(id);
return res.send("login fail")
}
});
지난 글에서 정리한 1절에 이어 2절에서 다루는 보안 약점까지 정리해 봤습니다. 2절에서 다루는 "보안기능"은 개별 기능의 안정성뿐 아니라 서비스 전체의 신뢰성과 직결되므로 생각해 볼 사항이 많을 것 같습니다.
남은 3~7절에서는 무한 루프나 재귀, 예외 처리, 코드 오류, 캡슐화, API 사용 등에 대한 보안사항을 다룹니다. 따로 정리하지 않을 예정이라 추가로 궁금하신 부분은 본 가이드를 참고하시면 좋을 것 같습니다!