
PoC — path traversal via une synchronisation d'appareil malveillante dans le plugin Supernote Obsidian (GHSA-3gx3-r874-5pp4, CVE-2026-86999, CVSS 5.6).
Statut CVE : demandé, en attente d'attribution. Cette découverte est publiée sous GHSA-3gx3-r874-5pp4. À l'attribution du CVE, ce dépôt sera renommé
CVE-YYYY-NNNNN-supernote-obsidian-plugin-PoCet cette bannière sera remplacée par le lien CVE.
| Chercheur | Dostxodjayev Abdullox (@squeeze440) |
| Avis | GHSA-3gx3-r874-5pp4 |
| CVSS 3.1 | 5.6 (Moyen) |
| Faiblesse | CWE-22, CWE-73 |
Une traversée de chemin (Path Traversal) dans la fonctionnalité de synchronisation automatique de l'appareil du plugin Obsidian Supernote (Unofficial) (philips/supernote-obsidian-plugin) v2.9.1 permet à un appareil « Supernote » malveillant ou compromis (ou à un attaquant sur le chemin réseau/sur le LAN usurpant l'IP de l'appareil appairé) de faire écrire par le client Obsidian de la victime un nouveau fichier arbitraire partout où le processus de bureau peut écrire — y compris en dehors du dossier de synchronisation configuré et en dehors du coffre lui-même — via un champ uri forgé dans la réponse de listage de répertoire de l'appareil.
philips/supernote-obsidian-plugin (« Supernote (Unofficial) »), un plugin communautaire Obsidian.md qui synchronise les notes d'un appareil physique Supernote à encre électronique dans un coffre via le serveur HTTP local « Browse and Access » de l'appareil (http://<device-ip>:8089, sans authentification, par conception de la fonctionnalité de l'appareil).
48db5bf4dcfb100632c84699c830c1309da1abb9supernote-typescript : commit 195415b3a1f74147... (non impliqué en lui-même — le bug se trouve entièrement dans le code de planification de synchronisation propre au plugin)5.6 Moyen — CVSS:3.1/AV:A/AC:H/PR:N/UI:R/S:C/C:N/I:H/A:N
AV:A — le serveur HTTP de l'appareil est en clair, non authentifié, et configuré par une simple adresse IPv4 (IP_VALIDATION_PATTERN, src/settings.ts:6) ; y accéder en tant que « l'appareil » nécessite une position réseau adjacente au LAN (AP pirate, usurpation ARP, ou revendication de l'IP), et non un accès distant arbitraire.AC:H — l'exploitation dépend du fait que l'attaquant occupe déjà cette position réseau lorsque le plugin de la victime communique avec directConnectIP:8089 ; il ne s'agit pas d'un déclenchement distant en un seul coup.UI:R — nécessite que la victime exécute « Sync supernote notes now » (ou que le basculement de synchronisation automatique soit déjà activé) alors qu'elle pointe vers le point de terminaison contrôlé par l'attaquant.S:C — le composant vulnérable est un plugin Obsidian limité au coffre ; l'écriture atterrit entièrement en dehors du coffre, sur le système de fichiers hôte sous-jacent, ce qui constitue une portée de sécurité différente.C:N — il s'agit d'une primitive en écriture seule ; rien n'est relu par l'attaquant.I:H — des octets contrôlés par l'attaquant atterrissent à un chemin choisi par l'attaquant avec un nom de fichier choisi par l'attaquant. Réserve limitative : writeBinaryAt() (src/syncEngine.ts:40-47) ne prend la branche create que pour les chemins non déjà suivis dans le manifeste de synchronisation propre au plugin, et Vault.createBinary() d'Obsidian refuse lui-même d'écraser silencieusement un fichier qui existe déjà physiquement au chemin résolu (confirmé empiriquement — voir PoC) — il s'agit donc de « planter un nouveau fichier n'importe où », et non d'« écraser n'importe quel fichier existant ».A:N — aucun impact sur la disponibilité démontré.runDeviceSync() (src/syncEngine.ts:100-196) liste les fichiers de l'appareil appairé via scanDeviceSupernoteTree() (src/FileListModal.ts:53-68), qui parcourt récursivement le listage de répertoire HTTP de l'appareil lui-même et conserve toute entrée dont le name correspond à /\.(note|spd)$/i (src/FileListModal.ts:46,63). Le champ uri de chaque entrée — une chaîne distincte et contrôlée indépendamment dans le même objet JSON renvoyé par l'appareil — n'est jamais validé par rapport à name ni par rapport à quoi que ce soit d'autre.
Cet uri brut est ensuite injecté directement dans 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 supprime \ : * ? " < > | mais ne supprime ni ne rejette jamais les segments de chemin ... Un uri d'appareil /../../PWNED.txt survit intact et est joint au dossier de synchronisation configuré (par défaut "Supernote sync") pour produire vaultPath = "Supernote sync/../../PWNED.txt".
syncEngine.ts:128 calcule ce vaultPath à partir de l'uri brut du listage, puis ensureFolder()/writeBinaryAt() (syncEngine.ts:146-148, 21-47) le transmettent directement à app.vault.getAbstractFileByPath() / createBinary() / modifyBinary() sans aucune vérification de traversée. La résolution de chemin propre à Obsidian normalise alors les segments .. par rapport au répertoire réel du coffre sur le disque, faisant atterrir l'écriture au-dessus du dossier de synchronisation — et, avec suffisamment de segments ../, au-dessus de la racine du coffre entièrement, sur le système de fichiers hôte, dans la portée de permissions de l'utilisateur du système d'exploitation.
Vérification des pairs : tous les autres points d'écriture dans le coffre de cette base de code (src/main.ts — capture de miroir d'écran, import PDF/markdown, « attach to note », DownloadListModal dans src/FileListModal.ts:245-246) construisent leur chemin de destination via app.fileManager.getAvailablePathForAttachment(file.name) d'Obsidian lui-même, qui ne consomme jamais que le champ name de l'appareil et n'est pas exploitable de cette manière. deviceUriToVaultPath() dans la fonctionnalité de synchronisation automatique plus récente est le seul point d'écriture qui fabrique à la main un chemin à partir du champ uri de l'appareil, et c'est celui qui a omis la désinfection des .. — un cas typique de « vérifié sur tous les pairs sauf celui-ci ».
L'interface de paramètres du plugin lui-même déclare : « La commande de synchronisation n'écrit jamais en dehors de ce dossier » (src/settings.ts, description du dossier de synchronisation) — ce PoC falsifie directement cette garantie.
Confirmé dynamiquement de bout en bout contre un binaire Obsidian 1.13.4 desktop réel et non modifié (Xvfb + fluxbox + xdotool + scrot), exécutant le main.js compilé réel du plugin — aucun mockage du code du plugin.
./scripts/build) et chargement dans un coffre de test vierge (Supernote (Unofficial) v2.9.1, activé via « Trust author and enable plugins »).Supernote IP address sur 127.0.0.1, Sync folder laissé à sa valeur par défaut Supernote sync.127.0.0.1:8089, simulant un appareil malveillant/compromis. Son listage de répertoire renvoie :
{"name":"Quick notes.note","size":61,"date":"2026-07-31 00:00:00",
"uri":"/../../PWNED_BY_DEVICE_SYNC.txt","extension":"note","isDirectory":false}
name passe le filtre d'extension .note ; uri porte la traversée.) Toute requête GET reçoit une réponse 200 avec les octets du fichier — les clients HTTP réels normalisent les .. hors du chemin de requête sortant avant qu'il n'atteigne le réseau (RFC 3986), ce qui est sans importance ici puisque le code vulnérable calcule le chemin d'écriture à partir de la chaîne JSON d'origine, et non de l'URL de requête normalisée.PWNED_BY_DEVICE_SYNC.txt, contenant les octets de l'attaquant, a été créé un répertoire au-dessus de la racine du coffre (frère de testvault/, entièrement en dehors du coffre). Le data.json du plugin a enregistré le chemin calculé mot pour mot : "vaultPath": "Supernote sync/../../PWNED_BY_DEVICE_SYNC.txt".Preuves (captures authentiques, ~/engagements/supernote-obsidian-plugin/evidence/) :
01-vault-escape-file-write.png — terminal réel : ls -la testvault/ PWNED_BY_DEVICE_SYNC.txt montrant le fichier comme frère du répertoire du coffre, plus son contenu contrôlé par l'attaquant via cat.02-plugin-data-json-vaultpath.png — le fichier d'état de synchronisation persisté par le plugin lui-même enregistrant "vaultPath": "Supernote sync/../../PWNED_BY_DEVICE_SYNC.txt".03-plugin-settings-security-claim.png — l'interface de paramètres du plugin affirmant que la commande de synchronisation « n'écrit jamais en dehors de ce dossier ».Un attaquant capable de répondre en tant qu'appareil Supernote configuré de la victime (adjacent au LAN : AP pirate, usurpation ARP, ou revendication de l'IP de l'appareil) peut faire créer par le client Obsidian de la victime un nouveau fichier contrôlé par l'attaquant à n'importe quel chemin du système de fichiers où le processus de bureau peut écrire — à l'intérieur du coffre (par exemple un tout nouveau fichier sous .obsidian/plugins/<new-id>/, préparant le terrain pour d'autres abus de confiance de plugins) ou entièrement en dehors (par exemple ~/.config/autostart/*.desktop, un nouveau fichier déposé dans crontab, ou tout autre emplacement où un nouveau fichier — et non un écrasement — suffit à obtenir une exécution ou une persistance). Il ne peut pas écraser silencieusement un fichier qui existe déjà au chemin résolu (createBinary d'Obsidian lève « File already exists » dans ce cas, confirmé empiriquement), ce qui limite la primitive au dépôt de fichiers entièrement nouveaux plutôt qu'à un écrasement universel.
uri fourni par l'appareil détermine directement la destination sur le disque)Dans deviceUriToVaultPath() (src/deviceSync.ts:153-161), rejeter ou supprimer les segments de chemin .. (ainsi que les segments vides/ne contenant que .) après avoir découpé deviceUri, par exemple :
const segments = deviceUri
.split('/')
.filter((s) => s.length > 0 && s !== '.' && s !== '..')
.map((s) => s.replace(INVALID_FILENAME_CHARS, '_'));
De plus, scanDeviceSupernoteTree() (src/FileListModal.ts:63) devrait valider que l'uri d'une entrée de listage est cohérent avec son name (par exemple que l'uri se termine par le même nom de fichier), plutôt que de faire confiance aux deux champs indépendamment — la même classe de défense déjà implicitement utilisée partout ailleurs dans la base de code, qui ne construit des chemins qu'à partir de name/basename via getAvailablePathForAttachment().
Dostxodjayev Abdullox
Aucun SECURITY.md n'est présent dans ce dépôt. Le signalement privé de vulnérabilités GitHub est confirmé activé pour philips/supernote-obsidian-plugin (gh api repos/philips/supernote-obsidian-plugin/private-vulnerability-reporting --jq .enabled → true ; 0 avis de sécurité publié antérieurement). Le flux GHSA standard s'applique : https://github.com/philips/supernote-obsidian-plugin/security/advisories/new.