Kerb3961Kernel.sys EnabledForAccountCommon RC4 policy bypass

RC4-HMAC is the legacy enctype everyone is trying to retire. Its key is derived from the NTLM hash, the cipher itself is broken, and tickets under it can be GPU-cracked offline (the Kerberoasting family), so Microsoft has been pushing RC4 out for a decade through rc4-only-client policies and the audit-then-enforce RC4 rollout. Full removal has to be per-account because legacy service principals still depend on it, and that per-account disposition is exactly what Kerb3961::RC4_4757::EnabledForAccountCommon answers. The Kerberos client asks it “is RC4-HMAC available for this account under this domain’s policy?”, passing the account, a KeyPolicyScenario struct, a scenario selector, and a request-flags byte. The function is meant to consult two authorities: a per-account virtual lookup reached through the vtable slot at offset 0x30 of this->Vtable — where “domain says this account must drop RC4” lives — and a static policy byte the caller already cooked into the scenario struct (PolicyAttr /* +0x4 */, bit 3). The contract is that RC4 is enabled only when neither authority forbids it; default-allow fires only after the per-account lookup has actually been called.

The vulnerable build honours that contract only when one of two gates fires, and neither gate looks at the static byte:

/* Kerb3961::RC4_4757::EnabledForAccountCommon  —  vulnerable build
 *   this         RC4_4757 instance
 *   pol          KeyPolicyScenario*  (r12)
 *   scenario     uint32 selector     (arg3, also the initial value of r14)
 *   attr         UserPolicyAttributes flags byte (arg4)
 *
 *   *(this->Vtable + 0x30)           per-account virtual policy lookup (vtable[6])
 *   pol->Header                      u32 at pol + 0x0  (byte 1 is the cipher-id,
 *                                                       compared to 2 and 8)
 *   pol->PolicyAttr                  u32 at pol + 0x4  (bit 3 == static RC4 blessing)
 */
uint8_t  explicit_consult = attr & 0x80;                            /* rbp_1: caller demands lookup */
uint8_t  scenario_mask    = attr & 0x03;                            /* rsi_2: scenario bits */
uint8_t  r15              = 0;                                      /* object-state gate, hard-coded 0 */
uint8_t  r13              = ((uint8_t)pol->PolicyAttr /* +0x4 */) & 0x08;  /* LOADED, NEVER TESTED */
uint32_t verdict          = scenario;                               /* r14: defaults to caller's selector */

if (explicit_consult != 0)                                          /* gate (1): caller asked aloud */
    goto consult;
if (scenario_mask == 0 && r15 == 0)                                 /* gate (2): no scenario bits
                                                                    * and r15 is always 0 here, so this
                                                                    * reduces to "no scenario bits" */
    goto consult;
/* fall-through: per-account lookup never called; r13 was loaded and ignored */

consult:
    verdict = (*(this->Vtable /* +0x0 */ + 0x30))(this);            /* vtable[6]: per-account lookup */

uint8_t cipher = *((uint8_t*)&pol->Header /* +0x0 */ + 1);          /* cipher-id at byte 1 */
if (cipher == 2 || (verdict & 4) != 0 || cipher == 8)
    return 1;                                                       /* RC4 enabled */
return 0;

The fall-through is the bug. The two gates between them consult whenever the request flags carry the 0x80 bit or lack scenario bits, but they never look at the static policy byte. r13 is loaded from PolicyAttr and immediately discarded — its 0x08 bit never reaches the consult decision. So any caller that passes a request-flags byte with scenario bits set (attr & 0x03 != 0) and no 0x80 bit reaches the decision without ever calling the per-account lookup. RC4 then turns on whenever the cipher-id byte at byte 1 of pol->Header is 2 or 8, or the verdict word has bit 0x04 set — and on the fall-through path that verdict word is just the caller’s own scenario selector, which has nothing to do with whether this account is allowed to use RC4.

That is the bypass: any Kerberos client path that reaches EnabledForAccountCommon with attr & 0x03 != 0, the explicit-consult bit clear, and a cipher-id byte the function recognises gets “RC4 enabled” back without the per-account policy being asked. An ordinary domain user requesting a TGS for a service principal whose admin had set the per-user policy to forbid RC4 gets back an RC4-HMAC ticket; that ticket then Kerberoasts offline. SMB and LDAP negotiation can be steered toward the weakened enctype by a MITM, and the cascade of hardenings that depends on RC4 being gone (AES-only long-term keys, blocking weak-key kvno downgrades) never fires. The attacker is just any authenticated principal in the realm — the same position from which any domain user can request a service ticket today.

The patch

The fix collapses the two gates into a single combined predicate, turns the object-state byte into a real read of this->Rc4Mode /* +0x68 */, and — most importantly — makes the static byte itself an authority. The cleared byte that the old predicate silently dropped now triggers the lookup:

/* Kerb3961::RC4_4757::EnabledForAccountCommon  —  patched */
uint8_t  explicit_consult = attr & 0x80;
uint8_t  scenario_mask    = attr & 0x03;
uint8_t  r15              = (this->Rc4Mode /* +0x68 */ == 2);      /* real object state */
uint8_t  r13              = ((uint8_t)pol->PolicyAttr /* +0x4 */) & 0x08;
uint32_t verdict          = scenario;

if (scenario_mask == 0 && (explicit_consult != 0 || r13 == 0 || r15 == 0))
    goto consult;
/* fall-through only when scenario_mask != 0, or every authority blessed RC4
 * (explicit_consult clear, r13 set, r15 set) with no scenario bits */

consult:
    verdict = (*(this->Vtable /* +0x0 */ + 0x30))(this);

uint8_t cipher = *((uint8_t*)&pol->Header /* +0x0 */ + 1);          /* cipher-id at byte 1 */
if (cipher == 2 || (verdict & 4) != 0 || cipher == 8)
    return 1;
return 0;

The default-allow path is now reachable only when the caller’s static policy byte actually blessed RC4 (r13 != 0), the RC4 mode field is already 2, the request carried no scenario bits, and there was no explicit-consult bit — in other words, when every authority that could say “no” has already signed off. The cleared byte was the case the old predicate silently dropped; the new predicate routes it back to the lookup. The principle is that a default-allow helper has to consult every authority that can say no before the default fires, and a static byte the caller fills to bless a legacy behaviour is itself one of those authorities — loading it into a register and never testing it is the bug in one line.

Attack path

flowchart TD
    A["domain admin sets per-user policy to disable RC4 for a service account"] --> B["attacker, any domain user, requests a TGS for that account"]
    B --> C["EnabledForAccountCommon sees scenario bits set in attr, explicit-consult bit clear, static PolicyAttr loaded but ignored"]
    C --> D["fall-through: per-account lookup never called, verdict stays as the scenario selector, RC4 returned enabled"]
    D --> E["KDC issues an RC4-HMAC ticket for an account that should forbid RC4, Kerberoasting and legacy surface preserved"]

The trigger is a single TGS-REQ. No kernel attacker, no memory corruption — just a consult predicate that loaded a policy byte into a register and never tested it, and a tightened predicate that puts the cleared byte back on the path to the lookup.