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.