해당 글은 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); });
당연히 실패합니다. 브라우저가 아니라 소켓 자체가 끊거는 거니 제대로 된 통신이 불가했습니다. 하지만, 우리는 확실히 핸드셰이크 요청을 보냈죠.
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 ; } 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코드를 아래처럼 보여줍니다.
마무리
오우야 오우야 참을 수 없습니다. 하지만 회사이기 때문에 참아야 합니다. 회사에서 월루해도 저는 오직 코딩과 공부와 공부를 정리하기만 했기 때문에 아주 틀린짓은 아닐지도 모르겠습니다.
재밌게 봐주셨다면 감사합니다.