JWT에 대하여

fart man·2026년 1월 23일
// ==================================
// 간단한 jwt 설명 데모
// ==================================
const {
  createHash,
  createHmac,
} = require('node:crypto');


const secret = 'Trixie_is_the_best_pony'

function getHeader(algorithm) {
    return `{
    "alg" : "${algorithm}",
    "typ" : "JWT"
}`;
}

const payload = `{
    "message" : "hi"
}`

function fuckedJWT() {
    let signature = secret + getHeader('none') + '.' + payload;
    return getHeader('none') + '.' + payload + '.' + signature;
}

function badInvalidJWT() {
    let signature = secret + toUrlBase64(getHeader('none')) + '.' + toUrlBase64(payload);
    return toUrlBase64(getHeader('none')) + '.' + toUrlBase64(payload) + '.' + toUrlBase64(signature);
}

// jwt sign을 아예 안할거며는 서명도 하지 말라고 합니다.
function badJWT() {
    return toUrlBase64(getHeader('none')) + '.' + toUrlBase64(payload) + '.'
}

// sha256를 JWT에서 쓰지는 않습니다.
// sha256가 왜 않좋은지는 좀있다 설명하겠습니다.
function slightlyBetterButInvalidJWT() {
    let signature = secret + toUrlBase64(getHeader('S256')) + '.' + toUrlBase64(payload);

    signature = createHash('sha256')
                    .update(signature)
                    .digest('base64url')

    return toUrlBase64(getHeader('S256')) + '.' + toUrlBase64(payload) + '.' + signature;
}

function goodJWT() {
    let signature = toUrlBase64(getHeader('HS256')) + '.' + toUrlBase64(payload);

    signature = createHmac('sha256', secret)
                    .update(signature)
                    .digest('base64url')

    return toUrlBase64(getHeader('HS256')) + '.' + toUrlBase64(payload) + '.' + signature;
}

console.log("\nfuckedJWT: ")
printOneLine(fuckedJWT());
console.log("\nbadInvalidJWT: ")
printOneLine(badInvalidJWT());
console.log("\nbadJWT: ")
printOneLine(badJWT());
console.log("\nslightlyBetterButInvalidJWT: ")
printOneLine(slightlyBetterButInvalidJWT());
console.log("\ngoodJWT: ")
printOneLine(goodJWT());

// ====================
// length attack
// ====================

// 왜 sha256를 안쓰는지 설명하겠습니다.
// 저희 sha256을 쓸때 간단하게 secret 과 message를 갖다 붙여
// signature를 만들고 있습니다.
//
// 하지만 이런 문제가 발생합니다.
console.log()
console.log('============ CONCAT ============')
console.log('should not be ' 
    + (sha256String('secret' + '69') == sha256String('secret6' + '9')))

// 하지만 더 심각한 문제는 length attack에 있습니다.
//
// 여기다 sha256를 구현할 시간도 실력도 없으므로 매우 간단한 예시를
// 들겠습니다.

// 1 PADING
//
// sha256 는 512 bit chunk 단위로 데이터를 처리합니다.
//
// 하지만 세상의 모든 데이터가 512로 나누어 떨어지진 않을거기 때문에
// 데이터가 오면 먼저 데이터가 512bit가 되도록 0을 마구 집어넣습니다.
// 
// 그 담에, 데이터가 512로 나누어 떨어지기 직전에 64bit 형태로 원본
// 데이터의 크기를 저장합니다.
//
//
// 2 PROCESSING
//
// sha256은 32bit 숫자 8개를 가지고 있습니다.
//
// 그리고 각 512bit chunk가 숫자 8개를 좀 복작한 수학 연산을 이용해
// 바꿉니다.
//
// 그리고 이렇게 바뀐 숫자 8개를 이어 붙여서 돌려줍니다.
//
// 이 수학 연산은 복잡해서 어떤 데이터가 이 숫자 8개를 생성해내는지 알아내는 것은
// 해커 입장에서 매우 어렵습니다.
//
// 하지만 그렇다고 너무 복잡하진 않아서 데이터를 검증한다고 서버가 버벅대지는 않습니다.
//
// 이샹 sha256 야매 설명이었습니다.

// 하지만 sha256은 큰문제가 있습니다!
//
// 예를들어 sha256(secret + message) 의 경우
//
// 해커 아저씨 아줌마가
//
// 1. secret의 길이
// 2. message
// 3. sha256(secret + message)의 결과를 알경우
//
// !!! secret이 뭔지 모르는 상태에서 !!!
// sha256(secret + message + attack)이 무슨 값을 내뿜을지 예측할수 있습니다!
//
// 원리는 간단합니다.
//
// secret + message를 sha256이 padding 하기 전에 해커가 미리 padding하는 것입니다!
//
// 이때 secret은 몰라도 됩니다!
//
// 해커 입장에서 중요한건 sha256이 가지고 있는 8개의 숫자이고 이는 할때마다 똑같은 값에서 시작하거든요.
//
// 그리고 이미 해커는 8개의 숫자가 뭔지 알고 있으니까 해커는 padding한 secret + message는 넘기고
// 시작 8개읜 숫자르 이미 알고 있는 숫자고 설정한뒤 자신의 attack만 process하면 됩니다!
//
// 이를 시연 위해 매우 간단한 sha 알고리즘을 짰습니다. 
//
// 진짜 sha256과 다른 점은 512bit 단위가 아닌
// 8bit 단위로 chunk를 나눈다는 것하고
//
// processing 이 매애애애애우 단순하다는 점과
//
// 숫자 8개 대신에 숫자 하나만 쓴단든 점입니다.
{
    console.log()
    console.log('============ LENGTH ATTACK ============')

    function pad(data) {
        data.push(1)

        while (data.length % 8 != 0) {
            data.push(0);
        }
    }

    function pushLength(data, length) {
        for (let i=7; i>=0; i--) {
            data.push((length & 1 << i) > 0 ? 1 : 0)
        }
    }

    function mySha(msg) {
        msg = msg.slice();
        // padding
        let msgLength = msg.length;

        pad(msg)

        pushLength(msg, msgLength)

        // process
        let hash = 0;

        for (let i=0; i<msg.length; i++) {
            hash += msg[i];
        }

        console.log(`padded msg     : ${msg}`)
        console.log(`hash           : ${hash}`)

        return hash;
    }

    let secret = [1, 1]
    let msg = [1, 0, 1]

    mySha(secret.concat(msg));

    function myShaAttack(
        secretKeyLength,
        msg, 
        attackMsg, 
        knownHash,
    ) {
        msg = msg.slice();
        attackMsg = attackMsg.slice();

        // 일단 알고 있는 secretKeyLength 만큼 0을 앞에 넣습니다
        for (let i=0; i<secretKeyLength; i++) {
            msg.unshift(0)
        }

        // 전과 같이 padding을 합니다
        let msgLength = msg.length;

        pad(msg)

        pushLength(msg, msgLength)

        let glue = msg.slice(msgLength)

        // 공격 시작!

        // process를 시작할 배열을 저장합니다
        let processStart = msg.length;

        // 저희의 공격 msg를 넣습니다.
        msg = msg.concat(attackMsg);

        // 길이를 새로 설정 합니다.
        msgLength = msg.length;

        pad(msg)

        pushLength(msg, msgLength)

        // process
        // 0 부터가 아닌 우리가 알고 있는 hash로 부터 시작합니다
        let hash = knownHash;

        for (let i=processStart; i<msg.length; i++) {
            hash += msg[i];
        }

        console.log(`padded msg     : ${msg}`)
        console.log(`hash           : ${hash}`)

        return {
            hash : hash,
            glue: glue
        };
    }

    let attack = [1, 1, 1]

    const attackInfo = myShaAttack(
        2,
        msg,
        attack,
        7
    );

    attack = [msg, attackInfo.glue, attack].flat()

    console.log(attackInfo.hash == mySha(secret.concat(attack)))
}

// 이러한 문제 때문에 jwt 쓸때 sha256을 가지고
//
// sha256(secret+message) 이런식으로 signature를 만들면 안됩니다.
//
// 그런 문제를 해결할려고 hmacsha256이라는 거를 사람들이 만들어 놨으니까
// 그거 쓰면 됩니다.

// ====================
// UTIL
// ====================
function printOneLine(toPrint) {
    console.log(toPrint.replaceAll('\n', '\\n'))
}

// base64 encoding은 binary data를 문자로 변환하는 함수
// base64 url encoding은 base64 랑 거의 똑같은데 
// 웹상에서 쓰기 좋으라고 좀 바꾼거.
function toUrlBase64(str) {
    const bytes = new TextEncoder().encode(str);
    let binString = '';
    for (const b of bytes) {
        binString += String.fromCodePoint(b)
    }
    const base64Encoded = btoa(binString);


    // url에서 쓰기에는 안전하지 못할수 있으니
    // + 는 -
    // / 는 _
    // 그리고 base64 encoding은 문자열 길이가 4의 배수로 만들려고 = 붙임
    // 이거 제거
    return base64Encoded
        .replaceAll('+', '-')
        .replaceAll('/', '_')
        .replaceAll('=', '');
}

function fromUrlBase64(str) {
    str = str
        .replaceAll('-', '+')
        .replaceAll('_', '/');

    while(str.length % 4 != 0) {
        str += '='
    }

    const binString = atob(str);
    const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0));
    return new TextDecoder().decode(bytes);
}

function sha256String(str) {
    return createHash('sha256').update(str).digest('base64');
}

0개의 댓글