
PoC — Supernote Obsidian 플러그인의 악성 기기 동기화를 통한 경로 순회 (GHSA-3gx3-r874-5pp4, CVE-2026-86999, CVSS 5.6).
CVE 상태: 요청됨, 할당 대기 중. 이 취약점은 GHSA-3gx3-r874-5pp4로 공개되었습니다. CVE가 할당되면 이 저장소는
CVE-YYYY-NNNNN-supernote-obsidian-plugin-PoC로 이름이 변경되고 이 배너는 CVE 링크로 교체됩니다.
| 연구자 | Dostxodjayev Abdullox (@squeeze440) |
| 권고 | GHSA-3gx3-r874-5pp4 |
| CVSS 3.1 | 5.6 (Medium) |
| 취약점 유형 | CWE-22, CWE-73 |
Supernote (Unofficial) Obsidian 플러그인(philips/supernote-obsidian-plugin) v2.9.1의 기기 자동 동기화 기능에서 발생하는 경로 순회(Path Traversal) 취약점은 악의적이거나 변조된 "Supernote" 기기(또는 페어링된 기기의 IP를 사칭하는 경로상/근거리 네트워크 공격자)가 기기의 디렉터리 목록 응답에 조작된 uri 필드를 통해 피해자의 Obsidian 클라이언트가 데스크톱 프로세스가 쓸 수 있는 임의의 위치 — 설정된 동기화 폴더 외부 및 볼트 자체 외부를 포함하여 — 에 임의의 새 파일을 쓰도록 만들 수 있게 합니다.
philips/supernote-obsidian-plugin("Supernote (Unofficial)")은 물리적 Supernote 전자잉크 기기의 노트를 기기의 로컬 "Browse and Access" HTTP 서버(http://<device-ip>:8089, 기기 기능 설계상 인증 없음)를 통해 볼트로 동기화하는 Obsidian.md 커뮤니티 플러그인입니다.
48db5bf4dcfb100632c84699c830c1309da1abb9supernote-typescript: 커밋 195415b3a1f74147... (자체적으로는 관련 없음 — 버그는 전적으로 플러그인 자체의 동기화 계획 코드에 있음)5.6 Medium — CVSS:3.1/AV:A/AC:H/PR:N/UI:R/S:C/C:N/I:H/A:N
AV:A — 기기의 HTTP 서버는 평문이고 인증이 없으며 단순 IPv4 주소로 구성됩니다(IP_VALIDATION_PATTERN, src/settings.ts:6). "기기"로서 이에 접근하려면 근거리 네트워크에 인접한 위치(로그 AP, ARP 스푸핑, 또는 IP 점유)가 필요하며, 임의의 원격 접근은 아닙니다.AC:H — 익스플로잇은 피해자의 플러그인이 directConnectIP:8089와 통신할 때 공격자가 이미 해당 네트워크 위치를 점유하고 있어야 합니다. 일회성 원격 트리거가 아닙니다.UI:R — 피해자가 공격자가 제어하는 엔드포인트를 향한 상태에서 "Sync supernote notes now"를 실행하거나(또는 자동 동기화 토글이 이미 활성화되어 있어야) 합니다.S:C — 취약한 구성 요소는 볼트 범위의 Obsidian 플러그인이며, 쓰기는 볼트 외부의 기반 호스트 파일시스템에 발생하는데, 이는 다른 보안 범위입니다.C:N — 쓰기 전용 프리미티브로, 공격자에게 읽혀 돌아오는 것은 없습니다.I:H — 공격자가 제어하는 바이트가 공격자가 선택한 경로에 공격자가 선택한 파일명으로 기록됩니다. 제한적 단서: writeBinaryAt()(src/syncEngine.ts:40-47)은 플러그인 자체 동기화 매니페스트에 이미 추적되지 않은 경로에 대해서만 생성 분기를 취하며, Obsidian의 Vault.createBinary() 자체도 해석된 경로에 이미 물리적으로 존재하는 파일을 조용히 덮어쓰기를 거부합니다(경험적으로 확인됨 — PoC 참조) — 따라서 이는 "임의의 위치에 새 파일 심기"이지 "기존 파일 덮어쓰기"가 아닙니다.A:N — 가용성 영향은 입증되지 않았습니다.runDeviceSync()(src/syncEngine.ts:100-196)는 scanDeviceSupernoteTree()(src/FileListModal.ts:53-68)를 통해 페어링된 기기의 파일을 나열하는데, 이 함수는 기기 자체의 HTTP 디렉터리 목록을 재귀적으로 탐색하며 **name**이 /\.(note|spd)$/i와 일치하는 항목을 유지합니다(src/FileListModal.ts:46,63). 각 항목의 uri 필드 — 기기가 반환한 동일한 JSON 객체 내의 별도로 독립적으로 제어되는 문자열 — 는 name이나 다른 어떤 것과도 검증되지 않습니다.
그 원시 uri는 그대로 deviceUriToVaultPath()(src/deviceSync.ts:153-161)로 전달됩니다:
const INVALID_FILENAME_CHARS = /[\\:*?"<>|]/g; // deviceSync.ts:144
export function deviceUriToVaultPath(syncFolder: string, deviceUri: string): string {
const segments = deviceUri
.split('/')
.filter((s) => s.length > 0)
.map((s) => s.replace(INVALID_FILENAME_CHARS, '_'));
const cleanRoot = syncFolder.replace(/^\/+|\/+$/g, '');
return cleanRoot ? `${cleanRoot}/${segments.join('/')}` : segments.join('/');
}
INVALID_FILENAME_CHARS는 \ : * ? " < > |를 제거하지만 .. 경로 세그먼트는 제거하거나 거부하지 않습니다. /../../PWNED.txt라는 기기 uri는 그대로 살아남아 설정된 동기화 폴더(기본값 "Supernote sync")에 결합되어 vaultPath = "Supernote sync/../../PWNED.txt"를 생성합니다.
syncEngine.ts:128은 목록의 원시 uri로부터 이 vaultPath를 계산한 다음, ensureFolder()/writeBinaryAt()(syncEngine.ts:146-148, 21-47)이 이를 순회 검사 없이 그대로 app.vault.getAbstractFileByPath() / createBinary() / modifyBinary()에 전달합니다. Obsidian 자체의 경로 해석은 그런 다음 디스크상의 실제 볼트 디렉터리에 대해 .. 세그먼트를 정규화하여, 쓰기가 동기화 폴더 위에 — 그리고 ../ 세그먼트가 충분하면 볼트 루트 전체 위에 — 호스트 파일시스템의 OS 사용자 권한 범위에 위치하게 됩니다.
형제 검사: 이 코드베이스의 다른 모든 볼트 쓰기 싱크(src/main.ts — 화면 미러 캡처, PDF/마크다운 가져오기, "attach to note", src/FileListModal.ts:245-246의 DownloadListModal)는 Obsidian 자체의 app.fileManager.getAvailablePathForAttachment(file.name)를 통해 대상 경로를 구성하며, 이는 기기의 name 필드만 소비하므로 이런 방식으로는 익스플로잇할 수 없습니다. 더 새로운 자동 동기화 기능의 deviceUriToVaultPath()는 대신 기기의 uri 필드로부터 경로를 직접 만들어내는 유일한 싱크이며, .. 정리(sanitization)를 건너뛴 바로 그 싱크입니다 — "모든 형제에서 검사되었지만 이것만 빠진" 명확한 사례입니다.
플러그인 자체 설정 UI에는 다음과 같이 명시되어 있습니다: "The sync command never writes anywhere outside this folder" (src/settings.ts, Sync folder 설명) — 이 PoC는 그 보장을 직접 반증합니다.
실제 수정되지 않은 Obsidian 1.13.4 데스크톱 바이너리(Xvfb + fluxbox + xdotool + scrot)에 대해 플러그인의 실제 컴파일된 main.js를 실행하여 종단 간 동적으로 확인했습니다 — 플러그인 코드의 모킹은 없습니다.
./scripts/build) 새 테스트 볼트에 로드했습니다(Supernote (Unofficial) v2.9.1, "Trust author and enable plugins"를 통해 활성화).Supernote IP address를 127.0.0.1로 설정하고, Sync folder는 기본값 Supernote sync로 두었습니다.127.0.0.1:8089에서 기기의 "Browse and Access" 서버를 모방한 단일 파일 Node 목 서버를 세워 악의적/변조된 기기를 시뮬레이션했습니다. 이 서버의 디렉터리 목록은 다음을 반환합니다:
{"name":"Quick notes.note","size":61,"date":"2026-07-31 00:00:00",
"uri":"/../../PWNED_BY_DEVICE_SYNC.txt","extension":"note","isDirectory":false}
(name은 .note 확장자 필터를 통과하고, uri는 순회를 담고 있습니다.) 모든 GET은 파일 바이트와 함께 200으로 응답됩니다 — 실제 HTTP 클라이언트는 나가는 요청 경로에서 ..를 정규화한 후 전송하지만(RFC 3986), 취약한 코드가 정규화된 요청 URL이 아니라 원본 JSON 문자열로부터 쓰기 경로를 계산하므로 여기서는 무관합니다.PWNED_BY_DEVICE_SYNC.txt가 볼트 루트 한 디렉터리 위(testvault/의 형제, 볼트 외부 전체)에 생성되었습니다. 플러그인 자체의 data.json은 계산된 경로를 그대로 기록했습니다: "vaultPath": "Supernote sync/../../PWNED_BY_DEVICE_SYNC.txt".증거(실제 캡처, ~/engagements/supernote-obsidian-plugin/evidence/):
01-vault-escape-file-write.png — 실제 터미널: ls -la testvault/ PWNED_BY_DEVICE_SYNC.txt로 파일이 볼트 디렉터리의 형제임을 보여주고, cat으로 공격자가 제어하는 내용을 보여줍니다.02-plugin-data-json-vaultpath.png — 플러그인 자체의 영속화된 동기화 상태 파일이 "vaultPath": "Supernote sync/../../PWNED_BY_DEVICE_SYNC.txt"를 기록한 모습.03-plugin-settings-security-claim.png — 동기화 명령이 "never writes anywhere outside this folder"라고 주장하는 플러그인 설정 UI.피해자가 구성한 Supernote 기기로서 응답할 수 있는 공격자(근거리 네트워크 인접: 로그 AP, ARP 스푸핑, 또는 기기의 IP 점유)는 피해자의 Obsidian 클라이언트가 데스크톱 프로세스가 쓸 수 있는 임의의 파일시스템 경로에 공격자가 제어하는 새 파일을 생성하도록 만들 수 있습니다 — 볼트 내부(예: .obsidian/plugins/<new-id>/ 아래의 완전히 새로운 파일로, 추가적인 플러그인 신뢰 악용의 발판 마련) 또는 완전히 외부(예: ~/.config/autostart/*.desktop, 새 crontab 드롭인, 또는 새 파일 — 덮어쓰기가 아닌 — 만으로 실행이나 지속성을 얻기에 충분한 다른 위치)에 생성할 수 있습니다. 해석된 경로에 이미 존재하는 파일을 조용히 덮어쓸 수는 없습니다(이 경우 Obsidian의 createBinary는 "File already exists"를 던지며, 경험적으로 확인됨). 이는 프리미티브를 보편적 덮어쓰기가 아닌 순수 신규 파일 심기로 제한합니다.
uri가 디스크상의 대상을 직접 결정)deviceUriToVaultPath()(src/deviceSync.ts:153-161)에서 deviceUri를 분할한 후 ..(및 비어 있거나 .만 있는) 경로 세그먼트를 거부하거나 제거하십시오. 예:
const segments = deviceUri
.split('/')
.filter((s) => s.length > 0 && s !== '.' && s !== '..')
.map((s) => s.replace(INVALID_FILENAME_CHARS, '_'));
추가로, scanDeviceSupernoteTree()(src/FileListModal.ts:63)는 목록 항목의 uri가 name과 일치하는지(예: uri가 동일한 파일명으로 끝나는지) 검증해야 하며, 두 필드를 독립적으로 신뢰해서는 안 됩니다 — 이는 코드베이스의 다른 모든 곳에서 이미 암묵적으로 의존하고 있는 동일한 종류의 방어로, getAvailablePathForAttachment()를 통해 name/basename으로만 경로를 구성합니다.
Dostxodjayev Abdullox
이 저장소에는 SECURITY.md가 없습니다. GitHub 비공개 취약점 신고는 philips/supernote-obsidian-plugin에 대해 활성화되어 있음이 확인되었습니다(gh api repos/philips/supernote-obsidian-plugin/private-vulnerability-reporting --jq .enabled → true; 이전에 공개된 보안 권고 0건). 표준 GHSA 흐름이 적용됩니다: https://github.com/philips/supernote-obsidian-plugin/security/advisories/new.