성인사이트 우회 요청 해보기

해당 글은 velog에 작성했던 글을 옮긴 것입니다. 무려 관심을 105개나 받으면서 월간 트렌딩에 올라갔던 글이지만, 불편하신 분들의 민원에 제목을 수정하였음에도 블라인드 처리된 비운의 글입니다. 이곳은 저밖에 없기 때문에 다시 수정하기 전 제목으로 옮깁니다.

안내받은 메일

개요

만약 글의 제목을 보고 흥미가 돋아 들어오셨다면, 당신은 어엿한 신사(또는 숙녀)입니다.

사실 이 행동은 2020년에 이루어졌으며, 그 때 어딘가에다가 글을 썼지만 잃어버려 이곳 벨로그에 쓰기 적당한 주제기에 새로 다시쓰기로 하였습니다.

오늘의 목표는 www.(그)hub.com 에 요청을 보내고 응답받은 내용을 파싱하는 것 까지 해보겠습니다.


SNI 스니핑

2019년 그 시절 우리는 정부로부터 HTTPS 패킷을 감청한다는 비극적인 소식을 들었습니다.

하지만 저는 신사답게 어떠한 시련이 저를 덮치더라도 이겨낸 뒤에 행복의 시간, 승리의 기쁨을 느껴야 합니다.

SNI 스니핑 방법을 이용해서 유해사이트를 판단, 차단한다고 하였는데요. 적을 알고 나를 안다면 백전 백승이듯, 이 기능이 무엇인지 잠깐 보도록 합시다.

HTTPS 통신

OSI 7계층을 보면 HTTP 밑에 TCP 프로토콜이 존재합니다. 물론, 정보의 바다에서 한 번쯤은 봤을만한 얘기죠. HTTP는 TCP 통신으로 진행됩니다. HTTPS는 TLS구요.

TLS 악수

여기서 TLS Handshake에 대해 자세히 다루지 않습니다. 설명하려면 매우 귀찮으니 이거라도 링크를 드리겠습니다.

아무튼 TLS 핸드셰이크 진행중, 클라이언트가 서버에게 보내는 Client Hello 메시지의 내용을 봅시다.

글을 쓰는 현재 회사이므로 일하는 척 해야되기 때문에 브라우저가 아닌 프로그램으로 통신해보도록 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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로 진행해 보겠습니다.

DNS

일단 우린 URL 주소를 알고 있지만, 직접 TCP 소켓을 연결할 IP주소를 가져와야 합니다.

그러기 위해서 사용하는 게 DNS Lookup이지만, 이에 대한 자세한 기술적 설명은 다른 글이 더 좋으니 여기서 설명하지 않습니다.

NodeJS에서는 dns 모듈을 제공합니다.

1
2
3
4
5
6
7
8
9
10
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 Message

이것도 세상 많은곳에 좋은 글이 퍼져있으니 자세히 설명하진 않습니다. 추천하는 글은 이것입니다.

위 주소에서 하나 인용해보겠습니다.

HTTP 메시지는 ASCII로 인코딩된 텍스트 정보이며 여러 줄로 되어 있습니다.

즉, HTTP 통신은 TCP로 소켓을 연결하고 사람이 이해할 수 있는 문장을 규격에 맞춰 보내준다면 쉽게 HTTP 통신이 가능하다는 뜻입니다.

예를 들어, 우리가 보낼 HTTP 메시지는 이렇게 되겠네요.

1
2
3
GET / HTTP/1.1 \n
Host: (그)hub.com \n
\n

이 메시지를 아까 알아온 IP 주소에다가 보내봅시다.

TLS

노드에서 아주 친절하게 net 모듈, 그리고 tls 모듈을 지원합니다. 그 모듈을 사용해 연결하고 메시지를 보내봅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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'));
});
})();

하지만 응답받은 값을 콘솔에 찍은 걸 보면 원하는 대로 통신이 안 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 헤더를 담아봅니다.

1
2
3
4
5
6
socket.write([
'GET / HTTP/1.1',
`Host: ${host}`,
'User-Agent: HereAgent', // 이거
'\n',
].join('\n'));

그러면 이상한 HTML 파일 내용을 우다다다 받게 됩니다. 곧 있으면 저는 누나들을 볼 수 있습니다. 아니, 이미 봤다고 해도 됩니다. 하지만 마지막 목표인 파싱까지만 해볼게요.

HTML 파싱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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코드를 아래처럼 보여줍니다.


마무리

오우야 오우야 참을 수 없습니다. 하지만 회사이기 때문에 참아야 합니다. 회사에서 월루해도 저는 오직 코딩과 공부와 공부를 정리하기만 했기 때문에 아주 틀린짓은 아닐지도 모르겠습니다.

재밌게 봐주셨다면 감사합니다.

작성자: raravel
주소: https://raravel.dev/20220322-1823/
저작권 고지: 이 블로그의 모든 기사는 별도로 명시하지 않는 한 CC BY-NC-SA 4.0 에 따라 라이선스가 부여됩니다.