오픈 소스 자바스크립트 난독화 도구인 JavaScript obfuscator
의 동작 원리를 분석해보았다.
꽤나 유명한 도구이기도 하고, 이를 통해 난독화 기법 및 자바스크립트 동작 원리를 더 잘 이해할 수 있을 것이라 기대했다. 온라인 버전(https://obfuscator.io/)도 존재한다.
javascript-obfuscator/javascript-obfuscator
수많은 옵션들을 하나씩 적용해가면서 어떤 코드가 추가되는지 직접 확인해 보는 방식을 사용했다. 보통 간단한 코드를 input 값으로 주었기 때문에 해당 옵션에 대한 모든 경우의 수를 확인하기에는 역부족이었겠지만, 대부분의 옵션에서 핵심적인 동작 원리를 확인할 수 있었다.
코드의 길이가 긴 경우에는 의미적으로 동일한 수도 코드(?)를 올려두고, 원본 output 코드는 아래에 추가해두었다. 자바스크립트 난독화에 관심이 있다면 직접 분석해보기를 강력하게 추천한다.
모든 옵션을 분석하지는 않았고, 내가 생각하기에 공부할만한 가치가 있다고 생각한 옵션만을 정리하였다.
compact
결과를 한 줄로 출력해주는 옵션
// input
function hi() {
console.log("Hello World!");
}
hi();
// output
function hi(){console['log']('Hello\x20World!');}hi();
controlFlowFlattening
소스 코드에서 구조적으로 변화를 주어 프로그램의 이해를 어렵게 만드는 옵션
// input
function hello(){
console.log(1+2)
}
hello();
// ouput
function hello() {
var _0x1c8f8d = {
'jAqnd': function (_0x358ea9, _0x5bfcf7) {
return _0x358ea9 + _0x5bfcf7;
}
};
console['log'](_0x1c8f8d['jAqnd'](0x1, 0x2));
}
hello();
원래 흐름에서 단순 +
연산자에 해당하는 것을 변수, 속성, 함수의 조합으로 바꾸었다.
StringArray
문자열을 삭제하고, 특정 배열 값으로 대체하는 옵션이다.
예를 들어 var m = "Hello World"
을 var m = _0x12c456[0x1]
와 같이 변경한다.
// input
function hi() {
var a = "Hello";
var b = "World!";
console.log(a+b);
}
hi();
// output
var _0x4052 = [
'Hello',
'World!',
'log'
];
var _0x55cd = function (_0x405222, _0x55cd0a) {
_0x405222 = _0x405222 - 0x0;
var _0x483cd2 = _0x4052[_0x405222];
return _0x483cd2;
};
function hi() {
var _0x599393 = _0x55cd;
var _0x483cd2 = _0x599393(0x0);
var _0x14763e = _0x599393(0x1);
console[_0x599393(0x2)](_0x483cd2 + _0x14763e);
}
hi();
문자열과 객체의 속성값(log
)을 포함한 문자열들을 배열로 다 모아서 분석을 어렵게 한다. 문자열 배열을 가리키는 wrapper(_0x55cd
, _0x599393
)를 생성하는 점이 특이하다.
StringArray
의 다양한 옵션들(서브 옵션?)
stringArrayEncoding
옵션을 사용하면 문자열들을 base64나 rc4 를 이용하여 인코딩하고, 디코딩 할 때에 사용되는 특별한 코드를 추가한다. 특별한 코드는 다음 기회에 분석해 보는 걸로(복잡함..)
stringArrayWrappersCount
옵션을 사용하면 각각의 function scope에서 string array를 가리키는 wrapper의 개수를 조정할 수 있다. 단순하게 말하면, 변수가 더 많고 복잡해진다는 것이다.
stringArrayWrappersChainedCalls
옵션을 사용하면 inner function에서 outer function의 wrapper를 연결하여 사용할 수 있다.(이를 chained call이라고 부르는 듯함). chained call을 사용하면, inner function이 중첩된 경우, 문자열을 해독하기 위해 모든 outer function을 보아야 하므로 분석 속도가 지연된다.
stringArrayWrappersType
옵션은 string array wrapper의 타입을 결정할 수 있다. variable
과 function
중에서 선택할 수 있는데, function
이 난독화 정도가 보다 높다고 한다.
stringArrayThreshold
옵션은 문자열이 string array에 포함될 확률을 정하는 데에 사용된다. 0에서 1 사이의 값으로 지정할 수 있으며, default로는 0.8이 사용된다고 한다.
deadCodeInjection
사용되지 않는 코드(deadCode)를 랜덤으로 생성하여 삽입한다.
코드의 길이가 최대 200%까지 늘어날 수 있으며, 이 옵션을 선택하면 stringArray
옵션이 자동으로 선택된다. 실제 테스트에서는 작동하지 않아 홈페이지 코드를 수정해서 가져왔다.
// input
function hi() {
console.log('abc');
}
hi();
// output
var _0x5024 = [
'zaU', // dead string
'log',
'abc'
];
var _0x4502 = function (_0x1254b1, _0x583689) {
_0x1254b1 = _0x1254b1 - 0x0;
var _0x529b49 = _0x5024[_0x1254b1];
return _0x529b49;
};
function hi(){
if (_0x4502('0x0') !== _0x4502('0x0')) { // dead 'if'
console[_0x4502('0x1')](_0x4502('0x0')); // dead code
} else {
console[_0x4502('0x1')](_0x4502('0x2')); // console.log("abc")
}
}
debugProtection
개발자도구에서의 debugger
옵션을 사용하지 못하게 만드는 옵션.
// input
function hello(){
console.log('123');
}
hello();
// output의 수도 코드(?)
function hello(){
function debug(){
debugger; // call debugger only when developer tools is opened!
debug(); // recursive function
};
try{ debug() } catch{}
console.log(123);
}
hello();
output의 원본 코드는 아래와 같다.
function hello() {
var _0x10762d = function () {
var _0x5a32f2 = !![];
return function (_0xb98681, _0x5879d3) {
var _0x457821 = _0x5a32f2 ? function () {
if (_0x5879d3) {
var _0x8bdad9 = _0x5879d3['apply'](_0xb98681, arguments);
_0x5879d3 = null;
return _0x8bdad9;
}
} : function () {
};
_0x5a32f2 = ![];
return _0x457821;
};
}();
(function () {
_0x10762d(this, function () {
var _0x148567 = new RegExp('function\x20*\x5c(\x20*\x5c)');
var _0x33f595 = new RegExp('\x5c+\x5c+\x20*(?:[a-zA-Z_$][0-9a-zA-Z_$]*)', 'i');
var _0x2e7068 = _0x3dd157('init');
if (!_0x148567['test'](_0x2e7068 + 'chain') || !_0x33f595['test'](_0x2e7068 + 'input')) {
_0x2e7068('0');
} else {
_0x3dd157();
}
})();
}());
console['log']('123');
}
hello();
function _0x3dd157(_0x238f2c) {
function _0x472ca3(_0x5e15dd) {
if (typeof _0x5e15dd === 'string') {
return function (_0x5715c2) {
}['constructor']('while\x20(true)\x20{}')['apply']('counter');
} else {
if (('' + _0x5e15dd / _0x5e15dd)['length'] !== 0x1 || _0x5e15dd % 0x14 === 0x0) {
(function () {
return !![];
}['constructor']('debu' + 'gger')['call']('action'));
} else {
(function () {
return ![];
}['constructor']('debu' + 'gger')['apply']('stateObject'));
}
}
_0x472ca3(++_0x5e15dd);
}
try {
if (_0x238f2c) {
return _0x472ca3;
} else {
_0x472ca3(0x0);
}
} catch (_0x57821) {
}
}
핵심은 debugger
라는 자바스크립트 명령이 개발자 도구가 켜진 상태에서만 작동한다는 것이다. 따라서 개발자 도구를 통해 무언가를 하려는 경우에는 debugger
명령이 재귀적으로 호출되면서 사용자를 방해한다.
그러나 개발자 도구가 켜져 있지 않다면, debugger
가 실행되지 않고, 빠르게 함수의 maximum recursion
을 초과하여 catch
를 통해 debug()
를 탈출할 수 있게 된다.
+) 이런 식으로 디버깅을 방해하면, 브라우저에서 디버깅 기능 자체를 꺼버릴 수 있다. Chrome에서는 Sources 탭에서 오른쪽 위에 보면 화살표 모양의 스위치가 있다. 다만, 이걸 하면 분석자도 디버깅 기능을 전혀 사용하지 못하게 된다.
disableConsoleOutput
console.log, console.info, console.error, console.warn, console.debug, console.exception, console.trace 과 같은 콘솔 명령어들을 빈 함수로 바꾸어 디버깅 추적을 어렵게 하는 옵션.
// input
function hello(){
alert(123);
}
hello();
// output의 수도 코드(?)
function hello(){
var foo = function(){}; // empty function
targets = ['log', 'warn', 'info', 'error', 'exception', 'table', 'trace'];
for(var idx=0; idx<targets.length; idx++){
var foo2 = Function.prototype.bind(foo); // empty function create
var tmp = targets[idx];
var target_func = window.console[tmp] || foo2; // 존재 X -> foo2로 대체(에러 방지)
foo2.__proto__ = foo.bind(foo);
foo2.toString = target_func.toString.bind(target_func.toString);
window.console[tmp] = foo2; // disable console function
}
alert(123);
}
hello();
output의 원본 코드는 아래와 같다.
function hello() {
var _0x14f39a = function () {
var _0x1745fd = !![];
return function (_0x3d13bb, _0x49f57f) {
var _0x3e6d02 = _0x1745fd ? function () {
if (_0x49f57f) {
var _0x3b480e = _0x49f57f['apply'](_0x3d13bb, arguments);
_0x49f57f = null;
return _0x3b480e;
}
} : function () {
};
_0x1745fd = ![];
return _0x3e6d02;
};
}();
var _0x24dd22 = _0x14f39a(this, function () {
var _0x4a40da = function () {
var _0x414b61;
try {
_0x414b61 = Function('return\x20(function()\x20' + '{}.constructor(\x22return\x20this\x22)(\x20)' + ');')();
} catch (_0x5ba82b) {
_0x414b61 = window;
}
return _0x414b61;
};
var _0x2d5ce5 = _0x4a40da();
var _0x25b6b4 = _0x2d5ce5['console'] = _0x2d5ce5['console'] || {};
var _0x41bd51 = [
'log',
'warn',
'info',
'error',
'exception',
'table',
'trace'
];
for (var _0x2e01c0 = 0x0; _0x2e01c0 < _0x41bd51['length']; _0x2e01c0++) {
var _0x446e0b = _0x14f39a['constructor']['prototype']['bind'](_0x14f39a);
var _0x19009b = _0x41bd51[_0x2e01c0];
var _0xe5fb17 = _0x25b6b4[_0x19009b] || _0x446e0b;
_0x446e0b['__proto__'] = _0x14f39a['bind'](_0x14f39a);
_0x446e0b['toString'] = _0xe5fb17['toString']['bind'](_0xe5fb17);
_0x25b6b4[_0x19009b] = _0x446e0b;
}
});
_0x24dd22();
console['log']('123');
}
hello();
아래는 좀 이해하기 쉽게 공부하면서 정리한 것.
Function.prototype.bind
는 바인딩 함수를 생성하는 함수이다. 바인딩 함수는 바운드 된 함수를 실행하는 역할을 하는 껍데기 함수이다(?). 첫 번째 인자로는 생성될 함수의 this
역할을 하는 인자를 주는데, 여기에 자기 자신(foo
)를 주어 껍데기 함수를 만든다. 어차피 바운드 함수 자체도 껍데기 함수라 큰 의미는 없다.
__proto__
값도 변경하는데, 큰 의미는 없다. 해당 함수의 toString
함수를 원본 함수(ex - console.log.toString
등)으로 바꿔주는데, 이는 눈속임이 목적인 것으로 추측된다. (가령 왜 안되지? 하고 체크해본다든가 하는 경우를 대비)
domainLock
특정 도메인 혹은 서브도메인에서만 코드가 작동하도록 하는 옵션이다. 이 옵션을 설정하면 특정인이 코드를 단순 복사 붙여넣기하여 사용하는 것을 막을 수 있다.
// input
function hi() {
console.log("Hello World!");
}
hi();
// output의 수도 코드(?)
function hi(){
var domainLock = function(){
var freeze = function(){
for(var i=0; i<1000; i--){} // Freeze!
};
if(!document || !this.document || !document.domain || !document.location.hostname ){
return; // When Domain not exist, No Freeze
}
var allowedDomain = ["example.com", "example2.com", "test.example.com"] // user input
var usr_domain = document.domain || document.location.hostname;
if(allowedDomain.indexOf(usr_domain) !== -1){ // usr_domain is in allowedDomain
return; // When Domain is allowed, No freeze
}else{
data; // When Domain is NOT allowed, raise Error
}
freeze();
}
console.log("Hello World!");
}
hi();
output의 원본 코드는 아래와 같다.
function hi() {
var _0x134b46 = function () {
var _0x2d31e0 = !![];
return function (_0x52f3c3, _0x18b7d9) {
var _0x5ca412 = _0x2d31e0 ? function () {
if (_0x18b7d9) {
var _0xc7553e = _0x18b7d9['apply'](_0x52f3c3, arguments);
_0x18b7d9 = null;
return _0xc7553e;
}
} : function () {
};
_0x2d31e0 = ![];
return _0x5ca412;
};
}();
var _0x3041f1 = _0x134b46(this, function () {
var _0x4f3ffd;
try {
var _0x1ad03c = Function('return\x20(function()\x20' + '{}.constructor(\x22return\x20this\x22)(\x20)' + ');');
_0x4f3ffd = _0x1ad03c();
} catch (_0x2186f2) {
_0x4f3ffd = window;
}
var _0x13588e = function () {
return {
'key': 'item',
'value': 'attribute',
'getAttribute': function () {
for (var _0x22e123 = 0x0; _0x22e123 < 0x3e8; _0x22e123--) {
var _0x59903d = _0x22e123 > 0x0;
switch (_0x59903d) {
case !![]:
return this['item'] + '_' + this['value'] + '_' + _0x22e123;
default:
this['item'] + '_' + this['value'];
}
}
}()
};
};
var _0x554fbb = new RegExp('[KUORFwMDOOSyjhgTjIiQTiHkWrOdFqLQEkIOIXEDQMnByXVwXROfRSTyDdSBMqIDHyObKXEWTEGuWbywVYzWZjGgYjjFRUNLnJB]', 'g');
var _0x4a1e36 = 'KexUORFwamMpDlOe.OScomyjh;geTjxIiQTiHamkpleW2r.coOdFqmLQE;teskIOtI.XEexaDQmpMnlBey.XcoVmwXROfRSTyDdSBMqIDHyObKXEWTEGuWbywVYzWZjGgYjjFRUNLnJB'['replace'](_0x554fbb, '')['split'](';');
var _0x2abc6a;
var _0x45d337;
var _0x104575;
var _0x1b0eeb;
for (var _0x38965b in _0x4f3ffd) {
if (_0x38965b['length'] == 0x8 && _0x38965b['charCodeAt'](0x7) == 0x74 && _0x38965b['charCodeAt'](0x5) == 0x65 && _0x38965b['charCodeAt'](0x3) == 0x75 && _0x38965b['charCodeAt'](0x0) == 0x64) {
_0x2abc6a = _0x38965b;
break;
}
}
for (var _0x5d7857 in _0x4f3ffd[_0x2abc6a]) {
if (_0x5d7857['length'] == 0x6 && _0x5d7857['charCodeAt'](0x5) == 0x6e && _0x5d7857['charCodeAt'](0x0) == 0x64) {
_0x45d337 = _0x5d7857;
break;
}
}
if (!('~' > _0x45d337)) {
for (var _0x5c37af in _0x4f3ffd[_0x2abc6a]) {
if (_0x5c37af['length'] == 0x8 && _0x5c37af['charCodeAt'](0x7) == 0x6e && _0x5c37af['charCodeAt'](0x0) == 0x6c) {
_0x104575 = _0x5c37af;
break;
}
}
for (var _0x325dd8 in _0x4f3ffd[_0x2abc6a][_0x104575]) {
if (_0x325dd8['length'] == 0x8 && _0x325dd8['charCodeAt'](0x7) == 0x65 && _0x325dd8['charCodeAt'](0x0) == 0x68) {
_0x1b0eeb = _0x325dd8;
break;
}
}
}
if (!_0x2abc6a || !_0x4f3ffd[_0x2abc6a]) {
return;
}
var _0x58d4fa = _0x4f3ffd[_0x2abc6a][_0x45d337];
var _0x5a06ee = !!_0x4f3ffd[_0x2abc6a][_0x104575] && _0x4f3ffd[_0x2abc6a][_0x104575][_0x1b0eeb];
var _0x2ba53e = _0x58d4fa || _0x5a06ee;
if (!_0x2ba53e) {
return;
}
var _0x56d245 = ![];
for (var _0x75b920 = 0x0; _0x75b920 < _0x4a1e36['length']; _0x75b920++) {
var _0x45d337 = _0x4a1e36[_0x75b920];
var _0x3fd325 = _0x45d337[0x0] === String['fromCharCode'](0x2e) ? _0x45d337['slice'](0x1) : _0x45d337;
var _0x3096d9 = _0x2ba53e['length'] - _0x3fd325['length'];
var _0x15a7d6 = _0x2ba53e['indexOf'](_0x3fd325, _0x3096d9);
var _0x3ce215 = _0x15a7d6 !== -0x1 && _0x15a7d6 === _0x3096d9;
if (_0x3ce215) {
if (_0x2ba53e['length'] == _0x45d337['length'] || _0x45d337['indexOf']('.') === 0x0) {
_0x56d245 = !![];
}
}
}
if (!_0x56d245) {
data;
} else {
return;
}
_0x13588e();
});
_0x3041f1();
console['log']('Hello\x20World!');
}
hi();
단순한 코드이다. domainLock
함수에서 유저의 현재 도메인과 허용되는 도메인을 비교하여, 같을 경우에는 리턴하고, 다를 경우에는 에러를 발생시킨다.
한편, 이 옵션은 너무나 쉽게 우회가 가능하다. 코드는 길지만 data;
부분을 return;
으로 바꿔주기만 해도 우회가 쉽게 된다.
domainLock is easy to circumvent · Issue #395 · javascript-obfuscator/javascript-obfuscator
forceTransformStrings
문자열을 강제로 변환하게 하는 옵션. 옵션 값으로 정규표현식 패턴을 주어야 한다.
이 옵션은 stringArrayThreshold
에 의해 변경되지 않은 값에만 영향을 준다고 한다. 이 부분은 여러 경우를 시도해 보았으나, 아직 명확히 이해하지는 못하였다.
identifierNamesGenerator
, identifiersPrefix
1) 변수의 이름 생성 방법에 대한 옵션이다. 기본 값은 hexadecimal
인데, _0x14f39a
와 같은 변수 이름이 생성된다. 이외에도 딕셔너리 혹은 알파벳으로 변수의 이름을 생성할 수 있다.
2) 변수 이름에 prefix를 설정하는 옵션이다.
numbersToExpressions
숫자를 표현식으로 변경해주는 옵션이다.
// input
function hi() {
console.log(1234);
}
hi();
// output
function hi() {
console['log'](-0x4 * 0x8ce + 0x1238 + 0x15d2);
}
hi();
renameGlobals
전역변수와 전역함수에 대해서 선언 단계에서의 난독화를 설정하는 옵션이다.
// input
function hi() {
console.log(1234);
}
hi();
// output
function _0x3d4ccf() {
console['log'](0x4d2);
}
_0x3d4ccf();
renameProperties
속성 값에 대해서 이름 변경을 허용하는 옵션이다. 기본적으로 설정되는 DOM 속성과 javascript 속성들이 무시될 수 있어, 유의해서 사용하라고 한다. safe 모드와 unsafe 모드가 존재한다.
// input
(function () {
const foo = {
prop1: 1,
prop2: 2,
calc: function () {
return this.prop1 + this.prop2;
}
};
console.log(foo.calc());
})();
// output
(function () {
const _0x3dba06 = {
'_0x3d0737': 0x1,
'_0x221d61': 0x2,
'_0x5c0802': function () {
return this['_0x3d0737'] + this['_0x221d61'];
}
};
console['log'](_0x3dba06['_0x5c0802']());
}());
selfDefending
output 코드가 코드 변경과 변수 이름 재설정 등에 저항하도록 하는 옵션이다. Javascript beautifier
등을 사용했을 때에, 코드가 작동하지 않게 한다.
// input
function hi() {
console.log("Hello World!");
}
hi();
// output의 수도 코드(?)
function hi(){
var regex_target_func = function(){
var stuff = function(){
var ordered = true; // some stuff
}
var stuff2 = function(){
var ordered = true; // some stuff
}
}
var regex = /^([^ ]+( +[^ ]+)+)+[^ ]}/; // 여기가 핵심!!
regex.test(regex_target_func); // Catastrophic backtracking occured!
console.log("Hello World!");
}
hi();
output의 원본 코드는 아래와 같다.
function hi() {
var _0x59e01b = function() {
var _0xaf9bc = !![];
return function(_0x5b0e15, _0x160356) {
var _0x2c056a = _0xaf9bc ? function() {
if (_0x160356) {
var _0x239051 = _0x160356['apply'](_0x5b0e15, arguments);
_0x160356 = null;
return _0x239051;
}
} : function() {};
_0xaf9bc = ![];
return _0x2c056a;
};
}();
var _0x360881 = _0x59e01b(this, function() {
var _0x1825bc = function() {
var _0x3e40e1 = _0x1825bc['constructor']('return\x20/\x22\x20+\x20this\x20+\x20\x22/')()['constructor']('^([^\x20]+(\x20+[^\x20]+)+)+[^\x20]}');
return !_0x3e40e1['test'](_0x360881);
};
return _0x1825bc();
});
_0x360881();
console['log']('Hello\x20World!');
}
hi();
selfDefending
을 구현한 원리는 Catastrophic backtracking
이다. 이는 과다한 계산을 요구하는 정규표현식을 의도적으로 만들어서 이를 처리하는 javascript engine을 뻗게(?) 만드는 것이다.
Catastrophic backtracking
을 만들어내기 위한 정규표현식은 다음과 같이 구성되어야 한다.
1) 다양한 경우의 수를 가지는 부분
2) 문자열이 만족할 수 없는 조건이 1) 이후에 존재
예를 들면, /^(\d+)*$/
라는 정규표현식을 사용하고 012345678901234567890123456789!
라는 input을 주었다면 1)은 ^(\d+)*
이, 2)는 $
이 될 것이다. 이 부분이 이해되지 않는다면 아래 링크를 참조.
원본 코드에서는 ^([^ ]+( +[^ ]+)+)+[^ ]}/
라는 정규표현식이 쓰였다. 이 정규표현식은 크게 2개로 쪼개서 볼 수 있는데, ([^ ]+( +[^ ]+)+)+
와 [^ ]}
로 나뉜다.
각각 1), 2)로 지칭한다고 하면, 1)은 공백 아닌 것(문자) 와 공백의 조합이므로 다양한 코드의 조각과 매칭될 것이다. 따라서 위에서의 1) 조건과 일치한다.
한편 2)는 공백 아닌 것에 연이어서 }
가 와야 하는 구조이다. 여기가 핵심이라고 볼 수 있는데, JavaScript Beautifier 등을 사용하는 경우에는 보통 공백 문자와 }
가 연이어서 오는 경우가 대부분이기 때문에 '정리된' 함수의 모양이라면 2)는 문자열이 만족할 수 없는 조건에 해당한다.
https://ko.javascript.info/regexp-catastrophic-backtracking
오픈 소스 자바스크립트 난독화 도구를 통해 난독화 기법에 대해 공부하였다.
분석해본 소감은 툴 제작자가 자바스크립트의 다양한 문법, 다양한 기법에 대해 깊이 있는 지식을 가지고 있다는 점이다.
특히 javascript 내부 구조(prototype, constructor 등)에 대해 많이 배울 수 있었고, 자바스크립트를 통해 브라우저를 뻗게 만드는 다양한 코드에 대해서도 볼 수 있었다.
기회가 된다면 자바스크립트 난독화를 자동으로 원본으로 바꿀 수 있는 툴을 제작해보고 싶다.
난독화 원리에 대해 검색하다가 좋은 포스팅을 보고 갑니다.
제가 개발자가 아니라 내용이 신기하기만 하네요. ^ㅅ^