Overview
QOwnNotes is an open-source, cross-platform (Qt/C++) notebook that stores each note as a plain Markdown file in a folder, and is designed around folder sync - Nextcloud, ownCloud, Syncthing, a shared drive. That sync model is the whole point of the product, and it is also what turns a local string-handling mistake into remote code execution.
The per-note Git history feature (“Show note git versions”) built a shell command line by
concatenating the note’s filename into a string and handing it to bash -c. Because a note’s
filename is attacker-controlled the moment the notebook is shared or synced, a crafted filename
turns “view this note’s history” into “run this attacker’s command,” with the privileges of the
desktop user. This is a textbook OS command injection (CWE-78), but reached through a data channel
(the filesystem, via sync) that users do not think of as untrusted input.
Reported to the maintainer on 2026-10-23, acknowledged the same day, and fixed in release 26.10.11 on 2026-10-24.
| Product | QOwnNotes (desktop, Qt/C++) |
| Class | OS command injection (CWE-78) -> remote code execution |
| Affected | < 26.10.11 |
| Fixed in | 26.10.11 (note path hardened); 26.10.12 (libgit2 viewer enabled in all builds) |
| Vector | Attacker-controlled note filename in a shared / synced notebook |
| Severity | High - CVSS:3.1 7.8 (AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H) |
| Advisory | GHSA-jp2h-69pm-p69c (GitHub as CNA; CVE requested) |
Root cause
The sink is Utils::Git::showLog() (src/utils/git.cpp). Rendering a note’s Git history produced a
command string in which the note’s file path was interpolated with only double-quote wrapping - no
escaping, no argument vector - and that string was then executed through a shell:
// reconstructed from the vulnerable pre-26.10.11 path
QString cmd = gitLogCommand; // user's configured git-log command
cmd += " \"" + filePath + "\""; // note path glued in, only "-quoted
QProcess::execute("bash", {"-c", cmd}); // shell parses the whole string
The wrapping quotes are the entire defense, so a single " in filePath closes the quoted region
and everything after it is parsed by bash as new shell syntax. Nothing escapes the path, and
nothing ends option parsing.
Two facts make the path genuinely attacker-controlled rather than theoretical:
- Filenames survive sanitization.
Note::cleanupFileName()strips only/,\and:on Linux/macOS (the aggressive quote-stripping variant is gated to FAT/NTFS). Notes that arrive by external sync are not run through cleanup at all - they are read off disk as-is. So shell metacharacters like",`,$,;live happily in a.mdfilename. - Only
/is awkward. Because the single stripped shell-relevant character is/, a working payload just has to be slash-free; the note folder is already the process working directory, so no absolute paths are needed.
Preconditions and impact
Three conditions, all of which sit inside normal QOwnNotes usage:
- The user has configured an external Git-log command (the documented way to use the note versioning feature).
- The attacker controls a note filename in that notebook - i.e. the notebook is shared or synced, which is QOwnNotes’ core Nextcloud/ownCloud workflow.
- The user opens Note -> Show note git versions on the attacker’s note.
When they do, the injected command runs as the user. This is the same shape as the gitk
crafted-filename command execution (CVE-2025-27614): a Git UI feature that trusts a filename it
should treat as hostile data.
# attacker names a note (slash-free, breaks out of the "-quoted region):
meeting-notes";touch $HOME/QOWN_RCE_PROOF;"z.md
# the per-note git-log then runs, effectively:
bash -c git log "…/meeting-notes";touch $HOME/QOWN_RCE_PROOF;"z.md"
# └──────── injected, runs as the user ────────┘
Confirmed end-to-end on the shipped v26.10.10 AppImage (headless Xvfb + xfwm4): a note synced into
the folder with a payload filename, external git-log enabled in the real UI, then “Show note git
versions” - the injected command executed and wrote its marker. A benign marker (touch) stands in
for what would be a reverse shell in a real attack.
The fix
Fixed by the maintainer in 26.10.11 by removing the shell from the code path entirely. The note path
is no longer concatenated into a command string; instead the configured command is tokenized with
QProcess::splitCommand() and run as an argument vector (setProgram / setArguments /
startDetached), so the note path is passed as one opaque argument that no shell ever parses.
Defense-in-depth was added on top: rejection of NUL / unterminated quotes, rejection of a
%notePath% substring that could smuggle the path back into the program token, and a leading ./
prefix on the path so it can never be read as an option.
In 26.10.12 the maintainer went further and enabled the built-in libgit2 history viewer in all
release builds (issue #3802), so the default path uses the library and never shells out at all. I
re-verified both: the single-string QProcess::start(QString) overload is absent from the binary,
and there are no bash -c / sh -c wrapper literals on either code path. CWE-78 is closed.
The general lesson. Never build a shell command line out of a filename - or any value you did not generate yourself. Pass arguments as an argument vector so the shell is never in the loop, and remember that on a sync-first application the filesystem is an attacker-reachable input channel.
Disclosure timeline
- 2026-10-23 - Reported to the maintainer (Patrizio Bekerle) by email with a full write-up and a screen-recording PoC; proposed coordinated disclosure.
- 2026-10-23 - Acknowledged the same day.
- 2026-10-24 - Advisory GHSA-jp2h-69pm-p69c opened (GitHub as CNA), reporter credited, CVE requested; fix released in 26.10.11 (release notes reference the advisory and the credit).
- 2026-10-24 - Fix independently verified in the released tag; 26.10.12 adds the libgit2 viewer as further hardening.
References
- QOwnNotes 26.10.11 / 26.10.12 release notes (fix and credit).
- Advisory: GHSA-jp2h-69pm-p69c. CVE: requested via GitHub, pending assignment.
- CWE-78: Improper Neutralization of Special Elements used in an OS Command.
- Prior art for the pattern: CVE-2025-27614 (
gitk, crafted filename -> command execution).
Credit
Reported by Dilanka Kaushal Hewage (r3vpwnx). Thanks to Patrizio Bekerle for the prompt fix and the clean coordinated-disclosure handling.