오늘도 공부
REA로 Notion 데스크톱 앱 리버싱 본문

REA(Reverse Engineering Assistant)로 Electron 앱의 preload → IPC → 메인 프로세스 경계를 복원한 실전 기록
TL;DR
- 대상: Notion Desktop 7.36.1 (Electron, notion.id), app.asar 10.5MB
- 목표: "복사" 버튼을 눌렀을 때 클립보드에 실제로 쓰는 코드가 어디 있는지 찾기
- 방법: asar 추출 → REA 자동 분석 → grep으로 경계 수동 추적
- 결과: 클립보드 브리지 4조각 전부 복원 + 에러 분류 10단계 확보
- 부수 발견: 같은 함수 안에서 origin 검증 방식이 정확 비교와 접두사 비교로 엇갈림
- 핵심 교훈: REA는 "어디를 봐야 하는지"를 알려주고, 깊이는 직접 파야 나온다
1. 왜 Notion인가
Electron 앱 리버싱에서 가장 배우기 좋은 대상이 Notion입니다. 이유는 세 가지입니다.
- 경계가 뚜렷하다 — preload / IPC / 메인 프로세스가 webpack 타깃별로 분리돼 있어 추적선이 명확합니다.
- 기능이 실전적이다 — 클립보드, 배지, 다운로드, OAuth 팝업, SQLite 브라우저까지 프로덕션 기능이 다 들어 있습니다.
- 공개 사례가 있다 — REA 공식 showcase에 Notion 클립보드 추적 사례가 있어 비교하며 배울 수 있습니다.
참고: 공식 사례는 Notion Desktop 7.6.1을 다뤘고, 이 글은 7.36.1을 다룹니다. 메이저 버전이 10배 차이 나지만 구조는 그대로입니다. 오히려 버전이 달라도 설계 의도는 유지된다는 걸 확인할 수 있습니다.
2. 준비: 대상과 도구
# 대상 확인
plutil -p "/Applications/Notion.app/Contents/Info.plist" | grep -E "CFBundleShortVersionString|CFBundleIdentifier"
# "CFBundleIdentifier" => "notion.id"
# "CFBundleShortVersionString" => "7.36.1"
# 도구: REA v6.4.0 + Node 22.22.0
npx -y rea-agents@latest --version # 6.4.0
node --version # v22.22.0
| 항목 | 값 |
| 앱 버전 | 7.36.1 |
| 번들 ID | notion.id |
| app.asar | 10,540,435 bytes (10.5MB) |
| 추출 결과 | 13MB |
| 메인 번들 | .webpack/main/index.js = 4,272,871 bytes (4.27MB) |
| preload | 10개 (webpack 타깃별로 분리) |
3. 1단계 — asar 추출
Electron 앱의 소스는 app.asar 안에 있습니다. asar는 헤더 + 파일들을 이어붙인 단순한 컨테이너라 추출은 1초면 끝납니다.
mkdir -p notion-extracted
npx -y @electron/asar extract \
"/Applications/Notion.app/Contents/Resources/app.asar" \
notion-extracted/app
추출 후 구조:
notion-extracted/app/
├── package.json # main: ./.webpack/main
├── .webpack/
│ ├── main/
│ │ ├── index.js ← 4.27MB, 메인 프로세스 전체
│ │ ├── SqliteServer.js
│ │ └── native_modules/
│ └── renderer/
│ ├── tab_browser_view/preload.js ← ★ 여기가 핵심
│ ├── tabs/preload.js
│ ├── windows_and_tabs/preload.js
│ ├── sqlite_browser/preload.js
│ ├── activity_monitor/preload.js
│ ├── internal_settings/preload.js
│ ├── speaker_and_microphone_status/preload.js
│ ├── popup/preload.js
│ ├── client_placeholder/preload.js
│ └── migration/preload.js
├── i18n/
├── patches/
└── config.js / config.json
첫 번째 관찰: preload가 10개로 쪼개져 있습니다. Notion은 창마다 별도 preload를 두는 구조입니다. 이걸 모르고 .webpack/renderer/preload.js를 찾으면 헛수고합니다.
4. 2단계 — REA 자동 분석
REA는 추출된 디렉터리나 asar를 직접 받습니다. 앱을 실행할 필요 없이 정적으로 분석합니다.
npx -y rea-agents@latest analyze-javascript-application \
"$(pwd)/notion-extracted/app" --json > analysis/notion-analysis.json
결과 (exit 0)
| 항목 | 값 |
| 산출물 크기 | 47,210,422 bytes (47MB JSON) |
| 그래프 노드 | 388 |
| 그래프 엣지 | 493 |
| javascript-module | 108 |
| electron-renderer | 12 |
| electron-main | 1 |
| endpoint | 35 |
| worker | 4 |
| native-addon | 4 |
| source-map | 1 |
| context-bridge-api | 1 |
| ipc-channel | 6 |
REA가 잘하는 것
- "어디에 창이 몇 개 있고 preload가 몇 개인가" 를 즉시 알려줍니다. electron-renderer 12개라는 숫자가 곧 "preload가 여러 개다"라는 힌트입니다.
- 각 사실에 근거(evidence)와 한계(limitations) 를 붙여서 반환합니다. 이게 중요합니다 — REA는 "내가 뭘 못 봤는지"까지 보고합니다.
- electron-main:package-entrypoint:./.webpack/main 처럼 진입점을 정확히 짚어줍니다.
REA가 못 하는 것
자동 추출된 context-bridge-api는 1개, ipc-channel은 6개입니다. 그런데 실제로는:
| REA 자동 | 수동 grep | |
| context bridge API | 1개 | 13개 |
| preload 파일 | 0개 | 10개 |
| 클립보드 IPC 채널 | (미검출) | clipboard-read, clipboard-sanitized-write |
즉 REA 결과만 보고 "브리지는 1개구나"라고 결론내리면 틀립니다. REA는 지도이고, 지도에 안 나온 골목은 직접 가봐야 합니다.

5. 3단계 — 수동 추적 (여기가 진짜 작업)
추적 순서는 항상 같습니다. 렌더러 → preload → IPC → 메인.
[1] 노출 API 찾기 exposeInMainWorld 검색
[2] preload 특정 어떤 preload가 그 API를 노출하는가
[3] IPC 채널 찾기 ipcRenderer.invoke / send 인자
[4] 메인 핸들러 ipcMain.handle / 권한 핸들러
5.1 노출된 API 13개 전부 뽑기
grep -rho 'exposeInMainWorld([^,)]*' .webpack | sed 's/exposeInMainWorld(//' | sort -u
"__activityMonitorApi"
"__desktopConfig"
"__desktopDeviceInfo"
"__electronApi" ← 클립보드가 여기
"__internalSettingsApi"
"__isElectron"
"__mediaApi"
"__opener"
"__platform"
"__sqliteBrowserApi"
"__windowListApi"
"tabsApi"
"__SENTRY_IPC__" ← 메인의 native_modules/default.js 에서 주입
주의: .webpack/renderer만 검색하면 12개, .webpack 전체를 검색하면 13개입니다. __SENTRY_IPC__는 메인 쪽 native_modules/default.js에서 주입됩니다. 검색 범위를 좁히면 놓칩니다.
5.2 어느 preload에 있나
for f in */preload.js; do
c=$(grep -o 'clipboard' "$f" | wc -l | tr -d ' ')
[ "$c" != "0" ] && echo "$f : $c"
done
# tab_browser_view/preload.js : 18
tab_browser_view/preload.js — Notion 본문(notion.so)을 로드하는 BrowserView의 preload입니다. 논리적입니다. 복사는 본문에서 일어나니까요.
5.3 IPC 채널 찾기
클립보드 관련 채널명을 검색합니다.
grep -oE '"[a-zA-Z0-9:_-]*[Cc]lipboard[a-zA-Z0-9:_-]*"' .webpack/main/index.js | sort -u
# "clipboard-read"
# "clipboard-sanitized-write"
여기서 첫 번째 반전이 나옵니다. 채널 이름이 notion:clipboard-write 같은 자체 이름이 아니라 Electron 표준 권한 이름입니다.
grep -rl "clipboard-sanitized-write" .
# ./.webpack/main/index.js ← 단 1개 파일
tab_browser_view/preload.js에는 이 문자열이 없습니다. 즉 preload는 커스텀 IPC를 쓰지 않습니다. 그럼 어떻게 쓰나?
5.4 결정적 발견 — IPC를 안 쓴다
preload의 클립보드 모듈(webpack 모듈 번호 8788)을 통째로 뽑아보면 답이 나옵니다.
t.copyText = function (e) {
return navigator.clipboard.writeText(e) // ← 웹 표준 API 직접 호출
}
커스텀 IPC가 아니라 navigator.clipboard 입니다. 그럼 메인의 clipboard-read / clipboard-sanitized-write는 어디서 쓰일까요? 답은 세션 권한 핸들러입니다.
6. 결과: 복원된 클립보드 브리지 전체
6.1 preload — 모듈 8788 (tab_browser_view/preload.js)
난독화를 풀어 읽을 수 있게 재구성한 코드입니다. 원본은 한 줄짜리 압축 코드입니다.
// ── 지원 탐지 ────────────────────────────────────────────────
function isClipboardSupported() {
return typeof ClipboardItem !== "undefined"
&& typeof navigator.clipboard?.write === "function"
}
// ── 텍스트 복사: 웹 표준 API 그대로 ──────────────────────────
export function copyText(text) {
return navigator.clipboard.writeText(text)
}
// ── 이미지 복사: fetch → canvas → PNG → 클립보드 ─────────────
export async function copyImage(imageUrl) {
try {
if (!isClipboardSupported())
throw makeClipboardError({ message: "Clipboard API is not supported",
failureStage: "clipboard_api", imageUrl })
// [1] 이미지 가져오기 (실패 시 캐시 무시하고 1회 재시도)
let response
try {
response = await fetch(imageUrl).catch(() => fetch(imageUrl, { cache: "no-store" }))
} catch (cause) {
throw makeClipboardError({ message: "Failed to fetch clipboard image",
failureStage: "network_request", imageUrl, cause })
}
if (!response.ok)
throw makeClipboardError({ message: "Clipboard image request failed",
failureStage: "http_response", imageUrl, response })
// [2] blob 으로 읽기
let blob
try { blob = await response.blob() }
catch (cause) {
throw makeClipboardError({ message: "Failed to read clipboard image response",
failureStage: "response_body", imageUrl, response, cause })
}
// [3] objectURL 생성
let objectUrl
try { objectUrl = URL.createObjectURL(blob) }
catch (cause) {
throw makeClipboardError({ message: "Failed to create clipboard image blob URL",
failureStage: "blob_url", imageUrl, response, imageBlob: blob, cause })
}
// [4] canvas 에 그려서 PNG 로 재인코딩
const png = await new Promise((resolve, reject) => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const img = new Image()
const cleanup = () => { URL.revokeObjectURL(objectUrl); canvas.remove(); img.remove() }
img.crossOrigin = "Anonymous"
img.onload = () => {
canvas.height = img.height
canvas.width = img.width
if (!ctx) { cleanup(); return reject(makeClipboardError({
message: "Failed to create clipboard image canvas context",
failureStage: "canvas_context", imageUrl, response, imageBlob: blob })) }
try { ctx.drawImage(img, 0, 0) }
catch (cause) { cleanup(); return reject(makeClipboardError({
message: "Failed to draw clipboard image",
failureStage: "canvas_draw", imageUrl, response, imageBlob: blob, cause })) }
try {
canvas.toBlob(out => {
cleanup()
out ? resolve(out) : reject(makeClipboardError({
message: "Failed to encode clipboard image as PNG",
failureStage: "png_encode", imageUrl, response, imageBlob: blob }))
}, "image/png")
} catch (cause) { cleanup(); reject(makeClipboardError({
message: "Failed to encode clipboard image as PNG",
failureStage: "png_encode", imageUrl, response, imageBlob: blob, cause })) }
}
img.onerror = () => { cleanup(); reject(makeClipboardError({
message: "Failed to load clipboard image",
failureStage: "image_decode", imageElement: img, imageUrl, response, imageBlob: blob })) }
try { img.src = objectUrl }
catch (cause) { cleanup(); reject(makeClipboardError({
message: "Failed to load clipboard image",
failureStage: "image_decode", imageElement: img, imageUrl, response, imageBlob: blob, cause })) }
})
// [5] 클립보드에 쓰기
let item
try { item = new ClipboardItem({ "image/png": png }) }
catch (cause) {
throw makeClipboardError({ message: "Failed to create clipboard image item",
failureStage: "clipboard_api", imageUrl, cause })
}
try { await navigator.clipboard.write([item]) }
catch (cause) {
throw makeClipboardError({ message: "Failed to write clipboard image",
failureStage: "clipboard_write", imageUrl, cause })
}
} catch (e) {
throw wrapAsCopyImageError(e)
}
}

6.2 에러 분류 10단계
이게 이번 리버싱에서 가장 값진 산출물입니다. Notion은 이미지 복사 실패를 10개 단계로 분류합니다.
| # | failureStage | 의미 | 함께 담기는 증거 |
| 1 | clipboard_api | ClipboardItem/API 미지원 | imageUrl |
| 2 | network_request | fetch 자체 실패 | imageUrl, cause |
| 3 | http_response | 응답은 왔으나 !res.ok | imageUrl, response |
| 4 | response_body | blob 읽기 실패 | + response |
| 5 | blob_url | createObjectURL 실패 | + imageBlob |
| 6 | canvas_context | getContext("2d") → null | + imageBlob |
| 7 | canvas_draw | drawImage 실패 | + cause |
| 8 | png_encode | toBlob 실패 또는 null | + imageBlob |
| 9 | image_decode | img.onerror / src 설정 실패 | + imageElement |
| 10 | clipboard_write | navigator.clipboard.write 실패 | + cause |
그리고 최종적으로 이렇게 래핑해서 리포트합니다:
{
actionName: "copy_image",
actionSpecificMetadata: { failure_stage, copy_path: "desktop", ... },
message, name, stack
}
왜 이게 중요한가: 프로덕션에서 "복사가 안 돼요"라는 CS를 받았을 때, failure_stage만 보면 네트워크 문제인지 canvas 문제인지 즉시 구분됩니다. 문서에는 절대 안 나오는 설계입니다.
6.3 메인 — 권한 이름 술어 (모듈 50641)
const isClipboardReadPermission = p => p === "clipboard-read"
const isClipboardSanitizedWritePermission = p => p === "clipboard-sanitized-write"
const HELP_EMBED_PATH = "/embed/help"
function isHelpCenterEmbedClipboardWriteRequest({ isMainFrame, requestingUrl, trustedBaseUrls }) {
// 메인 프레임이면 대상 아님
if (isMainFrame || !URL.canParse(requestingUrl)) return false
const u = new URL(requestingUrl)
const pathOk = u.pathname === HELP_EMBED_PATH || u.pathname.startsWith(HELP_EMBED_PATH)
if (!pathOk) return false
return trustedBaseUrls.some(base => {
if (!URL.canParse(base)) return false
const b = new URL(base)
// ★ protocol + host 정확 비교 (origin 비교)
return u.protocol === b.protocol && u.host === b.host
})
}
6.4 메인 — 세션 권한 핸들러
session.setPermissionRequestHandler((wc, permission, callback, details) => {
// 규칙 1: 클립보드 "읽기"는 무조건 거부
if (isClipboardReadPermission(permission)) return callback(false)
if (isClipboardSanitizedWritePermission(permission)) {
const base = getEffectiveBaseUrl()
return void callback(Boolean(
// 규칙 2-a: 메인 프레임이면서 우리 오리진
(details.isMainFrame && details.requestingUrl.startsWith(base))
// 규칙 2-b: 또는 신뢰된 /embed/help 서브프레임
|| isHelpCenterEmbedClipboardWriteRequest({
isMainFrame: details.isMainFrame,
requestingUrl: details.requestingUrl,
trustedBaseUrls: [base, FRONT_DOMAIN_BASE_URLS[env]],
})
))
}
// 그 외 권한: 완화 허용 목록 또는 메인 프레임 + 우리 오리진
callback(Boolean(
isRelaxedPermission(permission)
|| (details.isMainFrame && details.requestingUrl.startsWith(getEffectiveBaseUrl()))
))
})
session.setPermissionCheckHandler((wc, permission, origin, details) =>
!isClipboardReadPermission(permission) &&
!isClipboardSanitizedWritePermission(permission) &&
Boolean((isRelaxedPermission(permission) || details.isMainFrame) &&
details.requestingUrl?.startsWith(getEffectiveBaseUrl())))
6.5 세션 파티션
electronSessionPartition = "persist:notion" // 본체
googleDriveSession = "persist:gdrive-ephemeral" // Google Drive 연동 (휘발성)
Google Drive 세션만 ephemeral로 분리한 게 눈에 띕니다. 외부 서비스 연동은 세션을 격리한다는 설계 의도입니다.
7. REA 자동 분석 vs 수동 추적 — 정직한 비교
| REA 자동 | 수동 추적 | |
| 소요 시간 | 약 1분 | 약 10분 |
| 산출물 | 47MB JSON (노드 388 / 엣지 493) | 복원된 실제 코드 4조각 |
| context bridge | 1개 | 13개 |
| preload 파일 | 0개 | 10개 |
| IPC 채널 | 6개 | 클립보드 채널 포함 정확 |
| 에러 분류 | ✗ | 10단계 확보 |
| 설계 의도 | ✗ | ✓ |
결론
REA = 지도 "어디를 봐야 하는가"
수동 추적 = 등산 "무엇이 들어 있는가"
REA 없이 4.27MB 난독화 번들을 열면 어디서 시작할지 막막합니다. REA가 "렌더러 경계가 12개 있고 진입점은 여기"라고 알려주면 10분이면 끝납니다. 둘은 대체재가 아니라 순서입니다.
8. 활용 사례

8.1 기능 이식 — 내 앱에 같은 보안 모델 구현
가장 직접적인 활용입니다. 이제 설계 의도를 알기 때문에 베낄 수 있습니다.
| 배운 것 | 내 앱에 적용할 코드 |
| 커스텀 클립보드 IPC를 만들지 않는다 | preload에서 navigator.clipboard 직접 사용 |
| 읽기는 무조건 거부 | callback(false) |
| 쓰기만 조건부 허용 | isMainFrame && origin 일치 |
| Electron 표준 권한 이름 사용 | clipboard-read / clipboard-sanitized-write |
| 외부 서브프레임 예외는 경로까지 검사 | 화이트리스트 + pathname 가드 |
// 내 앱용 최소 구현
session.setPermissionRequestHandler((wc, permission, callback, details) => {
if (permission === "clipboard-read") return callback(false)
if (permission === "clipboard-sanitized-write") {
const allowed = details.isMainFrame
&& new URL(details.requestingUrl).origin === MY_APP_ORIGIN // 정확한 origin 비교
return callback(allowed)
}
callback(false) // 기본 거부 (allowlist 방식)
})
8.2 보안 감사 — 일관성 깨진 지점 찾기
리버싱의 실전 가치는 설계자의 실수를 찾아내는 것입니다. 이번에 발견한 것:
// 헬프 임베드 분기 — 정확한 origin 비교 (올바름) ✅
u.protocol === b.protocol && u.host === b.host
// 메인 프레임 분기 — 접두사 매치 ⚠️
details.requestingUrl.startsWith(base)
접두사 매치는 origin 검증이 아닙니다.
base = "https://www.notion.so"
통과: "https://www.notion.so/page" ← 정상
통과: "https://www.notion.so.evil.com/x" ← ⚠️ 접두사는 일치
실전에서는 네비게이션 allowlist(isNavigationAllowed)와 isMainFrame 조건이 방어선을 이루므로 즉시 익스플로잇 가능한 취약점은 아닙니다. 하지만 같은 파일 안에서 한쪽은 protocol+host 정확 비교를, 다른 쪽은 startsWith를 쓴다는 사실 자체가 코드 리뷰 대상입니다. 팀이 올바른 방법을 알고 있었다는 증거이기도 합니다.
이런 발견은 소스 코드 리뷰로도 가능하지만, 소스가 없는 앱에서는 리버싱만이 유일한 방법입니다.
8.3 패턴 학습 — 에러 설계 베끼기
문서에 없고 코드에만 있는 것들:
- failureStage 분류 — 파이프라인 단계마다 실패 지점을 태깅
- capability probe 선행 — isClipboardSupported() 로 먼저 확인 후 진입
- 캐시 무시 재시도 — fetch(url).catch(() => fetch(url, { cache: "no-store" }))
- Promise.allSettled([e]) — 실패해도 원본 에러를 죽이지 않는 정리 패턴
- canvas 재인코딩 — 원본 포맷(webp/gif)을 PNG로 통일해서 클립보드 호환성 확보
- objectURL 정리 — cleanup()에서 revokeObjectURL + DOM 노드 제거
8.4 회귀 테스트 작성
복원한 동작을 명세로 삼아 테스트를 만들 수 있습니다.
// 에러 분류가 실제로 나오는지 검증
test("이미지 fetch 실패 시 network_request 로 분류된다", async () => {
const err = await copyImage("https://invalid.example/x.png").catch(e => e)
expect(err.actionSpecificMetadata.failure_stage).toBe("network_request")
})
test("clipboard-read 권한은 항상 거부된다", () => {
let granted
requestHandler(null, "clipboard-read", v => { granted = v }, { isMainFrame: true,
requestingUrl: "https://www.notion.so/" })
expect(granted).toBe(false)
})
9. 다른 앱에 적용 — Claude.app 사례
같은 절차를 Claude 데스크톱 앱(com.anthropic.claudefordesktop 2.31226.1)에 적용하면 이렇게 대응됩니다.
| 단계 | Notion | Claude.app |
| preload 위치 | .webpack/renderer/*/preload.js | .vite/build/designWindow.js |
| 노출 API | __electronApi | omeletteDesktopPdf |
| IPC 채널 | clipboard-sanitized-write | claude.designPdf_$_DesignPdf_$_printFrameToPdf |
| 메인 핸들러 | 권한 핸들러 | DesignPdf.printFrameToPdf |
| 검증 방식 | origin 화이트리스트 | claude.ai / claude.com / <uuid>.claudeusercontent.com |
Claude.app에서 복원한 PDF 내보내기 브리지:
// preload: designWindow.js (전체가 이게 다임)
contextBridge.exposeInMainWorld("omeletteDesktopPdf", {
printFrameToPdf: ({ origin, nonce, docTitle, paper }) =>
ipcRenderer.invoke(/* ... */ "printFrameToPdf",
{ origin, nonce, docTitle, paper })
.catch(() => ({ ok: false, error: "refused" }))
})
// main: 검증 → 대화상자 → 인쇄 → 원자적 저장
// - origin ≤512자, nonce ≤128자, docTitle ≤4096자
// - origin: https://<uuid>.claudeusercontent.com 만 허용
// - origin + "#" + nonce 가 일치하는 서브프레임 "정확히 1개"
// - printToPDF({ printBackground, preferCSSPageSize, generateTaggedPDF,
// pageSize: paper ? {w/96, h/96} : "Letter" })
// - .om-<hex>.tmp 로 쓰고 rename (원자적)
// - 렌더 후 프레임 재검증 (바뀌면 폐기)
동시성 제한이 1개(activeExports = 1)라는 디테일, 저장 다이얼로그 후 프레임이 바뀌었는지 재확인하는 방어, .om- 접두사 임시파일로 원자적 쓰기 — 전부 문서에 없는 구현 세부입니다.
10. 재현하기
# 0) 준비
node --version # >= 22.19
# 1) asar 추출
npx -y @electron/asar extract \
"/Applications/Notion.app/Contents/Resources/app.asar" \
notion-extracted/app
# 2) REA 자동 분석 (약 1분)
npx -y rea-agents@latest analyze-javascript-application \
"$PWD/notion-extracted/app" --json > analysis/notion-analysis.json
# 3) 노출 API 전부 뽑기 (범위를 .webpack 전체로!)
grep -rho 'exposeInMainWorld([^,)]*' notion-extracted/app/.webpack \
| sed 's/exposeInMainWorld(//' | sort -u
# 4) 클립보드 preload 특정
for f in notion-extracted/app/.webpack/renderer/*/preload.js; do
c=$(grep -o 'clipboard' "$f" | wc -l | tr -d ' ')
[ "$c" != "0" ] && echo "$f : $c"
done
# 5) IPC 채널 찾기
grep -oE '"[a-zA-Z0-9:_-]*[Cc]lipboard[a-zA-Z0-9:_-]*"' \
notion-extracted/app/.webpack/main/index.js | sort -u
# 6) 메인 핸들러 컨텍스트 뽑기 (난독화 해제용)
node -e '
const s = require("fs").readFileSync(
"notion-extracted/app/.webpack/main/index.js", "utf8");
let i = -1;
while ((i = s.indexOf("setPermissionRequestHandler", i + 1)) >= 0)
console.log(s.slice(Math.max(0, i - 900), i + 1100), "\n---");
'
11. 정리
배운 것
- Electron 리버싱의 추적 순서는 항상 같다 — 노출 API → preload → IPC → 메인 핸들러
- 커스텀 IPC가 답이 아닐 수 있다 — Notion은 navigator.clipboard를 쓰고, 메인에서는 권한만 통제한다
- 문서에 없는 게 코드에 있다 — 에러 분류 10단계, 동시성 제한, 원자적 쓰기, 프레임 재검증
- REA와 수동 추적은 순서다 — 자동 분석으로 지도를 얻고, 손으로 깊이를 판다
- 리버싱의 실전 가치 3가지 — 기능 이식 / 보안 감사 / 패턴 학습
한계
- 이 글은 정적 분석입니다. 실제 실행 중인 프로세스의 IPC 트래픽을 관찰한 게 아닙니다.
- 복원한 코드는 난독화 해제 결과이며, 변수명과 구조는 원본과 다릅니다. 의미는 보존했습니다.
- 프레임워크 내부 동작(webpack 모듈 경계, Electron 권한 이름 규칙)에 대한 해석은 검증된 사실에 근거했지만, 설계자의 의도는 추정입니다.
- 버전이 다르면 코드가 바뀝니다. 이 글은 7.36.1 기준입니다.
부록: 한눈에 보는 복원 결과
Notion Desktop 7.36.1
├── 세션 파티션
│ ├── persist:notion
│ └── persist:gdrive-ephemeral
├── 노출 API 13개
│ └── __electronApi, tabsApi, __windowListApi, __mediaApi,
│ __opener, __platform, __isElectron, __desktopConfig,
│ __desktopDeviceInfo, __activityMonitorApi,
│ __internalSettingsApi, __sqliteBrowserApi, __SENTRY_IPC__
├── preload 10개
│ └── tab_browser_view, tabs, windows_and_tabs, sqlite_browser,
│ activity_monitor, internal_settings,
│ speaker_and_microphone_status, popup,
│ client_placeholder, migration
├── 클립보드 (tab_browser_view/preload.js · 모듈 8788)
│ ├── copyText → navigator.clipboard.writeText
│ ├── copyImage → fetch → blob → objectURL → canvas → PNG → clipboard
│ └── 실패 분류 10단계
│ clipboard_api · network_request · http_response · response_body
│ blob_url · canvas_context · canvas_draw · png_encode
│ image_decode · clipboard_write
└── 권한 모델 (main · 모듈 50641 / 481747)
├── clipboard-read → 항상 거부
├── clipboard-sanitized-write → 메인 프레임 + 오리진
│ 또는 /embed/help 서브프레임
└── setPermissionCheckHandler → 클립보드 권한은 별도 경로
분석 환경: macOS, Node 22.22.0, REA v6.4.0, Notion Desktop 7.36.1
'AI > 추천 오픈소스' 카테고리의 다른 글
| OpenAI Dots를 24시간 돌아가는 개인용/팀용 자율 에이전트 조직으로 설계하는 방법 (0) | 2026.10.02 |
|---|---|
| 클로드 팁 Fable을 어드바이저로 설정하기 (0) | 2026.09.29 |
| mobile-mcp github 분석 (0) | 2026.09.28 |
| AI 코딩 에이전트에게도 ‘프로젝트 기억’이 필요하다 (0) | 2026.09.28 |
| AI 코딩 에이전트는 어떻게 ‘끝날 때까지’ 일할까(/goal) (0) | 2026.09.28 |
