- 2
- Hanam09
- 조회 수 3252
안녕하세요. Hanam09라고 하옵니다.
오늘은 Python3을 이용해 네이버 로그인을 해보는 코드를 만들어 봅시다.
뭐 로그인하는거 별거 없다고 생각하실 수도 있습니다만, 네이버 로그인은 다른 사이트하고는 다른 독특한 방식으로 로그인 합니다.
<그림 1> 크고 아름다운 네이버의 로그인 제출 값
여기에서 보니 우리가 작성한 ID와 패스워드는 눈 씻고 찾아봐도 보이지 않습니다. 근데 네이버는 어떻게 우리가 입력한 아이디와 비밀번호를 알 수 있을까요? 바로 네이버가 우리의 아이디와 패스워드를 암호화 하고 서버에서 개인키를 통해 풀기 때문입니다.
<그림 2> Naver의 common200807v2.js 中 encryptIdPwSplit 함수
이렇게 가지고 있는 RSA 공개키값 e와 N으로 공개키를 설정하고 그 키로 사용자의 아이디와 비밀번호, 세션키를 암호화하게 됩니다.
이는 다음 카카오 또한 비슷한 방식을 취하고 있습니다. 그럼 이걸가지고 로그인 하는것이 끝이냐? 아닙니다. 네이버는 이거 말고도 또 다른 특징이 있습니다. 이걸 말하기 전에, 왜 네이버의 로그인 페이지는 HTTPS가 적용되어 있는데도 불구하고 이렇게 귀찮게 이용자의 ID와 패스워드를 암호화할까요?
제가 아는 한, 그 이유는, 5년 전만해도 암호화 비용을 이유로 HTTPS를 적용하지 않았기 때문입니다. 여기서 암호화 비용이라 함은 TLS 인증서가 아닌 (RSA 또는 ECC)+(AES 또는 3DES) 알고리즘을 이용해 데이터를 암호화하는데 들어가는 서버의 부담을 말합니다. 지금은 웹 브라우저 벤더사들과(특히 구글) Let's Encrypt 덕분에 HTTPS 열풍(?)이 불고 무로 DV 인증서 발급 방법을 많은 사람들이 알게되면서 현대의 웹 사이트들 대부분이 서버에 TLS인증서를 적용하여 암호화 통신을 지원하고 있지만, 그때 당시만 해도 대한민국의 많은 사이트들이 TLS인증서를 적용하지 않았습니다. 지금은 다행히도 이게 NAVER 등을 비롯한 각종 대한민국 포털사이트들에게까지도 (사실은 웹 브라우저 벤더 회사의 압박때문에..) 퍼져서 이제는 HTTPS를 적용하지 않은 사이트를 보기 힘들정도 입니다. (개인적으로 정말 좋은 변화 였다고 생각합니다.) 아무튼, 저 과거의 소스를 이용한 RSA의 암호화 방식이 지금까지 이어져 온것 입니다.
본론으로 돌아와서, js 소스코드를 알았으니 이걸 그대로 파이썬으로 옮겨서 정보를 암호화하고 그대로 로그인 하면 되겠네요...?
아쉽지만, 아닙니다. 아까 네이버 로그인 방식에 또 다른 특징이 있다고 했잖아요? 그게 뭐냐면, 자동 로그인 방지를 위한 네이버의 야심작(?), bvsd 라는 사람 행동 탐지기(?)입니다. 아까 보여드린 첫번째 사진의 name 값 중에 bvsd라는것이 있었죠? 저게 뭘 의미하는지는 모르겠지만, 저게 약간이라도 이상하면 캡챠가 우리를 반겨줍니다.
결론부터 말하자면, 이 bvsd의 값은 로그인 과정에서 있었던 마우스 움직임, 터치, browser fingerprint, 기기 움직임 등을 기록하여 서버에 같이 보내서 이게 사람이 로그인 하는건지 판단합니다. 미심쩍으면 캡챠로 다시 체크하고요. 한번 봅시다.
<그림 3> bvsd1.3.4.min.js 의 bvsd_data 값 생성 부분
딱 봐도 보이죠? 각 부분의 역할은 자명합니다. 이것이 바로 bvsd_data 부분인데요. 이 값이 압축 및 인코딩되어 다음과 같이 bvsd 필드의 encData 부분의 값으로 변합니다.
{"uuid":"2cdaa639-2385-4eb8-86c0-6976b63d8cba-0","encData":"N4IghiBcIEwMYBMxgGwGYCcBaGaAcArFgCwCmARnlninAAxYoYDsK56Cec5YWdIAGhDkoIAIwA6NBOKCQcKADMwAGwDOpIQigBtUAEtR+7UIiQdAXSEjIoM5etQ6AXyELoAC1IqVAeznant5+cqRKqhpCiuHqpK4GogAOAO5y9lbCUHa6GTYubqIBhUJhkAAuAE4Arpog0ZDKsc4Zpdm24FAEzBLMjpAoBD0FkMQSGK6Z7WZdQ5MDs+6j4xP1bWsdkBhbfVsYw7sTNm27O9vyUAeHWRvHZzYn55tbV1MXd297j5cTAObX9iA6AAfZhA4joIFoNCyITAtBAsTEIFYMRyYFIxFA-iwoEYeFiGAYZEwGBorEI+EkMkwBFiZFoMkoBHArAMnFMolUnF0pk4MnwvD0skEXFCnEYJnMEU4VE4zB0FDA0FiAiknHAjVkpEorFa5F07GAoGCnWG4EE-W67l0DFWo0ilmyo0qhF281Mg1kwWO6m2s1AxVoOms5iCxF4fmWp3A70Ir1AmnRhGsYm0pMGt2Q-WJjB6lHwkl5sT43M47ViYEwf3lumFsvIokwJPa5Wlo3a-Ew9upglF3F64EoAcBvV0rvo10ESN0qc4kU08dgyejsHToGz7t0tn2hOr+tblfbie14dHrOV4XktXdi9yq+XuF95uW68TnWvpc659f-l4Og05hKTPDVMyDQkWFdLACGA8lq3pBM83xRC43rHAENQgtkI-bUkWw+DFw7dDu1ZIi30TLDIxIs9pXhM9CLwtC6PghiaQYulv1Y4dv3Yp9eNQnj63NLi+zgoT+JQm8JInMTiIEo14Rk4EHSkpdFITREYDwZgiUVZF0AojCVKA0j11TFiVPLIyezzcjUM4ucoz4+SzMvH87zQ8yPxokzKXs4jMLsizmJsoKPNcvy4RchySI-XzQoiz8kOi2znLQ78AuIlK3wysiTPo1ycs-BLtQSuLSVyKBEWIYY0BtLQLjQXoQFKGACDoAg8E0qInAmAArSriAIIQPFEZhSAIMQ8FIMACAoDqBjERQUAQYgwEUAhFBgMhFDwRq8CdQxXmgABZXwAC99B8MAAHpBjoAACAAKAB1fQADsEF8ZI1HugA5AAVe6KwkOgAG57tet6UGIcGAA9oYASnugBBRJEhUUhnooABpfQyluxqpBQJ7sYACX+46ABkBHulR9AAa1Ie6AHFSDgenfCRgBhDwKl8ABbUhrowSQ6BkYhmBgCQCWIe6AGU1rACp9AJ7o0CHPpmreuR3C2+rIDwEpKu6kYhD+cwxBQP8BArNqMhGi2rboG26ArDJDqg4hnZAfrIDEIR6eNkAVCDs6g-50Qqje+m3q+7WhG16BIbQa9-GgKOY7jtIAR5vnBfugAFAARAAxQuVCqH53sgSAC98CoyjAcgMfuovfDgKpBbesp7pL+v+bAMoa7ANG6bgQf9F8N7rthrAfl8XwfgxrA4F5gXSCwRIEEUAA-LfoiEXP18L0v7oANX0UhklICoa+H0f9HHspJ+n-e9+3uRfongA3Jmubp0g3c76QBHujR+E8p4zywG9MAcAVA7wEKAseEDp6z0SDAuBO8QAZESKIOAbNWrqwwOQUg-56AoBgIoEWtVlB4AwKQ8gCAMDwDkAAR1EBQUgCB0CS3IK1DAa00CkAGH+RUK1mAIDQMgMACARBCAqKIZmC8l5MwAJJvTgBIJ66iyjeARjvZGv1maUwAKLaO7t4GmOjvCPQAEpIwAKqkyLizCoI8PCPx+hQh6Rd9AVDZmUNARcxBiHut-NQAB9AgESHqJEidE52rcgkhJwCgCQVZpZ0DFoBLaCM5BqBiJEEAZRCm1CqKUoQ38KkgFSA0CItRYa6Gdo0SILTSAZAAJ66BAMjZWqg5C9P0Koe6AAhFQsDA5CEGcMr+FQ+apEPqofQ5BlZyC5mAfmKyhlrI2VssA91jqDxGofAWj95ZgDej9Y6cs1lTzUL4cZBSTlVGVjfW5LzL4VD+lfOQrN65VwgEIUm3hf7P3HnIVR-NEiwJKUISmVQ4DGH2VzO5DywhwoRUi85lz7oOLeo-XwCB0UgGOo-Pm9zFA9wVjiuWN99AHxJXLFmvgygeIUEIa5hclGssfnITl1Kfq0uVgy-ldKGUF1UBPWO91KbvRZR0xIxLaXzyZgXZW3c5DKt8EzOWcBlaJFhSALVTMHGqM1aQFVuLVEyv0D8DwhrjVWvlqQfmyyHkmCNRa7VTq5YdM2Q8uQ-0wAeAFoCkA-19CCyeeGyNpAfq-SvvdWxob47hv8eQBFXge7XLkGfG+SAYFyEhj8BA70fgFIsM4ZwQA"}
<코드 1> form의 bvsd 필드
정말 딱 봐도 난해합니다(...) 이런 값은 여기에서 이렇게 처리되어 나옵니다
<그림 4> LZString.compressToEncodedURIComponent 함수를 이용한 압축과정
어쨌든, 이러한 동작 감시 시스템이 있는데 우리는 어떻게 네이버에게 프로그램을 사람처럼 속일 수 있을까요?
정말 많은 시도를 해봤는데, 답은 구글 크롬의 자동완성과 탭키를 활용해서 거의 "있을법한" 상황을 만들어서 로그인 과정에서 최대한 사람의 손(마우스와 키보드 사용)을 안 닿게하면 됩니다. 왜요? 구글 자동완성과 탭키 많이 쓰잖아요?
한번 구현해 봅시다.
준비물: python3
http 요청을 위한 requests 모듈
정보 암호화를 위한 rsa 모듈
bvsd data 인코딩에 필요한 lzstring 모듈
bvsd에 사용될 uuid 모듈
bvsd 등 json 정보를 stringify 하게 만들어줄 json 모듈
마지막으로, 요즘 새로추가된 dynamic_key를 가져오기 위한 파싱에 필요한 beautifulsoup4 모듈
일단, 최근에 네이버가 로그인 방식을 한번 더 바꿨더군요.. 이 때문에 PC 웹상에서는 처음에 제공된 dynamic_key를 통해 rsa 공개키를 가져와야 해서 이 글에서는 모바일 페이지를 통해 key를 가져올겁니다. (모바일 페이지에서는 rsa 키와 dynamic_key사 한번에 제공되요.)
차근 차근히 소스코드를 보시면 어느정도 이해가 되실겁니다.
from rsa import encrypt, PublicKey from requests import Session from lzstring import LZString from uuid import uuid4 from json import dumps from bs4 import BeautifulSoup class Naver(Session): def __init__(self) -> None: super().__init__() self.headers = { "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" } self.base = "https://nid.naver.com/nidlogin.login" self.bvsd = { "uuid": None, "encData": None } self.default = { "localechange": "", "dynamicKey": None, "encpw": None, "svctype": "262144", "smart_LEVEL": "-1", "bvsd": None, "encnm": None, "locale": "en_US", "url": "https:///m.naver.com/aside/", "nvlong": "on", "appSchemeView": "true", "id": "", "pw": "" } self.state_footprint = { "a": None, # uuidWithCaptchaSequence "b": "1.3.4", # bvsdVersion "c": False, # deviceTouchable "d": [{ # keyboardLogs "a": ["0,d,TAB,9"], # keyStrokeLog "b": { # inputIntervalLog "a": None, # valueTimelineList "b": 0 # timelineListIndex }, "c": "", # initialValue "d": None, # CompleteValue "e": False, # secureMode "f": False, # hideValueMode "i": "id" # inputFieldId }, { "a": ["0,d,TAB,"], # keyStrokeLog "b": { # inputIntervalLog "a": ["0,"], # valueTimelineList "b": 0 # timelineListIndex }, "c": "", # initialValue "d": "", # CompleteValue "e": True, # secureMode "f": False, # hideValueMode "i": "pw" # inputFieldId }], "e": { # deviceOrientation "a": { # firstOrientation "a": 53.9, # Alpha "b": 65.7, # Beta "c": 4.8 # Gamma }, "b": { # currentOrientation "a": 53.9, # Alpha "b": 65.7, # Beta "c": 4.8 # Gamma } }, "f": { # deviceMotion "a": { # first "a": { # firstAcceleration "a": 999, # X "b": 999, # Y "c": 999 # Z }, "b": { # firstAccelerationIncludingGravity "a": 999, # X "b": 999, # Y "c": 999 # Z } }, "b": { # current "a": { # currentAcceleration "a": 999, # X "b": 999, # Y "c": 999 # Z }, "b": { # currentAccelerationIncludingGravity "a": 999, # X "b": 999, # Y "c": 999 # Z } } }, "g": { # mouseMove "a": [], # mouseActiveLogs "b": 0, # timelineListIndex "c": 0, # pageXDifference "d": 0, # pageYDifference "e": -1, # totalInterval "f": 0 # errorCount }, "h": "7e518ea5eb58651f6d4af5f24ef83781", # fingerprintHash "i": { # browserFingerprintComponents "a": self.headers["user-agent"], "b": "en", "c": 24, "d": 8, "e": 1, "f": 4, "g": [1680, 1050], "h": [1680, 1010], "i": -540, "j": 1, "k": 1, "l": 1, "z": 1, "m": "unknown", "n": "Win32", "o": "unknown", "aa": ["Chrome PDF Plugin::Portable Document Format::application/x-google-chrome-pdf~pdf", "Chrome PDF Viewer::::application/pdf~pdf", "Native Client::::application/x-nacl~,application/x-pnacl~"], "p": "bb84491f8f0ca552e32aa5b90b350297", "q": "ebed6372b259af3e658060d47d3aaadb", "r": "Google Inc. (Intel)~ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11-26.20.100.7324)", "s": False, "t": False, "u": False, "v": False, "w": False, "x": [0, False, False], "y": ["Arial", "Arial Black", "Arial Narrow", "Calibri", "Cambria", "Cambria Math", "Comic Sans MS", "Consolas", "Courier", "Courier New", "Georgia", "Helvetica", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "MS Gothic", "MS PGothic", "MS Sans Serif", "MS Serif", "Palatino Linotype", "Segoe Print", "Segoe Script", "Segoe UI", "Segoe UI Light", "Segoe UI Semibold", "Segoe UI Symbol", "Tahoma", "Times", "Times New Roman", "Trebuchet MS", "Verdana", "Wingdings"] }, "j": 134 # fingerprintProcessingDuration } def form(self): return self.default def new_bvsd_data(self): return self.bvsd def new_bvsd_footprint(self): return self.state_footprint def encode_bvsd_data(bvsd_data): return LZString.compressToEncodedURIComponent(dumps(bvsd_data)) def fill_bvsd(self, bvsd, id): bvsd_uuid = str(uuid4())+"-0" # 로그인 할때 사용되는 bvsd uuid 생성 bvsd["uuid"] = bvsd_uuid bvsd_data = self.new_bvsd_footprint() bvsd_data["a"] = bvsd_uuid bvsd_data["d"][0]["b"]["a"] = ["0,{}".format(id)] bvsd_data["d"][0]["d"] = id bvsd["encData"] = Naver.encode_bvsd_data(bvsd_data) return bvsd def get_finalize(response_text: str): if response_text.find("location") > -1: return { "url": response_text.split('("')[1].split('")')[0], "result": True } else: return { "result": False } def login(self, NAVER_ID: str, NAVER_PW: str) -> bool: def download_keys(): DOM = BeautifulSoup(self.get(self.base, params={ "svctype": self.default["svctype"] # 모바일 페이지 }, headers=self.headers).text, 'html.parser') Keys = DOM.find('input', {'id': "session_keys"}).attrs['value'] session_key, key_name, e, N = Keys.split(",") return { "dynamic_key": DOM.find('input', {'id': 'dynamicKey'}).attrs['value'], "session_key": session_key, "public_key": PublicKey(int(e, 16), int(N, 16)), "key_name": key_name } def encrypt_with_public_key(ID: str, PW: str, Keys: dict) -> str: encode_login_info = ''.join([chr(len(s)) + s for s in [Keys['session_key'], ID, PW]]).encode() return encrypt(encode_login_info, Keys["public_key"]).hex() # 암호화하고 hex 값으로, e와 N값 순서는 네이버가 구라친겁니다. Keys = download_keys() # 서버의 공개키와 키 세션들을 가져온다. encrypted_info = encrypt_with_public_key(NAVER_ID, NAVER_PW, Keys) # 공개키를 이용하여 세션키와 함께 로그인 정보를 암호화한다. form = self.form() # 새로운 로그인 폼 생성 bvsd = self.fill_bvsd(self.new_bvsd_data(), NAVER_ID) # 새로운 bvsd 폼 생성 후 내용 채우기s form["dynamicKey"] = Keys["dynamic_key"] form["encpw"] = encrypted_info form["encnm"] = Keys["key_name"] form["bvsd"] = dumps(bvsd) result = Naver.get_finalize(self.post(self.base, data=form, headers=self.headers).text) if result["result"]: self.get(result["url"], headers=self.headers) return True else: return False if __name__ == "__main__": # TEST FIELD Browser = Naver() if Browser.login("", ""): print("성공!") else: print("실패!")
너무 졸려서 여기까지만 글을 쓰겠습니다..
모두 좋은 주말 보내세요.
추천은 일찍 눌렀는데 요새 정신없이 바쁘다보니 댓글이 늦었네요~
이 게시판에 제가 올린 허접한 팁보다는 100배 훌륭한 스크립트를 올려주셔서 진심으로 감사드립니다! ^-^