CVE-2026-49176 Exploit Development: WalletService to SYSTEM
/ 9 min read
Updated:Table of Contents
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.
TL;DR
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:
standard user -> create an ESE wallet.db containing a persisted callback -> redirect the user's Documents known folder to the seeded parent -> call WalletManager.RequestStoreAsync and GetItemsAsync -> WalletService opens <Documents>\Wallet\wallet.db as LocalSystem -> ESE opens the mandatory Cards table -> ESE resolves the persisted callback as DLL!Export -> attacker DLL loads inside the SYSTEM service host -> SYSTEM token is moved into the active session -> interactive cmd.exe as NT AUTHORITY\SYSTEMOriginal 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.
The WalletService attack surface
WalletService backs the public Windows.ApplicationModel.Wallet WinRT API. An ordinary user can reach the service through the intended public route:
WalletManager.RequestStoreAsync() -> WalletItemStore.GetItemsAsync() -> WalletService activation -> Wallet item manager initialization -> WalletDatabaseESE::OpenDirect 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:
CoImpersonateClient -> OpenThreadToken -> SHGetKnownFolderPath(FOLDERID_Documents, caller token) -> CoRevertToSelf -> append "\Wallet\" -> open wallet.db as LocalSystemA 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.
What the July patch changed
Patch diffing localized the fix to a servicing feature named:
Feature_Servicing_WalletServiceRedirectionGuardinternal feature ID: 3305877819The fixed build gates several legacy operations:
| Function | Fixed behavior |
|---|---|
WalletDatabaseESE::Open | Returns before opening the ESE database |
ServerUtils::HideAndRestrictFolder | Skips the legacy folder restriction |
RestrictFolder | Skips replacement of the directory DACL |
FileUtils::DeleteFiles | Skips legacy cleanup deletion |
WalletDatabaseESE::HandleDatabaseCorruption | Skips corruption cleanup |
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.
Proving the filesystem primitive first
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:
<chosen parent>\Wallet\WalletService then replaced the child directory’s DACL with SYSTEM and Administrators full control and created the normal ESE file family:
edb.chkedb.logedbres00001.jrsedbres00002.jrsedbtmp.logwallet.dbwallet.jfmOn 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 public API was reachable from a true standard-user token;
- the caller controlled the parent used by privileged WalletService initialization;
- the vulnerable and fixed builds diverged exactly at the legacy ESE and restriction path.
Dead ends that narrowed the search
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:
<Documents>\Wallet\Photos\{service-generated GUID}.<source extension>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 persisted callbacks
ESE supports user-defined-default callbacks. A column can store a callback specification in the database schema as:
C:\path\callback.dll!ExportNameWhen 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:
column.coltyp = JET_coltypLong;column.cbMax = sizeof(long);column.grbit = JET_bitColumnTagged | JET_bitColumnUserDefinedDefault;
user_default.szCallback = callback_spec;
JetAddColumnA( session, table_id, "Computed", &column, &user_default, sizeof(user_default), &column_id);The crucial service-side condition is:
JET_paramEnablePersistedCallbacks = 1WalletService 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.
Building the attacker-seeded Wallet database
The database seeder performs five relevant operations:
- creates a private ESE instance under the standard user’s writable directory;
- creates
wallet.db; - creates a table named
Cards; - adds a user-defined-default column containing the callback path and export name;
- shuts ESE down cleanly so WalletService can open the database later.
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 staged shape is:
%LOCALAPPDATA%\CVE-2026-49176-SHELL\<run-id>\ Wallet\ wallet.db payload\ wallet_callback_shell.dll shell_broker.exe result.txtThe callback string stored in the ESE schema points to:
<payload path>\wallet_callback_shell.dll!WalletCallbackTriggering WalletService from a standard user
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:
$store = Await-WinRtOperation ` ([Windows.ApplicationModel.Wallet.WalletManager]::RequestStoreAsync()) ` ([Windows.ApplicationModel.Wallet.WalletItemStore])
$operation = $store.GetItemsAsync()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.
Proving the SYSTEM boundary
The first callback payload was deliberately non-interactive. It wrote its process identity to a protected proof file. The preserved run recorded:
event=DLL_PROCESS_ATTACHpid=6232user=SYSTEMimage=C:\Windows\System32\svchost.exeThe 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:
Windows 11 Pro 25H2: 26200.8737WalletService.dll SHA-256:51A15901B445F0F58D0930535B2136BB567E56709291CA758F5A8265D66F4D12The fixed comparison used build 26200.8875 with:
WalletService.dll SHA-256:B9B1B38E9036F9B55634BCD8181E779EBFC599587DA4B374BF33FBE124D04880On 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.
Making the shell visible
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 broker:
- finds the active interactive session with
WTSGetActiveConsoleSessionIdandWTSEnumerateSessionsW; - duplicates its SYSTEM token as a primary token;
- changes
TokenSessionIdto the active session; - selects
winsta0\default; - calls
CreateProcessAsUserWforcmd.exe.
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:
| Stage | Security claim |
|---|---|
| Caller-controlled Documents mapping | User controls the parent used by WalletService |
Pre-seeded wallet.db | User controls the ESE schema consumed by the service |
| Persisted callback resolution | ESE loads an attacker-selected DLL in the SYSTEM host |
| Callback identity check | Confirms actual LocalSystem code execution |
| Temporary broker service | Obtains a clean service token for desktop delivery |
Session-aware CreateProcessAsUserW | Produces a visible SYSTEM command shell |
The complete exploit chain
End to end, the chain is:
┌──────────────────────────────────────────────────────────────┐│ Standard user ││ ││ Create Wallet\wallet.db with a Cards table ││ Persist callback = C:\...\callback.dll!WalletCallback ││ Redirect FOLDERID_Documents to the seeded parent │└──────────────────────────────┬───────────────────────────────┘ │ public Wallet WinRT API ▼┌──────────────────────────────────────────────────────────────┐│ WalletService, LocalSystem ││ ││ Resolve caller Documents path ││ Revert impersonation ││ Enable ESE persisted callbacks ││ Open attacker-created wallet.db and Cards │└──────────────────────────────┬───────────────────────────────┘ │ ESE resolves DLL!Export ▼┌──────────────────────────────────────────────────────────────┐│ Attacker callback DLL inside SYSTEM svchost ││ Verify WinLocalSystemSid ││ Start temporary shell broker service │└──────────────────────────────┬───────────────────────────────┘ │ duplicate token and set session ▼┌──────────────────────────────────────────────────────────────┐│ Interactive cmd.exe in the active session ││ Owner: NT AUTHORITY\SYSTEM │└──────────────────────────────────────────────────────────────┘Detection opportunities
The chain crosses several observable boundaries:
- a non-administrator process changes the per-user Documents known-folder mapping shortly before WalletService activation;
WalletServiceopensWallet\wallet.dbbeneath an unusual user-selected parent;- a WalletService-hosting
svchost.exeloads a DLL from a user-writable profile or application-data path; - a process running as SYSTEM creates and quickly deletes a short-lived service whose binary resides under a user’s profile;
- a SYSTEM process changes a primary token’s session and starts
cmd.exeonwinsta0\default; - the Wallet database appears before the service’s first initialization rather than being created by WalletService.
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.