452 lines
14 KiB
Python
452 lines
14 KiB
Python
#
|
|
# RDP file upload utility via Keyboard emulation.
|
|
# Uploads specified input file or directory, encodes it and retypes encoded contents
|
|
# by emulating keyboard keypresses into previously focused RDP session window.
|
|
# That will effectively transmit contents of the file onto the remote host without use
|
|
# of any sort of built-in file upload functionality. Remote desktop protocols such as
|
|
# RDP/VNC could be abused in this way by smuggling to the connected host implant files, etc.
|
|
#
|
|
# In case a directory was specified on input, will recursively add every file from that directory
|
|
# and create a Zip archive that will be later uploaded.
|
|
#
|
|
# Mouse movements will suspend file upload process.
|
|
#
|
|
# Average transfer bandwidths largely depend on your connectivity performance and system utilization.
|
|
# I've experienced following:
|
|
# - transfer to the Citrix Receiver RDP session: 40-60 bytes/s
|
|
# - transfer to LAN RDP session RDP session: 400-800 bytes/s
|
|
#
|
|
# Requirements:
|
|
# - pyautogui
|
|
# - tqdm
|
|
#
|
|
# Author:
|
|
# Mariusz B. / mgeeky (@mariuszbit), '20
|
|
# <mb [at] binary-offensive.com>
|
|
#
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import hashlib
|
|
import base64
|
|
import zipfile
|
|
import argparse
|
|
from io import BytesIO
|
|
|
|
try:
|
|
import pyautogui
|
|
except ImportError:
|
|
print('[!] Module "pyautogui" not found. Install it using: pip3 install pyautogui')
|
|
sys.exit(1)
|
|
|
|
try:
|
|
import tqdm
|
|
except ImportError:
|
|
print('[!] Module "tqdm" not found. Install it using: pip3 install tqdm')
|
|
sys.exit(1)
|
|
|
|
config = {
|
|
'debug': True,
|
|
'verbose': True,
|
|
'wait' : 10,
|
|
'interval' : 5,
|
|
'delay' : 0.5,
|
|
'base64' : 10,
|
|
'zip' : 10,
|
|
'chunk' : 256,
|
|
|
|
# Specifies what's the maximum mouse cursor position offset deviation (in pixels)
|
|
# that will be tolerated and won't interrupt file upload/retype loop.
|
|
# If mouse cursor will leave the N x N rectangle, we'll stop uploading/retyping.
|
|
'maxMouseDeviationInPixels' : 50,
|
|
|
|
'file' : '',
|
|
'format': '',
|
|
}
|
|
|
|
outputFormats = {
|
|
'raw',
|
|
'certutil',
|
|
}
|
|
|
|
fileWasEncoded = False
|
|
progressBar = None
|
|
|
|
class Logger:
|
|
@staticmethod
|
|
def _out(x):
|
|
sys.stdout.write(x + '\n')
|
|
|
|
@staticmethod
|
|
def verbose(x):
|
|
if config['verbose']:
|
|
Logger._out('[*] ' + x)
|
|
|
|
@staticmethod
|
|
def info(x):
|
|
Logger._out('[.] ' + x)
|
|
|
|
@staticmethod
|
|
def dbg(x):
|
|
if config['debug']:
|
|
Logger._out('[dbg] ' + x)
|
|
|
|
@staticmethod
|
|
def err(x):
|
|
sys.stdout.write('[!] ' + x + '\n')
|
|
|
|
@staticmethod
|
|
def fail(x):
|
|
Logger._out('[-] ' + x)
|
|
|
|
@staticmethod
|
|
def ok(x):
|
|
Logger._out('[+] ' + x)
|
|
|
|
class InMemoryZip(object):
|
|
# Source:
|
|
# - https://www.kompato.com/post/43805938842/in-memory-zip-in-python
|
|
# - https://stackoverflow.com/a/2463818
|
|
|
|
def __init__(self):
|
|
self.in_memory_zip = BytesIO()
|
|
|
|
def append(self, filename_in_zip, file_contents):
|
|
zf = zipfile.ZipFile(self.in_memory_zip, "a", zipfile.ZIP_DEFLATED, False)
|
|
zf.writestr(filename_in_zip, file_contents)
|
|
for zfile in zf.filelist:
|
|
zfile.create_system = 0
|
|
|
|
return self
|
|
|
|
def read(self):
|
|
self.in_memory_zip.seek(0)
|
|
return self.in_memory_zip.read()
|
|
|
|
def fetch_files(rootdir):
|
|
imz = InMemoryZip()
|
|
for folder, subs, files in os.walk(rootdir):
|
|
for filename in files:
|
|
real_path = os.path.join(folder, filename)
|
|
with open(real_path, 'rb') as src:
|
|
zip_path = real_path.replace(rootdir + '/', '')
|
|
imz.append(zip_path, src.read())
|
|
|
|
return imz.read()
|
|
|
|
def reformatOutput(contents):
|
|
out = contents
|
|
if config['format'] == 'certutil':
|
|
out = b'-----BEGIN CERTIFICATE-----\r\n'
|
|
for chunk in [contents[i:i + 64] for i in range(0, len(contents), 64)]:
|
|
out += chunk + b'\r\n'
|
|
|
|
out += b'-----END CERTIFICATE-----\r\n'
|
|
|
|
try:
|
|
Logger.dbg(f'''After reformatting output, data looks as follows:
|
|
----------------------------------------------------------------
|
|
{out}
|
|
----------------------------------------------------------------
|
|
|
|
''')
|
|
except: pass
|
|
|
|
return out
|
|
|
|
def checkChar(a):
|
|
if a >= 0x20 or a <= 0x7f:
|
|
return True
|
|
|
|
if a in [0x0a, 0x0d, 9]:
|
|
return True
|
|
|
|
return False
|
|
|
|
def encodeFile(filePath, contents, dontZip):
|
|
global fileWasEncoded
|
|
encoded = contents
|
|
|
|
if config['zip'] and not dontZip:
|
|
imz = InMemoryZip()
|
|
imz.append(os.path.basename(filePath), contents)
|
|
encoded = imz.read()
|
|
|
|
if config['base64'] or config['format'] == 'certutil':
|
|
encoded = base64.b64encode(encoded)
|
|
|
|
try:
|
|
Logger.dbg(f'''After encoding data:
|
|
----------------------------------------------------------------
|
|
{encoded}
|
|
----------------------------------------------------------------
|
|
|
|
''')
|
|
except: pass
|
|
|
|
out = reformatOutput(encoded)
|
|
|
|
for a in out:
|
|
if not checkChar(a):
|
|
Logger.err(f'After encoding file/directory contents we resulted with binary data ({a}). That\'s not supported.')
|
|
return None
|
|
|
|
fileWasEncoded = contents != out
|
|
return out.decode('utf-8', 'ignore')
|
|
|
|
def splitFile(contents, chunkSize):
|
|
for i in range(0, len(contents), chunkSize):
|
|
yield contents[i:i+chunkSize]
|
|
|
|
def flush():
|
|
time.sleep(2)
|
|
sys.stdout.flush()
|
|
sys.stderr.flush()
|
|
|
|
def progress(pos, total):
|
|
t = tqdm.tqdm(total=total, unit='characters')
|
|
if pos > 0: t.update(pos)
|
|
return t
|
|
|
|
def retypeFile(contents):
|
|
global progressBar
|
|
|
|
retypeMousePos = pyautogui.position()
|
|
pyautogui.click()
|
|
|
|
Logger.verbose(f'Mouse position of assumed RDP session window: {retypeMousePos}')
|
|
Logger._out('')
|
|
|
|
progressBar = progress(0, len(contents))
|
|
|
|
for chunk in splitFile(contents, config['chunk']):
|
|
prevPos = pyautogui.position()
|
|
|
|
if abs(prevPos.x - retypeMousePos.x) > config['maxMouseDeviationInPixels'] or \
|
|
abs(prevPos.y - retypeMousePos.y) > config['maxMouseDeviationInPixels']:
|
|
|
|
msg = "[?] Mouse cursor was moved away from initially focused position. File upload PAUSED.\n"
|
|
msg += " Press ENTER to resume upload or Ctrl-C to interrupt.\n"
|
|
|
|
progressBar.write(msg)
|
|
|
|
input('')
|
|
|
|
progressBar.clear()
|
|
progressBar.write(f"\n[?] Position your mouse cursor at the end of written text. Waiting {config['wait']} seconds to resume...\n")
|
|
if config['wait'] > 0: time.sleep(config['wait'])
|
|
|
|
retypeMousePos = pyautogui.position()
|
|
pyautogui.click()
|
|
progressBar.clear()
|
|
progressBar.unpause()
|
|
progressBar.refresh()
|
|
|
|
pyautogui.write(chunk, interval = (float(config['interval']) / 1000.0))
|
|
progressBar.update(len(chunk))
|
|
|
|
if config['delay'] > 0: time.sleep(config['delay'])
|
|
|
|
flush()
|
|
Logger._out('')
|
|
|
|
def printInstructions(hash1, hash2, filePath):
|
|
additional = ''
|
|
basename = os.path.basename(filePath)
|
|
inputFile = basename + '.txt' if fileWasEncoded else basename
|
|
output = basename
|
|
|
|
if config['zip']:
|
|
output = basename + '.zip'
|
|
|
|
if config['format'] == 'certutil':
|
|
inputFile = basename + '.b64'
|
|
additional = f'''
|
|
*) Base64 decode file using certutil:
|
|
cmd> certutil -decode {inputFile} {output}
|
|
'''
|
|
|
|
elif config['format'] != 'certutil' and config['base64']:
|
|
inputFile = basename + '.b64'
|
|
additional = f'''
|
|
*) Base64 decode file:
|
|
$ cat {inputFile} | base64 -d > {output}
|
|
or
|
|
cmd> powershell -c "[IO.File]::WriteAllBytes('{output}', [Convert]::FromBase64String([IO.File]::ReadAllText('{inputFile}')))"
|
|
'''
|
|
|
|
if config['zip']:
|
|
additional += f'''
|
|
*) Unzip resulting file:
|
|
$ unzip -d . {output}
|
|
or
|
|
PS> Expand-Archive -Path .\\{output} -Dest .
|
|
'''
|
|
|
|
if hash1 != hash2:
|
|
additional += f'''
|
|
*) Verify MD5 sum of final form of uploaded file to expected original value {hash1}:
|
|
$ md5sum {output}
|
|
or
|
|
PS> Get-FileHash .\\{output} -Algorithm MD5
|
|
'''
|
|
|
|
Logger.verbose(f'''
|
|
================================================================
|
|
B) After file was uploaded, next steps are:
|
|
|
|
*) Using your text editor: save the file in a remote system as "{inputFile}"
|
|
|
|
*) Verify MD5 sum of retyped file to base value {hash2}:
|
|
$ md5sum {inputFile}
|
|
or
|
|
PS> Get-FileHash .\\{inputFile} -Algorithm MD5
|
|
{additional}
|
|
''')
|
|
|
|
def parseOptions(argv):
|
|
global config
|
|
|
|
print('''
|
|
:: RDP file upload utility via Keyboard emulation.
|
|
Takes an input file/folder and retypes it into focused RDP session window.
|
|
That effectively uploads the file into remote host over a RDP channel.
|
|
|
|
Mariusz B. / mgeeky '20, (@mariuszbit)
|
|
<mb@binary-offensive.com>
|
|
''')
|
|
|
|
parser = argparse.ArgumentParser(prog = argv[0], usage='%(prog)s [options] <inputFile>')
|
|
|
|
parser.add_argument('inputFile', help='Input file or directory to upload. In case of directory - all files will get zipped recursively and resulting zip file will be uploaded.')
|
|
|
|
parser.add_argument('-v', '--verbose', action='store_true', help='Displays verbose output containing field steps to follow.')
|
|
parser.add_argument('-D', '--debug', action='store_true', help='Display debug output.')
|
|
|
|
parser.add_argument('-f', '--format', choices=outputFormats, default='raw', help=f'Specifies into which format retype input file. Default: retype the file as is. "certutil" format implies --base64')
|
|
|
|
timing = parser.add_argument_group('Timing & Performance', 'Adjusts settings impacting program\'s "upload" efficiency')
|
|
timing.add_argument('-w', '--wait', type=int, default=config['wait'], help=f'Hold on before we start retyping file contents for this long (in seconds). Default: {config["wait"]} seconds.')
|
|
timing.add_argument('-i', '--interval', type=int, default=config['interval'], help=f'Adjusts inter-key press interval (in milliseconds). Default: {config["interval"]} miliseconds.')
|
|
timing.add_argument('-d', '--delay', type=int, default=config['delay'], help=f'Every next chunk (of size {config["chunk"]} bytes) wait this amount of time. Default: {config["delay"]} miliseconds.')
|
|
|
|
encoding = parser.add_argument_group('Encoding', 'Controls how to encode the file before retyping it')
|
|
encoding.add_argument('-b', '--base64', action='store_true', help='Encode and then retype base64 encoded file contents.')
|
|
encoding.add_argument('-z', '--zip', action='store_true', help='Zip file contents before retyping them. If used with --base64 will retype results of base64(zip(file))')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not hasattr(args, 'inputFile') or args.inputFile is None:
|
|
parser.print_help()
|
|
return None
|
|
|
|
config['verbose'] = args.verbose
|
|
config['debug'] = args.debug
|
|
|
|
if args.debug: config['verbose'] = True
|
|
|
|
config['interval'] = args.interval
|
|
config['format'] = args.format
|
|
config['wait'] = args.wait
|
|
config['zip'] = args.zip
|
|
config['base64'] = args.base64
|
|
|
|
if args.interval < 5:
|
|
Logger.fail('WARNING: Setting too low inter-key press interval may result in keys being lost in the transit!')
|
|
Logger.fail(' Be sure to verify uploaded file\'s md5 checksum!\n')
|
|
|
|
return args
|
|
|
|
def main(argv):
|
|
opts = parseOptions(argv)
|
|
|
|
if not opts:
|
|
return False
|
|
|
|
contents = None
|
|
dontZip = False
|
|
t = 'file'
|
|
|
|
try:
|
|
if os.path.isfile(opts.inputFile):
|
|
with open(opts.inputFile, 'rb') as f:
|
|
contents = f.read()
|
|
|
|
elif os.path.isdir(opts.inputFile):
|
|
contents = fetch_files(rootdir)
|
|
|
|
t = 'directory'
|
|
dontZip = True
|
|
|
|
else:
|
|
Logger.err("Specified input file is neither a file nor directory (or it doesn't exist)!")
|
|
return False
|
|
|
|
except:
|
|
Logger.err(f'Could not open file for reading: "{opts.inputFile}"')
|
|
return False
|
|
|
|
if contents == None or len(contents) == 0:
|
|
Logger.fail("Specified file/directory was empty.")
|
|
return False
|
|
|
|
encoded = encodeFile(opts.inputFile, contents, dontZip)
|
|
|
|
if encoded == None or len(encoded) == 0:
|
|
Logger.fail("No encoded data to upload.")
|
|
return False
|
|
|
|
Logger.ok(f'Will upload {t}\'s contents: "{opts.inputFile}"\n')
|
|
|
|
hash1 = hashlib.md5(contents).hexdigest()
|
|
hash2 = hashlib.md5(encoded.encode()).hexdigest()
|
|
|
|
Logger.ok('MD5 checksum of file to be uploaded: ' + hash1)
|
|
Logger.ok('MD5 checksum of encoded data to be retyped: ' + hash2)
|
|
Logger.info(f'Size of input {t}: {len(contents)} - keys to retype: {len(encoded)}')
|
|
Logger.verbose(f'Inter-key press interval: {opts.interval} miliseconds.')
|
|
Logger.verbose(f'Every chunk cooldown delay: {1000*opts.delay} miliseconds.')
|
|
del contents
|
|
|
|
Logger.verbose('''
|
|
================================================================
|
|
A) How to proceed now:
|
|
|
|
1) In your RDP session, spawn a text editor (notepad, vim)
|
|
2) Click inside of a text area as you were about to write something.
|
|
3) Leave your mouse cursor in that RDP session window (client) having that window focused
|
|
''')
|
|
|
|
Logger.info('Do not use your mouse/keyboard until file upload is completed!\n')
|
|
Logger.ok('We\'re about to initiate upload process.')
|
|
|
|
try:
|
|
Logger.info(f'Waiting {config["wait"]} seconds before we begin...\n')
|
|
|
|
time.sleep(opts.wait)
|
|
|
|
Logger.ok('Starting file retype/upload...')
|
|
|
|
retypeFile(encoded)
|
|
|
|
Logger._out('')
|
|
Logger.ok("FILE UPLOADED.")
|
|
|
|
printInstructions(hash1, hash2, opts.inputFile)
|
|
|
|
except KeyboardInterrupt:
|
|
progressBar.clear()
|
|
flush()
|
|
Logger._out('')
|
|
Logger.fail("FILE WAS NOT FULLY UPLOADED. User has interrupted file retype/upload process!\n")
|
|
|
|
return False
|
|
|
|
flush()
|
|
progressBar.clear()
|
|
return True
|
|
|
|
if __name__ == '__main__':
|
|
main(sys.argv)
|