위 사진을 보면 fast-json-patch 모듈의 applyPatch() 메서드를 이용해서 a라는 메서드를 패치 시켜주는데, 이때 내부 오퍼레이션에 의해서 Prototype Pollution 취약점이 발생한다. 인자로는 2개의 값을 보내주는 것을 볼 수 있다. 첫 번째 인자는 패치할 주체가 들어가고, 두 번째 인자로는 패치의 적용할 데이터를 Json 형식으로 보내는 것을 볼 수 있고, 이 두 번째 인자에서 두 번째 값인 path 키의 대해서 Prototype Pollution 취약점이 발생한다.
functionapplyPatch(document, patch, validateOperation, mutateDocument, banPrototypeModifications) { if (mutateDocument === void0) { mutateDocument = true; } if (banPrototypeModifications === void0) { banPrototypeModifications = true; } if (validateOperation) { if (!Array.isArray(patch)) { thrownewexports.JsonPatchError('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY'); } } if (!mutateDocument) { document = helpers_js_1._deepClone(document); } var results = newArray(patch.length); for (var i = 0, length_1 = patch.length; i < length_1; i++) { // we don't need to pass mutateDocument argument because if it was true, we already deep cloned the object, we'll just pass `true` results[i] = applyOperation(document, patch[i], validateOperation, true, banPrototypeModifications, i); document = results[i].newDocument; // in case root was replaced } results.newDocument = document; return results; }
applyPatch() 메서드는 core.js 파일에 정의가 되어 있다. 코드를 보면 중간 부분에 patch 배열을 for 문으로 돌려가며 하나씩 applyOperation() 메서드의 두 번째 인자로 넘겨주는 것을 볼 수 있고, 첫 번째 인자는 당연 우리가 보내준 패치 될 주체이다. 여 기서 patch는 우리가 위에서 applyPatch() 함수에 넘겨준 두 번째 인자값이 된다.
applyOperation() 메서드를 호출하는 것을 보면 우리가 보낸 패치 파일을 다중으로 적용하는 것이 아닌 단일로 하나씩 적용하는 것으로 보인다.
functionapplyOperation(document, operation, validateOperation, mutateDocument, banPrototypeModifications, index) { if (validateOperation === void0) { validateOperation = false; } if (mutateDocument === void0) { mutateDocument = true; } if (banPrototypeModifications === void0) { banPrototypeModifications = true; } if (index === void0) { index = 0; } if (validateOperation) { if (typeof validateOperation == 'function') { validateOperation(operation, 0, document, operation.path); } else { validator(operation, 0); } } /* ROOT OPERATIONS */ if (operation.path === "") { var returnValue = { newDocument: document }; if (operation.op === 'add') { ... else { if (!mutateDocument) { document = helpers_js_1._deepClone(document); } var path = operation.path || ""; var keys = path.split('/'); var obj = document; var t = 1; //skip empty element - http://jsperf.com/to-shift-or-not-to-shift var len = keys.length; var existingPathFragment = undefined; var key = void0; var validateFunction = void0; if (typeof validateOperation == 'function') { validateFunction = validateOperation; } else { validateFunction = validator; } while (true) { key = keys[t]; if (banPrototypeModifications && key == '__proto__') { thrownewTypeError('JSON-Patch: modifying `__proto__` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README'); } if (validateOperation) { if (existingPathFragment === undefined) { if (obj[key] === undefined) { existingPathFragment = keys.slice(0, t).join('/'); } elseif (t == len - 1) { existingPathFragment = operation.path; } if (existingPathFragment !== undefined) { validateFunction(operation, 0, document, existingPathFragment); } } } t++; if (Array.isArray(obj)) { if (key === '-') { key = obj.length; } else { if (validateOperation && !helpers_js_1.isInteger(key)) { thrownewexports.JsonPatchError("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index", "OPERATION_PATH_ILLEGAL_ARRAY_INDEX", index, operation, document); } // only parse key when it's an integer for `arr.prop` to work elseif (helpers_js_1.isInteger(key)) { key = ~~key; } } if (t >= len) { if (validateOperation && operation.op === "add" && key > obj.length) { thrownewexports.JsonPatchError("The specified index MUST NOT be greater than the number of elements in the array", "OPERATION_VALUE_OUT_OF_BOUNDS", index, operation, document); } var returnValue = arrOps[operation.op].call(operation, obj, key, document); // Apply patch if (returnValue.test === false) { thrownewexports.JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document); } return returnValue; } } else { if (key && key.indexOf('~') != -1) { key = helpers_js_1.unescapePathComponent(key); } if (t >= len) { var returnValue = objOps[operation.op].call(operation, obj, key, document); // Apply patch if (returnValue.test === false) { thrownewexports.JsonPatchError("Test operation failed", 'TEST_OPERATION_FAILED', index, operation, document); } return returnValue; } } obj = obj[key]; } } }
applyOperation() 메서드를 보면 중간에 슬래쉬를 이용해서 path 값을 스플릿하는 것을 볼 수 있다. 우리는 path의 값으로 "/constructor/prototype/polluted"와 같이 주었기 때문에 keys의 값은 ['constructor', 'prototype', 'polluted']가 된다.
이렇게 값을 스플릿 해준 후에 keys.length만큼 와일문을 돌린다. 이때 keys 값으로 __proto__가 들어오면 JSON-Patch: modifying '__proto__' prop is banned for security reasons, if this was on purpose, please set 'banPrototypeModifications' flag false and pass it to this function. More info in fast-json-patch READM와 같은 구문을 출력하고 끝내는 것을 볼 수 있다. 아마도 Prototype Pollution을 방지한 것 같다.
하지만 __proto__ 프로퍼티와 constructor.prototype 프로퍼티는 동일하기 때문에 이를 이용해서 Prototype Pollution 공격을 할 수 있다.
위 세 가지지 조건을 모두 위배할 경우에는 위 코드가 실행이 된다. 일단 우리는 patch의 값으로 ~가 들어간 곳이 없기 때문에 3번 모두 else문이 실행이 될 것 이다. else 문을 보면 objOps를 이용해서 패치를 하는 것을 볼 수 있고, 인자로는 연산자, 패치 될 주체, 패치할 값, 패치 될 주체를 넘겨주고 있는 것을 볼 수 있다. 여기서 우리는 연산자를 넘겨줄 때, replace를 넘겨주었다.
우리는 위처럼 path 값을 전송을 했다. 그러니 내부 오퍼레이션에 의해서 와일문을 돌고, 제일 마지막 번째에서는 마치 a['constructor']['prototype']['polluted']와 같이 작동을 하게 되어 Prototype Pollution이 발생한다.
Exploit (Web) pwn2win CTF 2021 [152 pts]
이번 주말에는 pwn2win CTF 2021이라는 대회가 열렸는데 해당 대회에서 웹 문제 중에 솔브가 제일 많은 문제가 Prototype Pollution to RCE in ejs를 이용한 문제였다. 하지만 삽질 실수를 해서 Prototype Pollution 공격을 하지 못 했고, 대회가 끝나고 롸업을 본 후에 npm 공식 사이트를 보니 거의 중간에 답이 있어서 매우 아쉬운 문제다.
exports.renderFile = function () { var args = Array.prototype.slice.call(arguments); var filename = args.shift(); var cb; var opts = {filename: filename}; var data; var viewOpts;
// Do we have a callback? if (typeofarguments[arguments.length - 1] == 'function') { cb = args.pop(); } // Do we have data/opts? if (args.length) { // Should always have data obj data = args.shift(); // Normal passed opts (data obj + opts obj) if (args.length) { // Use shallowCopy so we don't pollute passed in opts obj with new vals utils.shallowCopy(opts, args.pop()); } // Special casing for Express (settings + opts-in-data) else { // Express 3 and 4 if (data.settings) { // Pull a few things from known locations if (data.settings.views) { opts.views = data.settings.views; } if (data.settings['view cache']) { opts.cache = true; } // Undocumented after Express 2, but still usable, esp. for // items that are unsafe to be passed along with data, like `root` viewOpts = data.settings['view options']; if (viewOpts) { utils.shallowCopy(opts, viewOpts); } } // Express 2 and lower, values set in app.locals, or people who just // want to pass options in their data. NOTE: These values will override // anything previously set in settings or settings['view options'] utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS); } opts.filename = filename; } else { data = {}; }
returntryHandleCache(opts, data, cb); };
renderFile() 메서드 하단을 보면 tryHandleCache() 메서드를 호출하는 것을 볼 수 있다.
functionhandleCache(options, template) { var func; var filename = options.filename; var hasTemplate = arguments.length > 1;
if (options.cache) { if (!filename) { thrownewError('cache option requires a filename'); } func = exports.cache.get(filename); if (func) { return func; } if (!hasTemplate) { template = fileLoader(filename).toString().replace(_BOM, ''); } } elseif (!hasTemplate) { // istanbul ignore if: should not happen at all if (!filename) { thrownewError('Internal EJS error: no file name or template ' + 'provided'); } template = fileLoader(filename).toString().replace(_BOM, ''); } func = exports.compile(template, options); if (options.cache) { exports.cache.set(filename, func); } return func; }
handleCache() 메서드에서는 export.compile() 메서드를 또 호출한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
exports.compile = functioncompile(template, opts) { var templ;
// v1 compat // 'scope' is 'context' // FIXME: Remove this in a future version if (opts && opts.scope) { if (!scopeOptionWarned){ console.warn('`scope` option is deprecated and will be removed in EJS 3'); scopeOptionWarned = true; } if (!opts.context) { opts.context = opts.scope; } delete opts.scope; } templ = newTemplate(template, opts); return templ.compile(); };
export.compile() 메서드에서는 Tempalte()이라는 객체를 생성자를 이용해서 생성하고, 생성한 템플릿 객체로 compile() 메서드를 실행하는 것을 볼 수 있다. ejs 모듈 내부는 처음보는데 아마 이 Template() 객체가 웹 프론트 단에 출력되는 부분인 거 같다.
1 2 3 4 5 6 7 8 9 10 11 12
compile: function () { /** @type {string} */ ... if (!this.source) { this.generateSource(); prepended += ' var __output = "";\n' + ' function __append(s) { if (s !== undefined && s !== null) __output += s }\n'; if (opts.outputFunctionName) { prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n'; } ...
compile() 메서드를 확인 해보면 outputFunctionName을 이용해서 문자열을 만드는 것을 볼 수 있다. 즉, 우리는 applyPatch()에서 발생하는 Prototype Pollution 취약점을 이용해서 compile() 메서드에서 사용되는 outputFunctionName 값을 잘 조작해 문자열을 맞춰 RCE를 발생시켜야 한다.