만약 글의 제목을 보고 흥미가 돋아 들어오셨다면, 당신은 어엿한 신사(또는 숙녀)입니다.
사실 이 행동은 2020년에 이루어졌으며, 그 때 어딘가에다가 글을 썼지만 잃어버려 이곳 벨로그에 쓰기 적당한 주제기에 새로 다시쓰기로 하였습니다.
오늘의 목표는 www.(그)hub.com 에 요청을 보내고 응답받은 내용을 파싱하는 것 까지 해보겠습니다.
2019년 그 시절 우리는 정부로부터 HTTPS 패킷을 감청한다는 비극적인 소식을 들었습니다.
하지만 저는 신사답게 어떠한 시련이 저를 덮치더라도 이겨낸 뒤에 행복의 시간, 승리의 기쁨을 느껴야 합니다.
SNI 스니핑 방법을 이용해서 유해사이트를 판단, 차단한다고 하였는데요. 적을 알고 나를 안다면 백전 백승이듯, 이 기능이 무엇인지 잠깐 보도록 합시다.
OSI 7계층을 보면 HTTP 밑에 TCP 프로토콜이 존재합니다. 물론, 정보의 바다에서 한 번쯤은 봤을만한 얘기죠. HTTP는 TCP 통신으로 진행됩니다. HTTPS는 TLS구요.
여기서 TLS Handshake에 대해 자세히 다루지 않습니다. 설명하려면 매우 귀찮으니 이거라도 링크를 드리겠습니다.
아무튼 TLS 핸드셰이크 진행중, 클라이언트가 서버에게 보내는 Client Hello
메시지의 내용을 봅시다.
글을 쓰는 현재 회사이므로 일하는 척 해야되기 때문에 브라우저가 아닌 프로그램으로 통신해보도록 하겠습니다.
import https from 'https';
https.request({
hostname: '(그)hub.com',
port: 443,
path: '/',
method: 'GET',
}, (res) => {
console.log(res);
});
/*
Error: read ECONNRESET
at TLSWrap.onStreamRead (node:internal/stream_base_commons:217:20) {
errno: -104,
code: 'ECONNRESET',
syscall: 'read'
}
*/
당연히 실패합니다. 브라우저가 아니라 소켓 자체가 끊거는 거니 제대로 된 통신이 불가했습니다. 하지만, 우리는 확실히 핸드셰이크 요청을 보냈죠.
Client Hello
메시지의 server_name
이라는 옵션이 있습니다. 이 주소를 통해 차단할지 말지 정하고 있으니 이 항목을 없애버린 채 통신한다면? 우회가 될 것입니다.
이제부터 그 내용을 NodeJS로 진행해 보겠습니다.
일단 우린 URL 주소를 알고 있지만, 직접 TCP 소켓을 연결할 IP주소를 가져와야 합니다.
그러기 위해서 사용하는 게 DNS Lookup이지만, 이에 대한 자세한 기술적 설명은 다른 글이 더 좋으니 여기서 설명하지 않습니다.
NodeJS에서는 dns 모듈을 제공합니다.
import { promises as dns, ADDRCONFIG, V4MAPPED } from 'dns';
(async () => {
const host = 'www.(그)hub.com';
const { address } = await dns.lookup(host, {
family: 4,
hints: ADDRCONFIG | V4MAPPED,
});
console.log('ip address', address);
})();
위 코드를 실행해 보면 한 개의 IP 주소를 얻을 수 있습니다.
이제 이 주소에다가 나의 누나들, 아니. 나의 행복을 내놓으라고 요구하면 됩니다.
이것도 세상 많은곳에 좋은 글이 퍼져있으니 자세히 설명하진 않습니다. 추천하는 글은 이것입니다.
위 주소에서 하나 인용해보겠습니다.
HTTP 메시지는 ASCII로 인코딩된 텍스트 정보이며 여러 줄로 되어 있습니다.
즉, HTTP 통신은 TCP로 소켓을 연결하고 사람이 이해할 수 있는 문장을 규격에 맞춰 보내준다면 쉽게 HTTP 통신이 가능하다는 뜻입니다.
예를 들어, 우리가 보낼 HTTP 메시지는 이렇게 되겠네요.
GET / HTTP/1.1 \n
Host: (그)hub.com \n
\n
이 메시지를 아까 알아온 IP 주소에다가 보내봅시다.
노드에서 아주 친절하게 net 모듈, 그리고 tls 모듈을 지원합니다. 그 모듈을 사용해 연결하고 메시지를 보내봅니다.
import { promises as dns, ADDRCONFIG, V4MAPPED } from 'dns';
import tls from 'tls';
function whenReceive(socket) {
return new Promise((resolve) => {
let data = '';
socket.on('data', (chunk) => {
data += chunk;
});
socket.once('end', () => {
socket.end();
resolve(data);
});
});
}
(async () => {
const host = 'www.(그)hub.com';
const { address } = await dns.lookup(host, {
family: 4,
hints: ADDRCONFIG | V4MAPPED,
});
const socket = tls.connect({
host: address,
port: 443,
rejectUnauthorized: false,
}, () => {
whenReceive(socket)
.then((data) => {
console.log('recieve', data);
});
socket.write([
'GET / HTTP/1.1',
`Host: ${host}`,
'\n',
].join('\n'));
});
})();
하지만 응답받은 값을 콘솔에 찍은 걸 보면 원하는 대로 통신이 안 됩니다.
recieve HTTP/1.1 302 Found
server: openresty
date: Tue, 22 Mar 2022 08:59:06 GMT
content-type: text/html; charset=UTF-8
transfer-encoding: chunked
cache-control: no-cache, no-store, must-revalidate
pragma: no-cache
ph-redirect: 1020
location: 내 주소
x-frame-options: SAMEORIGIN
vary: User-Agent
rating: RTA-5042-1996-1400-1577-RTA
x-request-id: 62398FDA-42FE722901BB8E3F-D1846
strict-transport-security: max-age=63072000; includeSubDomains; preload
대충 서버님이 User-Agent를 담아서 보내라는 뜻입니다.
어라? 아까는 아예 연결조차 되지 않았는데 지금은 HTTP 메시지를 응답받은 상태입니다. 통신이 성공적으로 되었다는 뜻이죠.
좋습니다. HTTP Message를 작성하는 배열에 User-Agent 헤더를 담아봅니다.
socket.write([
'GET / HTTP/1.1',
`Host: ${host}`,
'User-Agent: HereAgent', // 이거
'\n',
].join('\n'));
그러면 이상한 HTML 파일들을 우다다다 받게 됩니다. 곧 있으면 저는 누나들을 볼 수 있습니다. 아니, 이미 봤다고 해도 됩니다. 하지만 마지막 목표인 파싱까지만 해볼게요.
import { promises as dns, ADDRCONFIG, V4MAPPED } from 'dns';
import tls from 'tls';
import cheerio from 'cheerio';
function whenReceive(socket): Promise<string> {
return new Promise((resolve) => {
let data = '';
let first = true;
socket.on('data', (chunk) => {
if ( first ) {
// 첫 청크는 무조건 헤더이므로 거름
first = false;
return;
}
// 이후부터 Body 즉 HTML만 받음
data += chunk;
});
socket.once('end', () => {
socket.end();
resolve(data);
});
});
}
(async () => {
const host = 'www.(그)hub.com';
const { address } = await dns.lookup(host, {
family: 4,
hints: ADDRCONFIG | V4MAPPED,
});
const socket = tls.connect({
host: address,
port: 443,
rejectUnauthorized: false,
}, () => {
whenReceive(socket)
.then((data) => {
let $ = cheerio.load(data.trim());
let $pcList = $('#mostRecentVideosSection').find('li.pcVideoListItem');
$pcList.each((idx, vid) => {
const te = $(vid).find('span.title a');
const user = $(vid).find('div.usernameWrap a');
const duration = $(vid).find('var.duration').text().trim();
const url = "https://" + host + te.attr('href')?.trim();
const title = te.text().trim()?.replace(/\n/g, '');
const userName = user.text().trim();
const userHref = "https://" + host + user.attr('href')?.trim();
console.log('');
console.log(`Title : ${title}`);
console.log(`Duration : ${duration}`);
console.log(`Video Url : ${url}`);
console.log(`User Name : ${userName}`);
console.log(`User Url : ${userHref}`);
console.log('');
});
});
socket.write([
'GET / HTTP/1.1',
`Host: ${host}`,
'User-Agent: HereAgent',
'\n',
].join('\n'));
});
})();
위코드를 실행하게 되면 응답받은 HTML코드를 아래처럼 보여줍니다.
오우야 오우야 참을 수 없습니다. 하지만 회사이기 때문에 참아야 합니다. 회사에서 월루해도 저는 오직 코딩과 공부와 공부를 정리하기만 했기 때문에 아주 틀린짓은 아닐지도 모르겠습니다.
마지막으로, 이 글을 보고 공부가 되었다면 아래 글을 참고하여 직접적인 실습이 가능합니다.
(물론 성인사이트에 우회요청 하는 건 아닙니다.)
재밌게 봐주셨다면 감사합니다.
아주....유익하네요 👍