Skip to content
Researchers David Carliez demonstrated

Researchers David Carliez demonstrated

davidcarliez.github.io July 27, 2026

A standard user redirects WalletService to an attacker-seeded ESE database, loads a callback DLL as LocalSystem, then moves the resulting SYSTEM token into the active desktop session.

CVE-2026-49176 is a local privilege-escalation vulnerability in Windows WalletService. The public Wallet API allows a standard user to activate a LocalSystem service. During initialization, WalletService resolves the caller’s FOLDERID_Documents , appends a fixed Wallet component, reverts impersonation, and opens wallet.db with Extensible Storage Engine persisted callbacks enabled.

The exploit proceeds as follows:

Original vulnerability credit: Microsoft credits Chen Le Qi (@cplearns2h4ck) and Mochi Nishimiya (@MochiNishimiya) , both with STAR Labs SG , for reporting CVE-2026-49176. This post covers my independent root-cause analysis and exploit development, not the original discovery.

Microsoft rates the issue Important , with a CVSS 3.1 score of 7.8 . The advisory maps it to CWE-269: Improper Privilege Management and CWE-59: Improper Link Resolution Before File Access .

WalletService backs the public Windows.ApplicationModel.Wallet WinRT API. An ordinary user can reach the service through the intended public route:

Direct activation of the private Wallet COM class is capability-gated and returned E_ACCESSDENIED from a standard desktop process. That was not a blocker because the public Wallet API performs the supported activation and reaches the same privileged initialization path.

The trust boundary is crossed while the service builds its application-data path. WalletService obtains the caller token, resolves that token’s Documents known folder, then continues after reverting to itself:

A Windows user can change that user’s Documents mapping with SHSetKnownFolderPath . WalletService then treats the resulting path as trusted service storage, even though the caller controls both the location and any pre-existing Wallet child.

The bug is in that handoff. The service uses caller-derived identity state to select a pathname, then crosses back into LocalSystem before deciding what the object at that pathname may contain.

Patch diffing localized the fix to a servicing feature named:

The fixed build gates several legacy operations:

The directory is created before the guarded ESE open. That produces a clear patched differential: build 26200.8875 can still leave an empty Wallet directory, but it does not open wallet.db , create ESE files, replace the DACL, or resolve a persisted callback.

I initially treated the issue as a privileged filesystem problem rather than assuming it was already code execution. A bounded test redirected a standard user’s Documents folder to a disposable parent where the account had read and execute access but could not create files.

On vulnerable build 26200.8737 , GetItemsAsync() caused LocalSystem to create:

WalletService then replaced the child directory’s DACL with SYSTEM and Administrators full control and created the normal ESE file family:

On fixed build 26200.8875 , the same call created only an empty Wallet directory with inherited access. No ESE files appeared and no restrictive DACL was applied.

Before moving on to shell delivery, the differential established three facts:

The first primitive was not an arbitrary file write. WalletService always appended Wallet , and ESE selected fixed database and log names with structured contents.

A whole-directory junction did not help. WalletDatabaseESE::LoadDatabase opens the Wallet root, calls GetFinalPathNameByHandleW , and compares the resolved path with the supplied path. A Wallet junction therefore fails before the useful ESE path.

I also traced Wallet image properties. A file-backed image source can reach:

The bytes and source suffix are interesting, but the basename is a fresh GUID and no WalletService code executes that file. Package import is weaker for direct execution because image destinations use random GUID names and a forced .png suffix.

Neither route provided the known executable filename or automatic SYSTEM consumer needed for a deterministic shell. The ESE path remained interesting because the service was opening an attacker-selectable database in a SYSTEM process, rather than merely writing an opaque database.

ESE supports user-defined-default callbacks. A column can store a callback specification in the database schema as:

When persisted callbacks are enabled, opening the relevant table causes ESE to resolve and load the callback module. That feature is intended for trusted databases. It becomes an execution primitive when a privileged process accepts a database created by a lower-privileged user.

The exploit pre-seeds a minimal database with the mandatory Wallet table name, Cards . It adds a tagged column with JET_bitColumnUserDefinedDefault and stores an attacker-selected DLL!Export string:

The crucial service-side condition is:

WalletService enables this ESE parameter and opens Cards . Because the database is already present at the caller-derived Wallet path, ESE consumes the persisted callback metadata and loads the selected DLL inside the LocalSystem service host.

That turns the path and file-ownership problem into deterministic code execution. The chain does not rely on a race, memory corruption, symlink privilege, or a guessed service binary path.

The database seeder performs five relevant operations:

The callback DLL and shell broker remain outside the Wallet directory in a sibling payload directory. This matters because WalletService replaces the Wallet directory DACL during vulnerable initialization. Keeping the executable components outside that restricted child leaves them readable by the SYSTEM service host and writable only within the attacker’s own test root.

The callback string stored in the ESE schema points to:

After seeding the database, the trigger records the original Documents path and redirects it to the run root. It then uses the public WinRT API:

GetItemsAsync() is sufficient because WalletService initialization opens the database and the mandatory Cards table before normal item enumeration can complete.

The trigger restores the original Documents location in a finally block. Restoration does not depend on exploitation success, so a failed or fixed-build run does not leave the profile redirected.

The first callback payload was deliberately non-interactive. It wrote its process identity to a protected proof file. The preserved run recorded:

The proof file was owned by NT AUTHORITY\SYSTEM . A direct write attempt from the low account to the same protected directory failed with access denied. That rules out parent-process assumptions and a low-integrity marker written by the trigger itself; the code executed in the service host.

The tested vulnerable component identity was:

The fixed comparison used build 26200.8875 with:

On the fixed build, the same low-user trigger completed its public API calls but produced an empty Wallet directory. The attacker-seeded database was not opened, no callback DLL was loaded, and no SYSTEM payload stage ran.

Code execution inside WalletService occurs in a service-host context, not automatically on the interactive desktop. I kept shell delivery separate from the CVE primitive.

The callback first verifies that its token is WinLocalSystemSid . It then creates a temporary service pointing to shell_broker.exe , starts it, and deletes the service registration. The broker therefore receives a normal LocalSystem service token with the privileges needed for session placement.

The no-admin validation recorded zero temporary-service residue after the run. The resulting shell ran in session 1 as NT AUTHORITY\SYSTEM .

Each stage establishes a separate part of the chain:

End to end, the chain is:

The chain crosses several observable boundaries:

The highest-signal event is the module load. WalletService has no normal reason to load a DLL from a random user’s LocalAppData tree while processing Wallet data.

Defenders can also inventory unexpected Wallet roots by comparing the user’s current FOLDERID_Documents with the normal profile location, then checking whether Wallet\wallet.db predates the service’s first use.