Under the Hood of AFD.sys Part 2: TCP handshake
A walk-through of the bind + connect IOCTLs: capturing AFD.sys IRPs with WinDbg, reverse-engineering the buffers for IPv4/IPv6, and completing a manual TCP three-way handshake on Windows 11—still zero Winsock involved.
Plan for today
This is the second part in a series of posts concerning AFD.sys
. If you have not seen the previous one you can find it here. Familiarity with the first part will be key to understanding the content of this post, I will not duplicate the kernel debugging steps, but will immediately show here the contents of the buffers directed to NtDeviceIoControlFile
.
In this part we will look at the bind
and connect
operations. Although normally when we use Winsock we don’t need to perform the bind
, underneath mswsock.dll
actually performs this bind
for us, so it will be crucial for us to understand how we can establish a TCP handshake.
IOCTL for bind command
So let’s start with the bind
operation. In the previous part I focused mainly on TCP, this time we will perform some operations for TCP, UDP with IPv4 and IPv6. So that we can better understand what we are dealing with and what is what. However, as before, for the reconstruction of the structures, I will rely on what can be found on the Internet (killvxk), (unknowncheats.me ICoded post), (ReactOS Project), (DynamoRIO / Dr. Memory), (Dr. Memory - GH issue#376), (DeDf).
This time I will use such code using Winsock, it might be worthwhile in the future to port these programs directly to mswsock.dll
, but the problem is that they are documented (we have signatures of the available functions), but examples of actual use are missing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#pragma comment(lib,"Ws2_32.lib")
void createTCPv4() {
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET) { std::cerr << WSAGetLastError() << '\n'; return; }
sockaddr_in bindAddr{};
bindAddr.sin_family = AF_INET;
bindAddr.sin_port = htons(27015);
bindAddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(s, reinterpret_cast<sockaddr*>(&bindAddr), sizeof(bindAddr)) == SOCKET_ERROR) {
std::cerr << "bind: " << WSAGetLastError() << '\n'; closesocket(s); return;
}
closesocket(s);
}
void createUDPv4() {/*SAME FOR UDPv4*/}
void createTCPv6() {/*SAME FOR TCPv6*/}
void createUDPv6() {/*SAME FOR UDPv6*/}
int main() {
std::cout << "PID: " << GetCurrentProcessId() << "\nPress <Enter> to continue..." << std::endl;
std::cin.get();
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa)) return 1;
createTCPv4();
createUDPv4();
createTCPv6();
createUDPv6();
WSACleanup();
return 0;
}
Before we start collecting data, it is worth mentioning here that socket operations within AFD.sys
are performed using the NtDeviceIoControlFile
(missing reference). Among other things, the IoControlCode
parameter is passed there, with which the operations (this is a simplification, there is much more information behind it) that we want to perform are identified. The driver then performs a dispatch based on the value of this parameter. So, in addition to the data itself being passed to AFD.sys
, we need to collect this control code.
Exactly the same as for debugging NtCreateFile
, here we also set the corresponding breakpoint, but on adf!AdfBind
:
1
.foreach /pS 1 (ep { !process 0 0 afd_re.exe }) { bp /p ${ep} afd!AfdBind}
Now we need to read the information passed to the driver, i.e. the I/O request packet, known as IRP
(missing reference), for this we can use the following command in WinDbg:
1
2
3
4
5
6
7
8
9
10
11
4: kd> !irp @rcx 1
Irp is active with 4 stacks 4 is current (= 0xffffa70db3ef9718)
No Mdl: No System Buffer: Thread ffffa70db39f2080: Irp stack trace.
Flags = 00060000
ThreadListEntry.Flink = ffffa70db39f25c0
[...]
>[IRP_MJ_DEVICE_CONTROL(e), N/A(0)]
5 0 ffffa70dac138d40 ffffa70db718fd20 00000000-00000000
\Driver\AFD
Args: 00000010 00000014 0x12003 75118ff2d0
Already from this information we can learn quite a lot about the arguments of this call (TCPv4 variant):
00000010
- output buffer length - is also important, if we do not send a large enough buffer thenAFD.sys
will return an error,00000014
- input buffer length,0x12003
- control code a.k.a.IoControlCode
,75118ff2d0
- input buffer address in source process a.k.a.Type3InputBuffer
. The subsequent steps for reading the buffer are exactly the same as in theNtCreateFile
cases.
Let’s focus for a moment on the IoControlCode
, its value is 0x12003
, it would be nice if we had some way to build these values depending on the function we need. And here a very good source for us could be (dmex). The data we obtained actually matches what we were able to get:
1
2
3
4
5
6
7
...
#define AFD_BIND 0
...
#define FSCTL_AFD_BASE FILE_DEVICE_NETWORK
#define _AFD_CONTROL_CODE(Request, Method) (FSCTL_AFD_BASE << 12 | (Request) << 2 | (Method))
...
#define IOCTL_AFD_BIND _AFD_CONTROL_CODE(AFD_BIND, METHOD_NEITHER) // 0x12003
So you we confidently use these definitions to build our tool. Or at least for now, because you never know.
Collected data
During the data collection, I considered a total of eight cases. All in order to best be able to distinguish specific pieces of data and their roles in the overall bind process. The first table shows the implicit bind
that mswsock performs at connect if we have not previously performed a bind
. The second one with explicit bind, where I chose 127.0.0.1
as the source address for variants with IPv4 and ::1
for variants with IPv6. In both cases I additionally selected the port 27015
.
Implicit bind()
Variant | Input buffer (hex) | Input length (hex) |
---|---|---|
TCP v4 | 02 00 00 00 02 00 00 00 00 00 00 00 FF FF FF FF FF FF FF FF |
0x14 |
UDP v4 | 02 00 00 00 02 00 00 00 00 00 00 00 F3 03 00 00 00 00 00 00 |
0x14 |
TCP v6 | 02 00 00 00 17 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |
0x20 |
UDP v6 | (identical to TCP v6) | 0x20 |
Explicit bind(loopback, 27015)
Variant | Input buffer (hex) | Input length (hex) |
---|---|---|
TCP v4 | 00 00 00 00 02 00 69 87 7F 00 00 01 00 00 00 00 00 00 00 00 |
0x14 |
UDP v4 | (identical to TCP v4) | 0x14 |
TCP v6 | 00 00 00 00 17 00 69 87 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 |
0x20 |
UDP v6 | (identical to TCP v6) | 0x20 |
Analyzing retrieved data
TCPv4 and UDPv4
Let’s focus for now on all the TCP protocol variants and try to deduce what the field is based on what we see on the collected sources. The explicit bind for IPv4 can tell us the most at this point. Let’s change this to an array in C++ first:
1
2
3
4
5
6
7
8
unsigned char input[] = {
0x00, 0x00, 0x00, 0x00, // Some flags
0x02, 0x00, // Address Family, AF_INET == 0x0002
0x69, 0x87, // Source port (big-endian) 27015 == 0x6987
0x7F, 0x00, 0x00, 0x01, // Source addres 127.0.0.1 == 0x7f000001
// unknown 8 bytes
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
The fields for ADDRESS_FAMILY
, SOURCE_PORT
and SOURCE_ADDRESS
seem pretty clear, we have specified our data and have a direct mapping of it in the buffer. For further confirmation of ADDRESS_FAMILY
we can look at TCPv6, where in place of 0x0002
we have 0x0017
, which is AF_INET6
. Moving on, what might the flags be?
Looking at the definitions of the structures in (dmex), we can see that there they are properly defined and again correspond to what we can observe. The value 0x0000
indicates the normal use of the address (explicit bind). 0x0002
, on the other hand, I assume is supposed to indicate that AFD.sys
- or further components involved in communication - should infer from which available address they are to establish a connection.
1
2
3
4
#define AFD_NORMALADDRUSE 0
#define AFD_REUSEADDRESS 1
#define AFD_WILDCARDADDRESS 2
#define AFD_EXCLUSIVEADDRUSE 3
Mając te informacje możemy częściowo wywnioskować, że mamy do czynienia ze strukturą SOCKADDR
, która przechowuje AddressFamily
, Port
, Address
. Bardziej meaningful może być tutaj definicja struktury SOCKADDR_IN
:
1
2
3
4
5
6
7
8
9
10
11
12
typedef struct sockaddr_in {
#if(_WIN32_WINNT < 0x0600)
short sin_family;
#else //(_WIN32_WINNT < 0x0600)
ADDRESS_FAMILY sin_family;
#endif //(_WIN32_WINNT < 0x0600)
USHORT sin_port;
IN_ADDR sin_addr;
CHAR sin_zero[8];
} SOCKADDR_IN, *PSOCKADDR_IN;
It also explains to us the meaning of the last eight bytes, this is simply padding. I was able to experimentally confirm that they have no meaning on the AfdBind
call, so the final form that our AFD_BIND
structure can take can look like the following (similar to (dmex)):
1
2
3
4
struct AFD_BIND_SOCKET {
uint32_t flags;
SOCKADDR address;
}
To be sure, I have forced a fixed number of bits for flags
here (we will have to be aware of structure packing in memory). This structure will look identical for UDPv4 as for TCPv4.
TCPv6 and UDPv6
To analyse AfdBind
for IPv6 we will find it useful to know that an address in IPv6 is 128 bits long, so let’s break up our buffer as an array in C++:
1
2
3
4
5
6
7
8
9
unsigned char input[] = {
0x00, 0x00, 0x00, 0x00 // flags
0x17, 0x00 // AF_INET6
0x69, 0x87 // 27015
0x00, 0x00, 0x00, 0x00 // unknown 4 bytes
// ::1
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01
0x00, 0x00, 0x00, 0x00 // unknown 4 bytes
};
Basically, here we can already stop and partially deduce that our structure for IPv6 will simply use the SOCKADDR_IN6
structure, which is the equivalent of SOCKADDR
but for IPv6:
1
2
3
4
struct AFD_BIND_SOCKET6 {
uint32_t flags;
SOCKADDR_IN6 address;
}
Why so? Because the structure SOCKADDR_IN6
further defines sin6_flowinfo
and sin6_scope_id
between which our address is located, and this actually corresponds to what we see.
IOCTL for connect command
As with bind, it is useful to create code that generates valid calls to AfdConnect
, in which case we can skip the explicit bind
and do connect
straight away. This time, for an obvious reason, we will only focus on TCP.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iostream>
#pragma comment(lib,"Ws2_32.lib")
void createTCPv4() {
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s == INVALID_SOCKET) { std::cerr << WSAGetLastError() << '\n'; return; }
sockaddr_in dst{};
dst.sin_family = AF_INET;
dst.sin_port = htons(80);
InetPtonA(AF_INET, "192.168.1.1", &dst.sin_addr);
if (connect(s, reinterpret_cast<sockaddr*>(&dst), sizeof(dst)) == SOCKET_ERROR) {
std::cerr << "connect: " << WSAGetLastError() << '\n';
closesocket(s); return;
}
closesocket(s);
}
void createTCPv6() {/*SAME FOR TCPv6*/}
int main() {
std::cout << "PID: " << GetCurrentProcessId() << "\nPress <Enter> to continue..." << std::endl;
std::cin.get();
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa)) return 1;
createTCPv4();
createTCPv6();
WSACleanup();
return 0;
}
This is simple code to simply establish a connection to 192.168.1.1
on port 80
for IPv4 and ::1
on port 80
for IPv6.
Analyzing retrieved data
We will skip the data collection stage here, as it is identical to that of bind
, and go straight to presentation and analysis. It is worth starting with the fact that the IoControlCode
for AfdConnect
is 0x12007
, which again corresponds to what we have in (dmex). Below are the collected buffers:
Variant | Input buffer (hex) | Input length (hex) |
---|---|---|
TCP v4 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 f0 19 b5 c8 5c 02 00 00 02 00 00 50 c0 a8 01 01 00 00 00 00 00 00 00 00 |
0x28 |
TCP v6 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 a0 ed b5 c8 5c 02 00 00 17 00 00 50 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 |
0x34 |
In both cases, the matter seems quite simple when represented as an array in C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// IPv4
unsigned char inputv4[] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0xf0,0x19,0xb5,0xc8,0x5c,0x02,0x00,0x00,
// SOCKADDR
0x02,0x00, // AF_INET
0x00,0x50, // 80
0xc0,0xa8,0x01,0x01, // 127.0.0.1
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 // sin_zero
};
// IPv6
unsigned char inputv6[] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0xa0,0xed,0xb5,0xc8,0x5c,0x02,0x00,0x00,
// SOCKADDR_IN6
0x17,0x00, // AF_INET
0x00,0x50, // 80
0x00,0x00,0x00,0x00, // sin6_flowinfo
// ::1
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,
0x00,0x00,0x00,0x00 // sin6_scope_id
}
The presence of SOCKADDR_IN6
seems quite reasonable and simple to deduce from the contents of this buffer. The first three fields of our structure remain a mystery. Based on what is in (dmex) the first three fields are:
BOOLEAN SanActive
HANDLE RootEndpoint
HANDLE ConnectEndpoint
We know nothing more, we can guess by the names. It is possible that SAN
refers to Storage Area Network (missing reference), and the other two HANDLE
could indicate that AFD.sys
allows us to communicate directly with specific sockets, maybe within a process, maybe across multiple processes? Definitely a topic for further analysis. Interestingly, although in this case we have specified a value for ConnectEndpoint
, if we specify only zeros there, AFD.sys
will also accept such a buffer and perform the correct handshake.
We will certainly come back to this, it will be worth considering for malicious use!
Next steps
In next parts we will lean into sending and receiving data from our socket using the TCP protocol. While sending seems fairly straightforward, we will probably have to dig deeper.
Final code
Below you can find the full code that creates a socket without using any networking library. This definitely requires additional helpers to allow us to convert IP and port to the appropriate fields in SOCKADDR
, but without using functions from Winsock.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#include <stdint.h>
#include <Windows.h>
#include <winternl.h>
#include <iostream>
#include "afd_defs.h"
#include "afd_ioctl.h"
#pragma comment(lib, "ntdll.lib")
NTSTATUS createAfdSocket(PHANDLE socket) {...}
#define AFD_NORMALADDRUSE 0
#define AFD_REUSEADDRESS 1
#define AFD_WILDCARDADDRESS 2
#define AFD_EXCLUSIVEADDRUSE 3
struct AFD_BIND_SOCKET {
uint32_t flags;
SOCKADDR address;
};
NTSTATUS bindAfdSocket(HANDLE socket) {
AFD_BIND_SOCKET afdBindSocket = { 0 };
afdBindSocket.flags = AFD_NORMALADDRUSE;
afdBindSocket.address.sa_family = AF_INET;
// PORT == 27015
afdBindSocket.address.sa_data[0] = 0x69;
afdBindSocket.address.sa_data[1] = 0x87;
// ADDRESS == 127.0.0.1
afdBindSocket.address.sa_data[2] = 0x7F;
afdBindSocket.address.sa_data[3] = 0x00;
afdBindSocket.address.sa_data[4] = 0x00;
afdBindSocket.address.sa_data[5] = 0x01;
uint8_t outputBuffer[0x10];
IO_STATUS_BLOCK ioStatus;
NTSTATUS status = NtDeviceIoControlFile(socket, NULL, NULL, NULL, &ioStatus, IOCTL_AFD_BIND,
&afdBindSocket, sizeof(AFD_BIND_SOCKET),
outputBuffer, 0x00000010);
if (status == STATUS_PENDING) {
WaitForSingleObject(socket, INFINITE);
status = ioStatus.Status;
}
return status;
}
struct AFD_CONNECT_SOCKET {
uint64_t sanActive;
uint64_t rootEndpoint;
uint64_t connectEndpoint;
SOCKADDR address;
};
NTSTATUS connectAfdSocket(HANDLE socket) {
AFD_CONNECT_SOCKET afdConnectSocket = { 0 };
afdConnectSocket.sanActive = 0x00;
afdConnectSocket.rootEndpoint = 0x00;
afdConnectSocket.connectEndpoint = 0x00;
afdConnectSocket.address.sa_family = AF_INET;
// PORT == 80
afdConnectSocket.address.sa_data[0] = 0x00;
afdConnectSocket.address.sa_data[1] = 0x50;
// ADDRESS == 127.0.0.1
afdConnectSocket.address.sa_data[2] = 0x7F;
afdConnectSocket.address.sa_data[3] = 0x00;
afdConnectSocket.address.sa_data[4] = 0x00;
afdConnectSocket.address.sa_data[5] = 0x01;
IO_STATUS_BLOCK ioStatus;
NTSTATUS status = NtDeviceIoControlFile(socket, NULL, NULL, NULL, &ioStatus, IOCTL_AFD_CONNECT,
&afdConnectSocket, sizeof(AFD_CONNECT_SOCKET),
NULL, NULL);
if (status == STATUS_PENDING) {
WaitForSingleObject(socket, INFINITE);
status = ioStatus.Status;
}
return status;
}
int main() {
HANDLE socket;
NTSTATUS status = createAfdSocket(&socket);
if (!NT_SUCCESS(status)) {
std::cout << "[-] Could not create socket: " << std::hex << status << std::endl;
return 1;
}
std::cout << "[+] Socket created!" << std::endl;
status = bindAfdSocket(socket);
if (!NT_SUCCESS(status)) {
std::cout << "[-] Could not bind: " << std::hex << status << std::endl;
return 1;
}
std::cout << "[+] Socket bound!" << std::endl;
status = connectAfdSocket(socket);
if (!NT_SUCCESS(status)) {
std::cout << "[-] Could not connect: " << std::hex << status << std::endl;
return 1;
}
std::cout << "[+] Connected!" << std::endl;
return 0;
}
After executing this code, we can see that we are actually trying to set up a handshake from port 27015
to port 80
on localhost
:
References
- Vittitoe, Steven. “Reverse Engineering Windows AFD.sys: Uncovering the Intricacies of the Ancillary Function Driver.” Proceedings of REcon 2015, 2015, https://doi.org/10.5446/32819.
- killvxk. CVE-2024-38193 Nephster PoC. 2024, https://github.com/killvxk/CVE-2024-38193-Nephster/blob/main/Poc/poc.h.
- unknowncheats.me ICoded post. Native TCP Client Socket. n.d., https://www.unknowncheats.me/forum/c-and-c-/500413-native-tcp-client-socket.html.
- ReactOS Project. Afd.h. n.d., https://github.com/reactos/reactos/blob/master/drivers/network/afd/include/afd.h.
- DynamoRIO / Dr. Memory. afd_sharedḣ. n.d., https://github.com/DynamoRIO/drmemory/blob/master/wininc/afd_shared.h.
- Dr. Memory - GH issue#376. Issue #376: AFD Support Improvements. n.d., https://github.com/DynamoRIO/drmemory/issues/376.
- Microsoft. NtCreateFile Function (Winternl.h). n.d., https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntcreatefile.
- ---. x64 Calling Convention. n.d., https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170.
- ---. x64 Calling Convention. n.d., https://learn.microsoft.com/pl-pl/windows/win32/api/winsock2/nf-winsock2-wsasocketa.
- DeDf. AFD Repository. n.d., https://github.com/DeDf/afd/tree/master.
- Allievi, Andrea, et al. Windows® Internals Part 2 - 6th Edition. 6th ed., Microsoft Press (Pearson Education), 2022, https://learn.microsoft.com/sysinternals/resources/windows-internals.
- dmex. \Textttntafd.h – Ancillary Function Driver Definitions. commit 2dda0dd, System Informer / Winsider Seminars & Solutions, Inc., April 2025, https://github.com/winsiderss/systeminformer/blob/master/phnt/include/ntafd.h.