[Node.js] javascript-obfuscator를 이용한, JS 파일 난독화

동재·2024년 10월 22일
0
post-thumbnail

📌개요

웹 브라우저에서 개발자모드에 들어가면 해당 화면이 어떤 방식으로 구성되어 있는지 어떤 함수를 호출하는지 다 확인할 수 있습니다.

이번에 확장앱을 개발하면서 확장앱도 브라우저 기반으로 동작하기 때문에 background.js 즉 비즈니스 로직이 모두 노출된다는 걸 알았는데요. 따라서 비즈니스 로직을 감추기 위한 방법을 찾아보았습니다.

사실 .js 에 무슨 짓을 해도 사용자가 까보려고 하면 까볼 수 있다지만, 최소한의 노력은 하기 위해 로직을 파악하기 어렵게 만드는 법을 찾다 난독화를 진행하게 되었어요.

다행히도 관련 라이브러리가 있었고 (요즘 시대 당연한가?🤔), 해당 라이브러리를 활용하는 방법을 정리해두려고 합니다.

요구 조건

제가 난독화를 진행하면서 원했던 건 딱 한가지 "사용자가 비즈니스로직을 파악하기 어려울 것"이었습니다.

사실 코드 최소화만 진행해도 인간이라면, 개발자일지라도 코드를 파악하기가 굉장히 어렵다는걸 알지만, 요즘은 대 인공지능의 시대인만큼... 왠만한 코드는 ChatGpt에 넣고 돌리면 ChatGpt가 파악하기 쉽게 변환해주더라구요.

따라서 ChatGpt가 파악할 수 없게 하기 위해 몇가지 조건을 생각해야했습니다.

1. 모든 변수 및 함수명을 의도를 파악하기 어려운 값으로 변환할 것
2. 코드 순서를 섞을 것
3. 의미 없는 코드를 삽입할 것

결론적으로 이 3가지를 만족했을 때 chatGpt도 해당 코드가 어떤 동작을 원하는지 알기 어려워했고 정리도 못해줬습니다만,

그 와중에 열심히 알려주더라구요.. 몇 년뒤면 정리해줄지도 모르겠습니다..🥹

라이브러리 선택

난독화를 위한 라이브러리로 저는 javascript-obfuscator를 선택했습니다.

처음에는 난독화에 대한 지식이 부족하여 ChatGPT가 추천한 terser를 사용해 진행했지만, 요구 사항 중 2번과 3번을 terser가 지원하지 않는다는 것을 알게 되었습니다.

그래서 다른 대안을 찾던 중, javascript-obfuscator가 모든 요구 조건을 충족한다는 것을 확인하고 해당 라이브러리를 사용하기로 결정했습니다.


🛠️구현

해당 진행상황을 따라하기 전에 nodenpm이 설치되어 있어야 합니다.

0. 작업을 진행할 Directory 생성하기

난독화 작업을 진행할 Directory를 생성합니다. 저는 obfuscator라는 이름으로 디렉토리를 만들었습니다. 이 디렉토리 안에 난독화와 관련된 파일들을 저장할 예정입니다.

cd ..
mkdir obfuscator

최상위 디렉토리로 이동한 후, obfuscator 디렉토리를 생성합니다.


1. javascript-obfuscator 설치하기

먼저 javascript-obfuscator를 프로젝트에 설치해야 합니다. npm을 통해 간단히 설치할 수 있습니다:

npm install --save-dev javascript-obfuscator

2. 난독화 코드(minify.js) 작성하기

난독화를 위해 파일을 읽고, 난독화한 후 결과를 파일로 저장하는 코드를 작성합니다. fspath 모듈을 활용하여 파일 시스템을 처리하고, javascript-obfuscator를 이용해 코드를 난독화합니다.

저는 minify.js라고 이름지었습니다.

const fs = require('fs');
const path = require('path');
const obfuscator = require('javascript-obfuscator');

// 난독화할 파일 경로 설정
const inputFilePath = path.join(__dirname, 'input.js');
const outputFilePath = path.join(__dirname, 'output.js');

// 코드 난독화 함수
function obfuscateCode(filePath) {
    const code = fs.readFileSync(filePath, 'utf8'); // 파일 읽기
    const obfuscatedCode = obfuscator.obfuscate(code, {
        compact: true, // 코드 압축
        controlFlowFlattening: true, // 제어 흐름 플래튼화
        controlFlowFlatteningThreshold: 1, // 제어 흐름 플래튼화 적용 확률
        deadCodeInjection: true, // 죽은 코드 삽입
        deadCodeInjectionThreshold: 1, // 죽은 코드 삽입 확률
        stringArray: true, // 문자열을 배열로 변환
        stringArrayEncoding: ['base64'], // Base64로 문자열 인코딩
        stringArrayThreshold: 1, // 모든 문자열을 배열로 변환
        disableConsoleOutput: true, // console.log 등 출력 제거
        renameGlobals: true, // 전역 변수 이름 변경
        identifierNamesGenerator: 'mangled' // 변수 및 함수명을 짧고 의미 없는 이름으로 변경
    }).getObfuscatedCode();

    fs.writeFileSync(outputFilePath, obfuscatedCode); // 난독화된 코드 파일로 저장
    console.log(`Obfuscated code saved to: ${outputFilePath}`);
}

obfuscateCode(inputFilePath);

inputFilePathoutputFilePath는 난독화할 파일과 결과를 저장할 파일의 경로를 설정하는 부분입니다. 각자의 프로젝트 구조에 맞게 이 경로를 수정해야 합니다.

위 예시에서는 파일과 디렉토리 위치를 임의로 설정했습니다.


3. javascript-obfuscator 옵션 설명

난독화에 사용할 수 있는 다양한 옵션들이 있습니다. 각 옵션은 난독화의 강도를 조절할 수 있습니다.

compact: true : 코드 내 공백과 주석을 제거하여 파일 크기를 줄입니다.
controlFlowFlattening: true: 제어 흐름 플래튼화를 활성화합니다. 이는 코드의 실행 흐름을 더 복잡하게 만들어 코드 분석을 어렵게 합니다.
controlFlowFlatteningThreshold: 1: 제어 흐름 플래튼화를 모든 코드에 적용합니다. 이 값을 0.5로 설정하면 코드의 절반만 제어 흐름 플래튼화가 적용됩니다.
deadCodeInjection: true: 의미 없는 "죽은 코드"를 삽입하여 코드 분석을 더 복잡하게 만듭니다.
deadCodeInjectionThreshold: 1: 죽은 코드를 100% 삽입합니다. 이 값은 0과 1 사이의 값을 설정하여 적용할 비율을 조절할 수 있습니다.
stringArray: true: 코드에 있는 모든 문자열을 배열로 변환하여 인덱스로 접근하게 합니다.
stringArrayEncoding: ['base64']: 문자열을 Base64로 인코딩하여 가독성을 더 떨어뜨립니다.
disableConsoleOutput: true: console.log()와 같은 디버깅 출력을 제거합니다.
renameGlobals: true: 전역 변수의 이름을 바꿔서 코드가 어떤 역할을 하는지 분석하기 어렵게 만듭니다.
identifierNamesGenerator: 'mangled': 변수와 함수명을 짧고 의미 없는 이름으로 변경합니다. 이는 코드를 해독하는 과정을 더 복잡하게 만듭니다.


4. 코드 난독화 실행

위 코드를 작성한 후, input.js 파일에 있는 JavaScript 코드를 난독화하려면 다음 명령을 실행합니다:

node minify.js

이 명령을 실행하면 output.js 파일이 생성되고, 난독화된 코드가 저장됩니다.


5. 난독화된 코드 확인(예시 코드)

var _0x23b8 = ['log', 'hello world'];
(function(_0x5689ca, _0x23b8bc) {
    var _0x437c93 = function(_0x1c741a) {
        while (--_0x1c741a) {
            _0x5689ca['push'](_0x5689ca['shift']());
        }
    };
    _0x437c93(++_0x23b8bc);
}(_0x23b8, 0x1f4));
var _0x437c = function(_0x5689ca, _0x23b8bc) {
    _0x5689ca = _0x5689ca - 0x0;
    var _0x437c93 = _0x23b8[_0x5689ca];
    return _0x437c93;
};
console[_0x437c('0x0')](_0x437c('0x1'));

🛠️ 적용

이번 프로젝트에서 javascript-obfuscator를 사용해 난독화를 적용한 방법과 그 과정에서 발생한 이슈를 공유합니다.

프로젝트의 주요 디렉토리 구조는 다음과 같습니다:

📂 root
├── 📂 build
│ ├── 📂 module
│ │ └── ... (module 내 여러 파일들)
│ ├── 📂 page
│ │ └── ... (page 내 여러 파일들)
│ ├── background.js
│ └── manifest.json


├── 📂 obfuscator
│ └── minify.js
├── 📂 deploy
│ └── ... (배포를 위한 파일들)

  • build 디렉토리: 개발을 위한 디렉토리로, 비즈니스 로직을 도메인 별로 모듈화하여 관리합니다. background.js는 이 모듈들을 불러와서 동작합니다.
  • obfuscator 디렉토리: 난독화 작업을 위한 파일들이 들어있는 디렉토리입니다.
  • deploy 디렉토리: 난독화된 파일들을 배포하기 위한 디렉토리입니다.

🔥난독화 중 발생한 이슈

다음과 같은 간단한 코드를 예시로 들어보겠습니다:

import { getTest } from './module/test.js';;


chrome.alarms.create('updateTest', {delayInMinutes: 360, periodInMinutes: 360});
chrome.alarms.onAlarm.addListener(async (alarm) => {
    if (alarm.name === 'updateTest') {
        console.log("test 동작");
        }
    }
});

해당 코드의 난독화를 진행했을 때, import 문이 난독화될 경우 파일을 찾지 못하는 문제가 발생했습니다. 하지만 이를 난독화하지 않으면 어떤 파일이 어떤 역할을 하는지 쉽게 파악할 수 있어, 비즈니스 로직이 노출될 가능성이 있었습니다.

이를 해결하기 위해 import 할 파일명을 난수화 한 이후 import 문을 해당 파일명으로 변경하는 방법을 생각해 보았습니다. 하지만 여러 가지 문제로 인해 이 방법은 실패했습니다.

해결 방법

여러 시행착오 끝에, 최종적으로 배포 시 모든 코드를 background.js에 통합하여 난독화하는 방법으로 전환했습니다. 모든 코드를 background.js에 통합하면, import 문과 관련된 이슈를 더 이상 신경 쓸 필요가 없었기 때문입니다.

이를 구현하기 위해 minify.js 파일에 다음과 같은 코드를 추가했습니다:

const inputDirPath = path.join(__dirname, '..', 'build');

// import 및 export 문, use strict 제거 함수
function removeImportsAndExports(code) {
    return code
        .replace(/^(import\s.*?;|export\s)/gm, '')  // import/export 문 제거
        .replace(/^\s*['"]use strict['"];\s*/gm, '')  // 'use strict'; 제거
        .trim();
}


// 코드 합치기 및 난독화 함수
async function processFiles(inputDirPath) {
    try {
        let combinedCode = '';

        const moduleDirPath = path.join(inputDirPath, 'module');
        const moduleFiles = fs.readdirSync(moduleDirPath).filter(file => file.endsWith('.js'));

        // module 디렉토리 내 파일 합치기
        for (const file of moduleFiles) {
            const filePath = path.join(moduleDirPath, file);
            const code = fs.readFileSync(filePath, 'utf8');
            combinedCode += removeImportsAndExports(code) + '\n';
        }

        // background.js 파일 내용 추가
        const backgroundFilePath = path.join(inputDirPath, 'background.js');
        const backgroundCode = fs.readFileSync(backgroundFilePath, 'utf8');
        combinedCode += removeImportsAndExports(backgroundCode) + '\n';

        // 난독화된 코드 생성 및 저장
        const obfuscatedCode = obfuscateCode(combinedCode);
        fs.writeFileSync(outputFilePath, obfuscatedCode, 'utf8');
        console.log(`Obfuscated code saved to: ${outputFilePath}`);
    } catch (error) {
        console.error('Error processing files:', error);
    }

이 코드는 module 디렉토리의 모든 JS 파일을 가져와 import 문을 제거하고, background.js와 결합한 뒤 난독화합니다.

결과적으로, 개발 단계에서는 모듈화를 유지하고, 배포 단계에서는 모든 코드를 하나로 합쳐 난독화함으로써 클라이언트가 코드를 파악하기 어렵게 만들 수 있었습니다.


⚙️Deploy 최적화

난독화를 완료한 후, deploy 디렉토리에 build 파일 전체를 복사하고, 마지막으로 난독화 된 background.js만 업데이트한 후 배포를 진행했습니다.

저는 이 과정을 이해하고 있었기 때문에 문제되지 않았지만, 다른 사람에게 설명하기에는 복잡하고 개발자답지 않다고 느껴져 이 과정을 자동화하기로 했습니다.

이를 위해 minify.js에 자동화 코드를 추가했습니다:


// 디렉토리 및 파일 복사 함수
function copyDirectory(src, dest) {
    if (!fs.existsSync(dest)) {
        fs.mkdirSync(dest, { recursive: true });
    }

    const entries = fs.readdirSync(src);
    for (const entry of entries) {
        const srcPath = path.join(src, entry);
        const destPath = path.join(dest, entry);

        if (fs.statSync(srcPath).isDirectory()) {
            copyDirectory(srcPath, destPath);  // 디렉토리일 경우 재귀적으로 복사
        } else {
            fs.copyFileSync(srcPath, destPath);  // 파일일 경우 복사
        }
    }
}

// 코드 합치기 및 난독화 함수
async function processFiles(inputDirPath) {
    try {
        // ../build에서 module 디렉토리 제외한 모든 파일 및 디렉토리 복사
        const allFiles = fs.readdirSync(inputDirPath);
        for (const file of allFiles) {
            const filePath = path.join(inputDirPath, file);
            const isModuleDir = fs.statSync(filePath).isDirectory() && file === 'module';

            // module 디렉토리와 background.js 파일 건너뛰기
            if (isModuleDir || file === 'background.js') continue;

            const destFilePath = path.join(outputDirPath, file);

            if (fs.statSync(filePath).isDirectory()) {
                copyDirectory(filePath, destFilePath);
                console.log(`Copied directory ${file} to ${outputDirPath}`);
            } else {
                fs.copyFileSync(filePath, destFilePath);
                console.log(`Copied file ${file} to ${outputDirPath}`);
            }
        }
    } catch (error) {
        console.error('Error processing files:', error);
    }
}

위 코드에서 copyDirectory 함수는 깊은 복사를 위해 사용됩니다. 이 함수는 재귀적으로 디렉토리 내 모든 파일을 끝까지 탐색하여 복사할 수 있도록 도와줍니다.

✅최종 파일

모든 요구사항을 만족한 최종 minify.js 파일은 아래와 같습니다.

const fs = require('fs');
const path = require('path');
const obfuscator = require('javascript-obfuscator');

// 경로 설정
const inputDirPath = path.join(__dirname, '..', 'build');
const outputFilePath = path.join(__dirname, '..', 'deploy', 'background.js');
const outputDirPath = path.dirname(outputFilePath);

// output 디렉토리 생성
if (!fs.existsSync(outputDirPath)) {
    fs.mkdirSync(outputDirPath, { recursive: true });
}

// import 및 export 문, use strict 제거 함수
function removeImportsAndExports(code) {
    return code
        .replace(/^(import\s.*?;|export\s)/gm, '')  // import/export 문 제거
        .replace(/^\s*['"]use strict['"];\s*/gm, '')  // 'use strict'; 제거
        .trim();
}

// 코드 난독화 함수
function obfuscateCode(code) {
    return obfuscator.obfuscate(code, {
        compact: true,
        controlFlowFlattening: true,
        controlFlowFlatteningThreshold: 1,
        deadCodeInjection: true,
        deadCodeInjectionThreshold: 1,
        stringArray: true,
        stringArrayEncoding: ['base64'],
        stringArrayThreshold: 1,
        disableConsoleOutput: false, // console 출력 제거 여부
        renameGlobals: true,
        identifierNamesGenerator: 'mangled'
    }).getObfuscatedCode();
}

// 디렉토리 및 파일 복사 함수
function copyDirectory(src, dest) {
    if (!fs.existsSync(dest)) {
        fs.mkdirSync(dest, { recursive: true });
    }

    const entries = fs.readdirSync(src);
    for (const entry of entries) {
        const srcPath = path.join(src, entry);
        const destPath = path.join(dest, entry);

        if (fs.statSync(srcPath).isDirectory()) {
            copyDirectory(srcPath, destPath);  // 디렉토리일 경우 재귀적으로 복사
        } else {
            fs.copyFileSync(srcPath, destPath);  // 파일일 경우 복사
        }
    }
}

// 코드 합치기 및 난독화 함수
async function processFiles(inputDirPath) {
    try {
        let combinedCode = '';

        const moduleDirPath = path.join(inputDirPath, 'module');
        const moduleFiles = fs.readdirSync(moduleDirPath).filter(file => file.endsWith('.js'));

        // module 디렉토리 내 파일 합치기
        for (const file of moduleFiles) {
            const filePath = path.join(moduleDirPath, file);
            const code = fs.readFileSync(filePath, 'utf8');
            combinedCode += removeImportsAndExports(code) + '\n';
        }

        // background.js 파일 내용 추가
        const backgroundFilePath = path.join(inputDirPath, 'background.js');
        const backgroundCode = fs.readFileSync(backgroundFilePath, 'utf8');
        combinedCode += removeImportsAndExports(backgroundCode) + '\n';

        // 난독화된 코드 생성 및 저장
        const obfuscatedCode = obfuscateCode(combinedCode);
        fs.writeFileSync(outputFilePath, obfuscatedCode, 'utf8');
        console.log(`Obfuscated code saved to: ${outputFilePath}`);

        // ../build에서 module 디렉토리 제외한 모든 파일 및 디렉토리 복사
        const allFiles = fs.readdirSync(inputDirPath);
        for (const file of allFiles) {
            const filePath = path.join(inputDirPath, file);
            const isModuleDir = fs.statSync(filePath).isDirectory() && file === 'module';

            // module 디렉토리와 background.js 파일 건너뛰기
            if (isModuleDir || file === 'background.js') continue;

            const destFilePath = path.join(outputDirPath, file);

            if (fs.statSync(filePath).isDirectory()) {
                copyDirectory(filePath, destFilePath);
                console.log(`Copied directory ${file} to ${outputDirPath}`);
            } else {
                fs.copyFileSync(filePath, destFilePath);
                console.log(`Copied file ${file} to ${outputDirPath}`);
            }
        }
    } catch (error) {
        console.error('Error processing files:', error);
    }
}

// 메인 실행 함수
(async () => {
    await processFiles(inputDirPath);
})();

모든 자동화 작업이 그렇듯, 할 때는 귀찮고 번거롭지만, 완료하고 보니 정말 편리하네요. 난독화 작업이 처음이라 시간이 꽤 걸렸지만, 다음 번에는 수월하게 진행할 수 있을 것 같습니다.

결과를 확인해보니, 해당 JS 파일만으로는 비즈니스 로직을 파악하는 것이 쉽지 않아 보입니다.

JS 파일에 중요한 비즈니스 로직이 포함되어 있고 이를 숨겨야 한다면,
이 방법을 추천드립니다!👍

profile
Backend Developer

0개의 댓글