Chrome 쿠키 암호화 역공학 (AI 에이전트 인증을 위해)
I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source line you already provided) here? Once I have it, I’ll translate it into Korean while preserving all formatting, markdown, and technical terms.
문제 – 로그인 화면
AI 에이전트가 웹사이트와 상호작용하도록 만들었다면, 로그인 화면이라는 장벽에 부딪혔을 것입니다.
에이전트가 해야 할 일은 다음과 같습니다:
- LinkedIn 알림 확인
- 대시보드 스크래핑
- 플랫폼에 게시
하지만 사이트는 인증을 요구합니다. 대부분의 개발자가 가장 먼저 하는 일은 Chrome DevTools를 열어 Cookie 헤더를 복사하고 스크립트에 붙여넣는 것입니다.
이 방법은 약 24시간 동안은 동작합니다. 그 후 세션이 만료되고, 자동화가 새벽 3시에 끊기며, 화난 알림에 깨어나게 됩니다.
이러한 반복에 지쳤습니다. Chrome에는 이미 인증된 세션이 로컬에 저장되어 있습니다. 지금도 LinkedIn에 로그인되어 있죠. 에이전트가 그 세션을 바로 사용할 수 있다면 어떨까요?
사실 가능하지만, Chrome은 이를 쉽게 제공하지 않습니다.
Chrome이 쿠키를 저장하는 위치
Chrome은 쿠키를 SQLite 데이터베이스에 저장합니다. 위치는 운영 체제에 따라 다릅니다:
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/Google/Chrome/Default/Cookies |
| Linux | ~/.config/google-chrome/Default/Cookies |
| Windows | %LOCALAPPDATA%\Google\Chrome\User Data\Default\Network\Cookies |
SQLite 클라이언트를 사용해 파일을 열 수 있습니다. 예:
sqlite3 ~/Library/Application\ Support/Google/Chrome/Default/Cookies
스키마 (관련 열)
CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL,
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL
/* … other columns omitted … */
);
LinkedIn에 대한 예시 쿼리
SELECT name, value, encrypted_value
FROM cookies
WHERE host_key LIKE '%linkedin%';
일반적인 결과:
name: li_at
value: (empty)
encrypted_value: v10[blob of binary garbage]
value 열은 비어 있으며, 흥미로운 내용은 모두 encrypted_value에 들어 있습니다. 해당 블롭은 암호화되어 있습니다.
Chrome이 쿠키를 암호화하는 방법
Chrome은 2014년경부터 쿠키를 암호화하기 시작했으며, 이는 악성코드가 세션을 쉽게 탈취하는 것을 방지하기 위함입니다. 암호화 방식은 플랫폼마다 다릅니다.
| Platform | 마스터 키가 저장되는 위치 | 암호화 알고리즘 |
|---|---|---|
| macOS | macOS 키체인 항목 “Chrome Safe Storage” | AES‑128‑CBC (PBKDF2를 통해 파생된 키) |
| Linux | GNOME Keyring / KWallet via libsecret (fallback: literal peanuts) | AES‑128‑CBC |
| Windows | Data Protection API (DPAPI) – 로그인한 Windows 사용자에 연결됨 | DPAPI (추출 가능한 키 없음) |
세 경우 모두 암호화된 페이로드 앞에 리터럴 문자열 v10을 붙여 버전을 표시합니다.
Python으로 쿠키 복호화 (macOS 예시)
아래는 최소한의 독립 실행형 스크립트로:
- macOS 키체인에서 Chrome의 마스터 키를 가져옵니다.
- PBKDF2를 사용해 AES‑128 키를 파생합니다.
encrypted_value블롭을 복호화합니다.- LinkedIn 쿠키 사전을 반환합니다.
Note: 동일한 전체 흐름이 Linux에서는 (키‑가져오기 단계만 교체) 작동하고, Windows에서는
win32crypt.CryptUnprotectData를 사용합니다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import shutil
import sqlite3
import subprocess
import tempfile
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# ----------------------------------------------------------------------
# 1️⃣ Get Chrome's master key from the macOS Keychain
# ----------------------------------------------------------------------
def get_chrome_key_mac() -> str:
"""
Returns the password stored under the Keychain entry
“Chrome Safe Storage”. This is the raw secret Chrome uses
as the PBKDF2 password.
"""
cmd = [
"security",
"find-generic-password",
"-s", "Chrome Safe Storage",
"-w"
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout.strip()
# ----------------------------------------------------------------------
# 2️⃣ Derive the AES‑128 key from the master secret
# ----------------------------------------------------------------------
def derive_aes_key(chrome_key: str) -> bytes:
"""
Chrome uses PBKDF2‑HMAC‑SHA1 with:
• salt = b"saltysalt"
• iterations = 1003
• key length = 16 bytes (AES‑128)
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA1(),
length=16,
salt=b"saltysalt",
iterations=1003,
backend=default_backend(),
)
return kdf.derive(chrome_key.encode()) # chrome_key is a UTF‑8 string
# ----------------------------------------------------------------------
# 3️⃣ Decrypt a single cookie value
# ----------------------------------------------------------------------
def decrypt_cookie(encrypted_value: bytes, aes_key: bytes) -> str:
"""
Chrome prefixes encrypted blobs with b"v10". The actual ciphertext
is AES‑128‑CBC with a static IV of 16 spaces (0x20).
"""
if not encrypted_value.startswith(b"v10"):
# Not encrypted – return as‑is (unlikely for Chrome)
return encrypted_value.decode(errors="ignore")
# Strip the version prefix
ciphertext = encrypted_value[3:]
# Static IV = 16 spaces
iv = b" " * 16
cipher = Cipher(
algorithms.AES(aes_key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
# Remove PKCS#7 padding
padding_len = padded[-1]
if padding_len > 16:
# Something went wrong; return raw bytes
return padded.decode(errors="ignore")
return padded[:-padding_len].decode(errors="ignore")
# ----------------------------------------------------------------------
# 4️⃣ Pull LinkedIn cookies from Chrome's SQLite DB
# ----------------------------------------------------------------------
def get_linkedin_cookies() -> dict[str, str]:
"""
Returns a mapping {cookie_name: cookie_value} for all cookies whose
host contains “linkedin”. The function works on macOS; adjust the
`cookie_path` for Linux/Windows.
"""
# Chrome locks the DB while it runs, so copy it to a temp file first
cookie_path = os.path.expanduser(
"~/Library/Application Support/Google/Chrome/Default/Cookies"
)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
shutil.copy2(cookie_path, tmp.name)
# Open the copied DB
conn = sqlite3.connect
(tmp.name)
cur = conn.cursor()
cur.execute(
"""
SELECT name, encrypted_value
FROM cookies
WHERE host_key LIKE '%linkedin%'
"""
)
rows = cur.fetchall()
conn.close()
os.unlink(tmp.name) # clean up the temp file
# Prepare decryption materials
chrome_key = get_chrome_key_mac()
aes_key = derive_aes_key(chrome_key)
# Decrypt each cookie
cookies: dict[str, str] = {}
for name, enc_val in rows:
# `enc_val` comes out of SQLite as a `bytes` object
cookies[name] = decrypt_cookie(enc_val, aes_key)
return cookies
# ----------------------------------------------------------------------
# Example usage
# ----------------------------------------------------------------------
if __name__ == "__main__":
linkedin_cookies = get_linkedin_cookies()
for name, value in linkedin_cookies.items():
print(f"{name} = {value}")
스크립트가 수행하는 작업
- 복사 Chrome의
CookiesSQLite 파일을 임시 위치에 복사합니다 (Chrome은 원본을 잠금 상태로 유지합니다). - 쿼리
host_key에 “linkedin”이 포함된 모든 행을 조회합니다. - 마스터 키를 macOS 키체인(
Chrome Safe Storage)에서 가져옵니다. - PBKDF2‑HMAC‑SHA1을 사용해 AES‑128 키를 파생합니다.
- 각
encrypted_value를 복호화합니다 (v10접두사를 건너뛰고, 16개의 공백으로 구성된 고정 IV를 사용하며, PKCS#7 패딩을 제거합니다). requests혹은 다른 HTTP 클라이언트에 바로 전달할 수 있는 평문 사전(dictionary)을 반환합니다.
TL;DR
- Chrome은 쿠키를 SQLite DB(
Cookies)에 저장합니다. - macOS에서는 쿠키 값이 AES‑128‑CBC로 암호화되며, 키는 키체인에 저장된 비밀번호(
Chrome Safe Storage)에서 파생됩니다. - 해당 비밀번호를 추출하고 AES 키를 파생한 뒤
encrypted_value블롭을 복호화하면, 브라우저에 이미 저장된 세션 쿠키를 프로그래밍적으로 재사용할 수 있습니다—수동 복사‑붙여넣기나 24시간 만료가 없습니다.
이제 AI 에이전트는 Chrome에 로그인된 상태인 한 계속 로그인된 상태를 유지할 수 있습니다. 🎉
cookies[name] = decrypt_cookie(encrypted_value, aes_key)
conn.close()
os.unlink(tmp.name)
return cookies
사용법
cookies = get_linkedin_cookies()
print(cookies)
# {'li_at': 'AQEDAT...', 'JSESSIONID': 'ajax:123...', ...}
작동합니다. 이제 복호화된 세션 쿠키를 requests에서 사용할 수 있습니다:
import requests
response = requests.get(
'https://www.linkedin.com/feed/',
cookies=cookies
)
# 인증되었습니다
왜 스크립트만으로는 부족한가
So we can decrypt cookies. Problem solved? Not quite. This script has several issues:
- 보안: 복호화된 쿠키가 이제 스크립트 메모리, 로그, 그리고 잠재적으로 git 히스토리 안에 평문으로 존재합니다. 세션 토큰은 비밀번호만큼 민감합니다.
- 범위 제한 없음: 어떤 스크립트든 모든 쿠키에 접근할 수 있습니다. 당신의 “LinkedIn 에이전트”가 은행 쿠키까지 읽을 수 있어 보안 악몽이 됩니다.
- 감사 추적 부재: 문제가 발생하면(그리고 발생합니다), 어느 에이전트가 언제 무엇에 접근했는지 알 수 없습니다.
- 세션 관리: 쿠키는 만료됩니다. 사이트는 토큰을 교체합니다. 신선도를 추적하고 언제 재인증해야 하는지 알아야 합니다.
- 다중 에이전트 혼란: 5개의 에이전트가 10개의 사이트에 접근하면 쿠키 관리 자체가 하나의 프로젝트가 됩니다.
스크립트에서 도구로
이것이 내가 AgentAuth 를 만든 이유다.
아키텍처
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Chrome Extension│────▶│ Encrypted Vault │────▶│ Your Agent │
│ (export once) │ │ (AES‑256) │ │ (scoped access)│
└─────────────────┘ └─────────────────┘ └─────────────────┘
- Chrome Extension: 사이트에 로그인한 상태에서 “Export” 버튼을 클릭한다. 확장 프로그램이 쿠키를 캡처해 로컬 금고에 전송한다—이제 DevTools에서 복사‑붙여넣을 필요가 없다.
- Encrypted Vault: 쿠키는 로컬 SQLite 데이터베이스에 저장되며, 사용자가 제어하는 비밀번호로 AES‑256 암호화된다. 스크립트에 흩어져 있지 않다.
- Scoped Agents: 특정 도메인 접근 권한을 가진 이름 있는 에이전트를 만든다.
linkedin-agent는linkedin.com쿠키에만 접근할 수 있고, 은행 쿠키는 건드릴 수 없다. - Audit Logging: 모든 접근은 타임스탬프, 에이전트 이름, 도메인과 함께 기록된다.
예시 코드
from agent_auth import Vault
import requests
vault = Vault()
vault.unlock("password")
cookies = vault.get_session("linkedin.com")
# Use with requests
response = requests.get('https://linkedin.com/feed', cookies=cookies)
# Or with Playwright
# context.add_cookies(cookies)
한 줄로 인증된 쿠키를 가져올 수 있다. 복호화 코드는 필요 없고, 하드코딩된 토큰도 없으며, 보안 악몽도 없다.
큰 그림
이 작업을 하면서 AI 에이전트를 위한 세션 관리가 아직 해결되지 않은 인프라라는 것을 깨달았습니다.
- 사용자용 앱을 위한 OAuth가 있습니다.
- 서버 간 통신을 위한 API 키가 있습니다.
- 하지만 AI 에이전트는? 아직도 2010년에 웹 스크래핑에 사용했던 동일한 해킹 방법에 의존하고 있습니다.
업계는 점점 더 정교한 에이전트를 만들고 있습니다—브라우징, 양식 작성, 구매까지 할 수 있는 에이전트 말이죠. 하지만 우리는 여전히 DevTools에서 쿠키를 복사‑붙여넣기 하고 있습니다.
AgentAuth는 이를 해결하기 위한 제 시도입니다. 오픈 소스이며, 오늘날 어떤 사이트에서도 작동하고 (OAuth 채택이 필요 없으며), LangChain, Playwright, n8n과 통합됩니다.
링크
- GitHub: https://github.com/jacobgadek/agent-auth
- PyPI:
pip install agentauth-py - n8n node:
npm install n8n-nodes-agentauth
인증이 필요한 에이전트를 구축하고 있다면 한 번 사용해 보세요. 그리고 기여하고 싶다면—특히 Windows DPAPI 지원이나 Firefox 확장 기능—PR을 환영합니다.
이 내용이 유용했다면, 레포지토리에 별표를 달아 주세요. 다른 사람들이 프로젝트를 발견하는 데 도움이 됩니다.