06 · IPC 안티패턴과 CVE 사례
이 문서가 답하는 질문: 매년 같은 실수가 왜 새 CVE로 나오나? 어떤 패턴이 반드시 안티패턴이고, 무엇으로 대체하나? 한 줄 답: “넓은 노출·동기 통신·신뢰된 입력 가정·원격 객체 위임 — 이 네 가지가 모든 IPC CVE의 공통 원인이다. 안전한 대안은 좁은 함수, Promise, 입력 검증, 명시적 메시지.”
Why — 왜 같은 실수가 반복되는가
Electron이 편하게 보이기 때문이다. 웹 개발자가 데스크톱 앱을 만들 때, 웹의 사고방식을 그대로 가져온다 — XSS는 cookie 훔치는 정도라고 무의식적으로 가정한다. 그러나 Electron에서는 같은 XSS가 RCE다.
이 문서는 6가지 안티패턴을 위험한 코드 → 안전한 대안의 짝으로 본다. 각 안티패턴은 실제 CVE와 연결된다.
안티패턴 1 — ipcRenderer 통째 노출
위험한 코드
// ❌ preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer);
// 또는
contextBridge.exposeInMainWorld('api', {
invoke: ipcRenderer.invoke.bind(ipcRenderer),
send: ipcRenderer.send.bind(ipcRenderer),
on: ipcRenderer.on.bind(ipcRenderer),
});왜 위험한가
XSS가 임의 채널에 임의 메시지를 쏠 수 있다. ipcMain.handle('admin:delete-all', ...) 같은 채널이 하나라도 존재하면 — 끝.
// 공격자 페이로드 (XSS로 주입)
window.api.invoke('admin:delete-all');
window.api.invoke('fs:write', '/etc/passwd', 'root::0:0::/:/bin/sh');실제 사례
- CVE-2018-1000136 (Electron 1.x):
nodeIntegration: true+ 비검증 URL 로딩으로 임의 코드 실행. 영향: Signal Desktop·Skype 등. - CVE-2019-13720 계열: V8/Chromium 취약점 + 넓은 IPC 표면이 결합해 RCE.
안전한 대안
03에서 본 동사 단위 함수 노출.
// ✅
contextBridge.exposeInMainWorld('api', {
user: { get: (id: string) => ipcRenderer.invoke('user:get', id) },
app: { quit: () => ipcRenderer.send('app:quit') },
});안티패턴 2 — ipcMain.handle에서 입력 검증 없이 OS 호출
위험한 코드
// ❌ main.ts
ipcMain.handle('exec', (_, cmd: string) => {
return new Promise((resolve, reject) => {
exec(cmd, (err, stdout) => err ? reject(err) : resolve(stdout));
});
});
// 또는
ipcMain.handle('fs:read', (_, path: string) => fs.promises.readFile(path, 'utf8'));
ipcMain.handle('open-url', (_, url: string) => shell.openExternal(url));왜 위험한가
Renderer 입력을 그대로 OS에 넘긴다. 어떤 입력이라도 거른 적이 없다 — XSS가 window.api.exec('rm -rf ~')을 부르면 그대로 실행.
실제 사례
- CVE-2018-15685: Brave Browser (Electron 기반)에서 IPC handler가 URL 검증을 안 해 —
file://URL로 임의 파일 읽기. - shell.openExternal 우회: 2020년경 다수 앱에서 —
shell.openExternal('javascript:...')또는shell.openExternal('file:///System/Library/...')로 임의 코드 실행 및 파일 접근.
안전한 대안
(a) 입력을 enum으로 받기
// ✅ 임의 명령어 대신 — 미리 정의된 작업만
type Action = 'logout' | 'restart-app' | 'open-logs';
ipcMain.handle('action', (_, name: Action) => {
switch (name) {
case 'logout': return doLogout();
case 'restart-app': return app.relaunch();
case 'open-logs': return shell.openPath(logDir);
default: throw new Error('unknown action');
}
});(b) Path traversal 차단
// ✅ basename으로 폴더 탈출 차단
ipcMain.handle('app-data:read', (_, name: string) => {
if (typeof name !== 'string') throw new Error('invalid name');
const safeName = path.basename(name); // ../ 제거
const fullPath = path.join(app.getPath('userData'), 'data', safeName);
// 추가 — resolve된 경로가 정말 그 폴더 안인지
const root = path.resolve(app.getPath('userData'), 'data');
const resolved = path.resolve(fullPath);
if (!resolved.startsWith(root + path.sep)) throw new Error('out of root');
return fs.promises.readFile(resolved, 'utf8');
});(c) URL 스킴 검증
// ✅ openExternal 전에 protocol 화이트리스트
ipcMain.handle('open-url', (_, urlStr: string) => {
const u = new URL(urlStr);
if (!['https:', 'http:', 'mailto:'].includes(u.protocol)) {
throw new Error('unsupported protocol');
}
return shell.openExternal(urlStr);
});안티패턴 3 — sendSync로 UI freeze
위험한 코드
// ❌ preload / renderer
const config = ipcRenderer.sendSync('config:load');왜 위험한가
02에서 본 그대로 — renderer 스레드 전체가 멈춘다. main이 1초 걸리면 UI가 1초 동안 응답 없음. main이 deadlock하면 renderer도 죽음.
실제 사례
CVE가 아니지만 — Electron 앱 사용자 리뷰의 “앱이 멈춰요” 의 진짜 원인. 특히 startup 시 sendSync로 config 여러 개 받으면 — 시작 시간이 프로세스 띄우는 시간 × 채널 수가 된다.
안전한 대안
// ✅ invoke로 — async 보일러플레이트가 살짝 늘 뿐
const config = await window.api.config.load();
// startup 시 병렬로
const [config, prefs, theme] = await Promise.all([
window.api.config.load(),
window.api.prefs.load(),
window.api.theme.load(),
]);안티패턴 4 — remote 모듈 (v14에서 제거된 이유)
위험한 코드
// ❌ Electron v13 이하
const { remote } = require('electron'); // 또는 @electron/remote
const win = remote.getCurrentWindow();
const fs = remote.require('fs');
fs.writeFileSync('/tmp/x', 'data');
win.close();왜 사라졌나
remote 모듈은 main 프로세스의 임의 객체를 renderer에서 그대로 호출할 수 있게 했다. 내부적으로 synchronous IPC로 모든 호출을 main에 위임한다.
문제:
- 모든 호출이 동기 IPC → UI freeze 폭증.
- 임의 객체 노출 → XSS가
remote.require('child_process').exec(...)을 직접 실행. - 디버깅 지옥 → 어디서 호출됐는지 stack에 안 남음.
- 직렬화 함정 → 객체가 진짜로 main에 있는지 renderer에 있는지 헷갈림.
Electron v9에서 enableRemoteModule: false가 디폴트가 됐고, v14에서 코어에서 완전 제거됐다. @electron/remote로 별도 npm 패키지가 되어 명시적으로 깔아야 쓸 수 있게 됐다 — 나가는 방향 신호.
실제 사례
- CVE-2018-1000136 외 다수:
remote.require로 인한 RCE가 Electron 보안 사고의 절반. - 2020년 VSCode 보안 audit: 모든
remote사용처를ipcMain.handle+ 명시적 채널로 마이그레이션 — 수백 채널이 새로 생김.
안전한 대안
명시적 채널. 매 동작마다 채널 이름을 정하고 main에서 handler를 짠다.
// ❌ remote
remote.getCurrentWindow().close();
// ✅ 채널
// preload
contextBridge.exposeInMainWorld('window', {
close: () => ipcRenderer.send('window:close'),
});
// main
ipcMain.on('window:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close();
});코드 줄 수는 늘지만 — 공격 표면이 채널 단위로 측정 가능해진다.
안티패턴 5 — 채널 이름 충돌
위험한 코드
// libA/main.ts
ipcMain.handle('data', async () => { /* DB 데이터 */ });
// libB/main.ts (다른 라이브러리)
ipcMain.handle('data', async () => { /* user info */ });
// → libA의 handler가 silent overrides 됨!왜 위험한가
채널 이름은 전역 namespace. 같은 채널을 두 곳에서 등록하면 — 뒤에 등록한 것이 조용히 덮어쓴다. 디버깅 시 왜 응답이 이상한지 추적이 매우 어렵다.
실제 사례
CVE는 아니지만 — Electron 디스코드/issues에 매년 수십 건의 비슷한 사고 보고. 특히 플러그인 시스템 있는 앱(VSCode·Atom)에서 플러그인끼리 채널 충돌이 흔하다.
안전한 대안
(a) 도메인 prefix
// ✅
ipcMain.handle('libA:data:get', ...);
ipcMain.handle('libB:data:get', ...);(b) shared const
// shared/channels.ts
export const CH = {
userGet: 'user:get',
fsRead: 'fs:read',
appQuit: 'app:quit',
} as const;
// 모든 곳에서 CH.userGet 사용 → 오타 컴파일 에러(c) 중복 등록 감지
// main 부트스트랩
const _handle = ipcMain.handle.bind(ipcMain);
const registered = new Set<string>();
ipcMain.handle = (channel: string, listener: any) => {
if (registered.has(channel)) {
throw new Error(`channel ${channel} already registered`);
}
registered.add(channel);
return _handle(channel, listener);
};이렇게 부트스트랩에서 중복 등록을 throw로 막으면 — silent override 사고를 컴파일 직후 첫 실행에 발견한다.
안티패턴 6 — CSP 우회 / webSecurity: false
위험한 코드
// ❌
new BrowserWindow({
webPreferences: {
webSecurity: false, // same-origin policy 끔
allowRunningInsecureContent: true, // mixed content 허용
},
});왜 위험한가
웹 보안의 근간인 same-origin policy를 우회한다. 어떤 origin의 코드든 — 같은 권한으로 다른 origin에 접근. 결과적으로 — XSS의 전파 범위가 앱 전체가 된다.
실제 사례
- 2019년 Discord —
webSecurity: false로 인해 외부 URL 임베드 시 local 파일 접근 가능. - 다수 Electron 게임 런처 —
<webview>에webSecurity: false로 임의 사이트가 런처 API에 접근.
안전한 대안
(a) webSecurity: true 유지 (디폴트)
옵션을 건드리지 않는 것이 답. 만약 cross-origin fetch가 진짜로 필요하면 — main 프로세스에서 net 모듈로 처리해 IPC로 결과만 전달.
(b) CSP 헤더 명시
// main.ts
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; script-src 'self'; object-src 'none'",
],
},
});
});(c) HTML meta로 CSP
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">unsafe-inline과 unsafe-eval은 절대 켜지 않는다 — 그 둘이 켜져 있으면 CSP가 사실상 무력하다.
안티패턴 종합 — 위험 vs 안전 한눈에
What-if — 안티패턴을 조합해서 쓰면 (실제 사고 시나리오)
상상이 아니라 — 실제 보고된 사고 모양이다.
2018년 X 앱 (가명):
nodeIntegration: true+contextIsolation: false(B1류)<webview>로 외부 URL 임베드 (B6류)remote.require('child_process').exec을 디버그 메뉴에 노출 (B4류)- CSP 없음 (
B6)공격 흐름: 외부 광고 네트워크의 XSS payload →
webview안에서 실행 →parent.frames[0].require('child_process').exec(...)→ 호스트 OS에 임의 명령 → 데이터 유출.사후:
contextIsolation: true+<webview>제거 +@electron/remote비사용 + CSP 추가의 4단계 마이그레이션. 6개월 소요.
이런 시나리오에서 어느 한 안티패턴만 막혀 있어도 — 공격이 완성되지 않았다. *심층 방어(defense in depth)*가 이래서 필요하다.
Insight — 한 단락 이야기
“Electron 보안의 역사는 ‘편한 디폴트’와 ‘안전한 디폴트’의 줄다리기다”
2013~2017년: Electron은 편한 디폴트를 골랐다.
nodeIntegration: true,contextIsolation: false,enableRemoteModule: true. 결과: 모든 웹 개발자가 데스크톱 앱을 짤 수 있었다. 동시에 CVE 카운터가 폭증했다. 2018년 Cure53가 다수의 Electron 앱을 audit하며 한 문장으로 정리했다 — “Electron의 디폴트가 안전하지 않은 한, 평균적 앱은 안전하지 않다”. 그 보고서 이후 Electron 코어 팀이 안전한 디폴트로의 마이그레이션 5년 계획을 세웠고, v5(2019) → v12(2021) → v14(2021)에 걸쳐 디폴트가 안전한 쪽으로 옮겨졌다. 흥미로운 반전 — 같은 시기 VS Code 팀이 마이그레이션의 reference implementation이 됐다. Microsoft 안에서 수십 개 팀이 VS Code의 IPC 패턴을 복붙하면서 — 안전한 IPC 패턴이 사실상 표준화됐다. 즉 Cure53의 한 보고서가 → Electron 팀의 5년 계획이 → VS Code의 reference가 → 모든 Electron 앱의 표준이 된 체인이 있었다. 결론: 안티패턴 6개는 우연한 실수 목록이 아니라 Electron 보안 진화의 화석이다. 이 화석을 읽으면 — 같은 자리에서 같은 실수를 안 하게 된다.
요약 + Mermaid
| 안티패턴 | 한 줄 대안 |
|---|---|
| ipcRenderer 통째 노출 | contextBridge로 동사 함수 |
| 검증 없는 handle | enum · basename · URL 화이트리스트 |
| sendSync | invoke + await |
| remote 모듈 | 명시적 채널 |
| 채널 이름 충돌 | 도메인 prefix + shared const + 중복 throw |
| webSecurity: false | 디폴트 유지 + CSP 명시 |
한 줄 결론 — 안티패턴은 피하는 것이 아니라 구조적으로 막는 것이다. lint rule(eslint-plugin-electron-secure-defaults), CSP audit, 중복 채널 감지, contextBridge 강제 — 모두 반복되는 사고를 코드 리뷰 이전에 잡는 장치다.
이 챕터(
03-ipc-bridge)를 끝내면 — Renderer가 OS에 닿는 순간을 한 함수마다 검증할 줄 알게 된다. 다음 챕터(04-native-integration)는 그 닿음 너머의 실제 OS API 표면을 본다.