JavaScript 시큐어코딩 가이드를 읽어보자 (제2절)

·2025년 9월 13일
post-thumbnail

이미지 출처 : Freepik

KISA 한국인터넷진흥원에 안내되어 있는 JavaScript 시큐어코딩 가이드는 다양한 보안 위협 사항들과 그에 따른 안전한 코딩 방법에 대해 소개합니다. 지난 1절에 이어 2절 보안기능에 대한 내용을 정리해 보겠습니다.

🎬 본 가이드에서는 자바스크립트로 클라이언트, 서버 모두 개발이 가능한 점을 고려해 풀스택 상황에서의 보안 가이드를 제시하는 것을 목표로 합니다.


1. 적절한 인증 없는 중요 기능 허용

클라이언트측에서 다양한 라이브러리를 사용해 인증 기능을 구현할 수 있지만 궁극적으로는 서버측에서 안전한 인증이 지원되지 않으면 보안 문제가 발생할 수 있다.

📌 안전한 코딩기법

  • 클라이언트의 보안 검사를 우회하여 서버에 접근하지 못하도록 설계
  • 중요한 정보가 있는 페이지는 재인증 적용
  • 안전하다고 검증된 라이브러리나 프레임워크를 사용

❌ 안전하지 않은 코드 예시
패스워드 재확인 절차가 생략된 코드 예시

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: "패스워드가 일치하지 않습니다." });
  }
});

2. 부적절한 인가

사용자가 접근 가능한 경로에 대해서 접근 제어를 정확히 처리하지 않거나 불완전하게 검사하는 경우 공격자는 접근 가능한 실행경로를 통해 정보를 유출할 수 있다.

📌 안전한 코딩기법

  • 정보와 기능이 가지는 역할에 맞게 분리 개발함으로써 공격자에게 노출되는 공격 노출면(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("권한이 없습니다.");
  }
});

3. 중요한 자원에 대한 잘못된 권한 설정

중요한 보안관련 자원에 대해 읽기 또는 수정하기 권한을 의도하지 않게 허가할 경우 권한을 갖지 않은 사용자가 해당 자원을 사용하게 된다.

📌 안전한 코딩기법

  • 설정 파일, 실행 파일, 라이브러리 등은 관리자에 의해서만 읽고 쓰기가 가능하도록 설정
  • 설정 파일과 같이 중요한 자원을 사용하는 경우 허가 받지 않은 사용자가 중요한 자원에 접근 가능한지 검사

❌ 안전하지 않은 코드 예시
/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');
  })
}

4. 취약한 암호화 알고리즘 사용

정보보호 측면에서 취약하거나 위험한 암호화 알고리즘을 사용하면 공격자가 알고리즘을 분석해 무력화시킬 수 있는 가능성을 높일 수 있기 때문에 사용해서는 안 된다.

📌 안전한 코딩기법

  • 자신만의 암호화 알고리즘을 개발하는 것은 위험하며, 학계 및 업계에서 이미 검증된 표준화된 알고리즘을 사용
  • 기존에 취약하다고 알려진 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;
}

5. 암호화되지 않은 중요정보

많은 응용 프로그램은 메모리나 디스크 상에서 중요한 정보(개인정보, 인증정보, 금융정보 등)를 처리한다. 사용자 또는 시스템의 중요 정보가 포함된 데이터를 평문으로 송·수신 또는 저장 시 인가되지 않은 사용자에게 민감한 정보가 노출될 수 있다.

📌 안전한 코딩기법

  • 개인정보(주민등록번호, 여권번호 등), 금융정보(카드번호, 계좌번호 등), 패스워드 등 중요정보를 저장하거나 통신채널로 전송할 때는 반드시 암호화 과정을 거쳐야 함
  • 중요정보를 읽거나 쓸 경우에 권한인증 등을 통해 적합한 사용자만 중요정보에 접근하도록 해야함
  • 가능하다면 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);
  })
}

6. 하드코드된 중요정보

프로그램 코드 내부에 하드코드된 중요정보를 통해 내부 인증에 사용하거나 외부와 통신을 하는 경우 관리자의 정보가 노출될 수 있어 위험하다. 또한 하드코드된 암호화 키를 사용해 암호화를 수행하면 암호화된 정보가 유출될 가능성이 높아진다. 암호키의 해쉬를 계산해 저장하더라도 역계산이 가능해 무차별 대입 공격에는 취약할 수 있다.
❗ 무차별 대입 공격(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,
  });
...

7. 충분하지 않은 키 길이 사용

짧은 길이의 키를 사용하는 것은 암호화 알고리즘을 취약하게 만들 수 있다. 키 길이가 충분히 길지 않으면 짧은 시간 안에 키를 찾아낼 수 있고 이를 이용해 공격자가 암호화된 데이터나 패스워드를 복호화 할 수 있게 된다.

📌 안전한 코딩기법

  • 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') }
}

8. 적절하지 않은 난수 값 사용

예측 불가능한 숫자가 필요한 상황에서 예측 가능한 난수를 사용한다면 공격자가 생성되는 다음 숫자를 예상해 시스템을 공격할 수 있다.

📌 안전한 코딩기법

  • 난수 발생기에서 시드(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);
}

9. 취약한 패스워드 허용

강한 패스워드 조합 규칙을 사용하도록 강제하지 않으면 패스워드 공격으로부터 사용자 계정이 위험에 빠질 수 있다.

📌 안전한 코딩기법

  • 「패스워드 선택 및 이용 안내서」에서 제시하는 패스워드 설정 규칙을 적용
  • 패스워드 생성 시 강한 조건 검증을 수행 (숫자와 영문자, 특수문자 등을 혼합)
  • 주기적으로 변경하여 사용하도록함

❌ 안전하지 않은 코드 예시
사용자가 입력한 패스워드에 대한 복잡도 검증 없이 가입 승인 처리를 수행

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자리 이상이어야 합니다.");
  }
});

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('[!] 에러 - 서명이 올바르지 않습니다.');
  }
});

11. 부적절한 인증서 유효성 검증

인증서가 유효하지 않거나 악성인 경우 공격자가 호스트와 클라이언트 사이의 통신 구간을 가로채 신뢰하는 엔티티인 것처럼 속일 수 있다.

📌 안전한 코딩기법

  • 데이터 통신에 인증서를 사용하는 경우 송신측에서 전달한 인증서가 유효한지 검증한 후 데이터를 송수신
  • 기본 검증 함수가 존재하지 않거나 일반적이지 않은 방식으로 인증서를 생성한 경우 암호화 패키지를 사용해 별도의 검증 코드를 작성

❌ 안전하지 않은 코드 예시
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);
  });
});

12. 사용자 하드디스크에 저장되는 쿠키를 통한 정보 노출

대부분의 웹 응용프로그램에서 쿠키는 메모리에 상주하며, 브라우저가 종료되면 사라진다.
브라우저 세션에 관계없이 지속적으로 쿠키 값을 저장하도록 설정도 가능하며, 이 경우 정보는 디스크에 기록되고 다음 브라우저 세션 시작 시 메모리에 로드 된다.
개인정보, 인증 정보 등이 이와 같은 영속적인 쿠키(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("쿠키 발급 완료");
});

13. 주석문 안에 포함된 시스템 주요정보

편의를 위해서 주석문에 패스워드를 적어둔 경우 소프트웨어가 완성된 후에는 그것을 제거하는 것이 매우 어렵게 된다. 만약 공격자가 소스코드에 접근할 수 있다면 시스템에 손쉽게 침입할 수 있다.

📌 안전한 코딩기법
주석에는 아이디, 패스워드 등 보안과 관련된 내용을 기입하지 않는다.

❌ 안전하지 않은 코드 예시
중요정보를 주석문 안에 작성 후 지우지 않는 경우 정보 노출되는 예시

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);
});

14. 솔트 없이 일방향 해쉬 함수 사용

패스워드와 같이 중요정보를 솔트없이 일방향 해쉬 함수를 사용해 저장한다면 공격자는 미리 계산된 레인보우 테이블을 이용해 해쉬값을 알아낼 수 있다.
중요정보를 저장할 경우 가변 길이 데이터를 고정된 크기의 해쉬값으로 변환해주는 일방향 해쉬 함수를 이용해 저장할 수 있다.
❗ 솔트(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 };
}

15. 무결성 검사없는 코드 다운로드

원격지에 위치한 소스코드 또는 실행 파일을 무결성 검사 없이 다운로드 후 실행하면 호스트 서버의 변조, 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("파일이 손상되었습니다.")
  }
});

16. 반복된 인증시도 제한 기능 부재

일정 시간 내에 여러 번의 인증 시도 시 계정 잠금 또는 추가 인증 방법 등의 충분한 조치가 수행되지 않는 경우 공격자는 인증에 성공할 가능성이 높은 계정과 패스워드들을 무차별 대입하여 로그인 성공 및 권한 획득이 가능하다.

📌 안전한 코딩기법

  • 악의적인 반복된 인증 시도 자체를 클라이언트측에서 제한하거나 서버와의 연계를 통해 어느 정도 차단 효과를 기대할 수 있지만, 궁극적으로는 안전한 서버측 코드가 구현되어야만 무작위 대입 공격에 대응 가능
  • 최대 인증시도 횟수를 적절한 횟수로 제한
  • 설정된 인증 실패 횟수를 초과할 경우 계정을 잠금 하거나 추가적인 인증 과정을 거쳐서 시스템에 접근이 가능하도록 설정
  • 인증 시도 횟수를 제한하는 방법 외에 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 사용 등에 대한 보안사항을 다룹니다. 따로 정리하지 않을 예정이라 추가로 궁금하신 부분은 본 가이드를 참고하시면 좋을 것 같습니다!

출처 : 과학기술정보통신부와 한국인터넷진흥원의 '자바스크립트 시큐어코딩 가이드'

profile
FE ✨

0개의 댓글