#!/usr/bin/env python3 # Impacket - Collection of Python classes for working with network protocols. # # Copyright (C) 2023 Fortra. All rights reserved. # # This software is provided under a slightly modified version # of the Apache Software License. See the accompanying LICENSE file # for more information. # # Description: # Terminal Services manipulation tool. # Initial idea was to provide similar functionality as the QWINSTA and other TS* windows commands: # # qwinsta: Display information about Remote Desktop Services sessions. # tasklist: Display a list of currently running processes on the system. # taskkill: Terminate tasks by process id (PID) or image name # tscon: Attaches a user session to a remote desktop session # tsdiscon: Disconnects a Remote Desktop Services session # tslogoff: Signs-out a Remote Desktop Services session # shutdown: Remote shutdown # msg: Send a message to Remote Desktop Services session (MSGBOX) # # Author: # Alexander Korznikov (@nopernik) # # Reference for: # [MS-TSTS] # import argparse import codecs import logging import sys from struct import unpack from impacket import version from impacket.examples import logger from impacket.examples.utils import parse_target from impacket.smbconnection import SMBConnection from impacket import LOG from impacket.dcerpc.v5 import transport from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY from impacket.dcerpc.v5 import tsts as TSTS import traceback class TSHandler: def __init__(self, username, password, domain, options): self.__username = username self.__password = password self.__domain = domain self.__options = options self.__action = options.action.lower() self.__lmhash = '' self.__nthash = '' self.__aesKey = options.aesKey self.__doKerberos = options.k self.__kdcHost = options.dc_ip self.__smbConnection = None self.__remoteOps = None if options.hashes is not None: self.__lmhash, self.__nthash = options.hashes.split(':') def connect(self, remoteName, remoteHost): self.__smbConnection = SMBConnection(remoteName, remoteHost, sess_port=int(self.__options.port)) if self.__doKerberos: self.__smbConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, self.__aesKey, self.__kdcHost) else: self.__smbConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash) def run(self, remoteName, remoteHost): if self.__options.action == 'shutdown': if not max([options.logoff, options.shutdown, options.reboot, options.poweroff]): LOG.error('At least one flag is required: -logoff, -shutdown, -reboot or -poweroff') exit(1) self.connect(remoteName, remoteHost) getattr(self,'do_'+self.__action)() def get_session_list(self): # Retreive session list smb = self.__smbConnection target_ip = self.__options.target_ip with TSTS.TermSrvEnumeration(self.__smbConnection, self.__options.target_ip) as lsm: handle = lsm.hRpcOpenEnum() rsessions = lsm.hRpcGetEnumResult(handle, Level=1)['ppSessionEnumResult'] lsm.hRpcCloseEnum(handle) self.sessions = {} for i in rsessions: sess = i['SessionInfo']['SessionEnum_Level1'] state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess['State']).split('_')[-1] self.sessions[sess['SessionId']] = { 'state' :state, 'SessionName' :sess['Name'], 'RemoteIp' :'', 'ClientName' :'', 'Username' :'', 'Domain' :'', 'Resolution' :'', 'ClientTimeZone':'' } def enumerate_sessions_config(self): # Get session config one by one smb = self.__smbConnection target_ip = self.__options.target_ip if len(self.sessions): with TSTS.RCMPublic(self.__smbConnection, self.__options.target_ip) as termsrv: for SessionId in self.sessions: resp = termsrv.hRpcGetClientData(SessionId) if resp is not None: self.sessions[SessionId]['RemoteIp'] = resp['ppBuff']['ClientAddress'] self.sessions[SessionId]['ClientName'] = resp['ppBuff']['ClientName'] if len(resp['ppBuff']['UserName']) and not len(self.sessions[SessionId]['Username']): self.sessions[SessionId]['Username'] = resp['ppBuff']['UserName'] if len(resp['ppBuff']['Domain']) and not len(self.sessions[SessionId]['Domain']): self.sessions[SessionId]['Domain'] = resp['ppBuff']['Domain'] self.sessions[SessionId]['Resolution'] = '{}x{}'.format( resp['ppBuff']['HRes'], resp['ppBuff']['VRes'] ) self.sessions[SessionId]['ClientTimeZone'] = resp['ppBuff']['ClientTimeZone']['StandardName'] def enumerate_sessions_info(self): # Get session info one by one smb = self.__smbConnection target_ip = self.__options.target_ip if len(self.sessions): with TSTS.TermSrvSession(self.__smbConnection, self.__options.target_ip) as TermSrvSession: for SessionId in self.sessions.keys(): sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId) sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['SessionFlags']) self.sessions[SessionId]['flags'] = sessflags domain = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DomainName'] if not len(self.sessions[SessionId]['Domain']) and len(domain): self.sessions[SessionId]['Domain'] = domain username = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['UserName'] if not len(self.sessions[SessionId]['Username']) and len(username): self.sessions[SessionId]['Username'] = username self.sessions[SessionId]['ConnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['ConnectTime'] self.sessions[SessionId]['DisconnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DisconnectTime'] self.sessions[SessionId]['LogonTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LogonTime'] self.sessions[SessionId]['LastInputTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LastInputTime'] def do_qwinsta(self): options = self.__options desktop_states = { 'WTS_SESSIONSTATE_UNKNOWN': '', 'WTS_SESSIONSTATE_LOCK' : 'Locked', 'WTS_SESSIONSTATE_UNLOCK' : 'Unlocked', } self.get_session_list() if not len(self.sessions): print('No sessions found...') return self.enumerate_sessions_info() if options.verbose: self.enumerate_sessions_config() maxSessionNameLen = max([len(self.sessions[i]['SessionName'])+1 for i in self.sessions]) maxSessionNameLen = maxSessionNameLen if len('SESSIONNAME') < maxSessionNameLen else len('SESSIONNAME')+1 # maxUsernameLen = max([len(self.sessions[i]['Username'])+1 for i in self.sessions]) maxUsernameLen = max([len(self.sessions[i]['Username']+self.sessions[i]['Domain'])+1 for i in self.sessions])+1 maxUsernameLen = maxUsernameLen if len('Username') < maxUsernameLen else len('Username')+1 maxIdLen = max([len(str(i)) for i in self.sessions]) maxIdLen = maxIdLen if len('ID') < maxIdLen else len('ID')+1 maxStateLen = max([len(self.sessions[i]['state'])+1 for i in self.sessions]) maxStateLen = maxStateLen if len('STATE') < maxStateLen else len('STATE')+1 maxRemoteIp = max([len(self.sessions[i]['RemoteIp'])+1 for i in self.sessions]) maxRemoteIp = maxRemoteIp if len('RemoteAddress') < maxRemoteIp else len('RemoteAddress')+1 maxClientName = max([len(self.sessions[i]['ClientName'])+1 for i in self.sessions]) maxClientName = maxClientName if len('ClientName') < maxClientName else len('ClientName')+1 template = ('{SESSIONNAME: <%d} ' '{USERNAME: <%d} ' '{ID: <%d} ' '{STATE: <%d} ' '{DSTATE: <9} ' '{CONNTIME: <20} ' '{DISCTIME: <20} ') % (maxSessionNameLen, maxUsernameLen, maxIdLen, maxStateLen) template_verbose = ('{CLIENTNAME: <%d} ' '{REMOTEIP: <%d} ' '{RESOLUTION: <11} ' '{TIMEZONE: <15}') % (maxClientName,maxRemoteIp) result = [] header = template.format( SESSIONNAME = 'SESSIONNAME', USERNAME = 'USERNAME', ID = 'ID', STATE = 'STATE', DSTATE = 'Desktop', CONNTIME = 'ConnectTime', DISCTIME = 'DisconnectTime', ) header2 = template.replace(' <','=<').format( SESSIONNAME = '', USERNAME = '', ID = '', STATE = '', DSTATE = '', CONNTIME = '', DISCTIME = '', ) header_verbose = '' header2_verbose = '' if options.verbose: header_verbose = template_verbose.format( CLIENTNAME = 'ClientName', REMOTEIP = 'RemoteAddress', RESOLUTION = 'Resolution', TIMEZONE = 'ClientTimeZone' ) header2_verbose = template_verbose.replace(' <','=<').format( CLIENTNAME = '', REMOTEIP = '', RESOLUTION = '', TIMEZONE = '' ) result.append(header+header_verbose) result.append(header2+header2_verbose+'\n') for i in self.sessions: connectTime = self.sessions[i]['ConnectTime'] connectTime = connectTime.strftime(r'%Y/%m/%d %H:%M:%S') if connectTime.year > 1601 else 'None' disconnectTime = self.sessions[i]['DisconnectTime'] disconnectTime = disconnectTime.strftime(r'%Y/%m/%d %H:%M:%S') if disconnectTime.year > 1601 else 'None' userName = self.sessions[i]['Domain'] + '\\' + self.sessions[i]['Username'] if len(self.sessions[i]['Username']) else '' row = template.format( SESSIONNAME = self.sessions[i]['SessionName'], USERNAME = userName, ID = i, STATE = self.sessions[i]['state'], DSTATE = desktop_states[self.sessions[i]['flags']], CONNTIME = connectTime, DISCTIME = disconnectTime, ) row_verbose = '' if options.verbose: row_verbose = template_verbose.format( CLIENTNAME = self.sessions[i]['ClientName'], REMOTEIP = self.sessions[i]['RemoteIp'], RESOLUTION = self.sessions[i]['Resolution'], TIMEZONE = self.sessions[i]['ClientTimeZone'] ) result.append(row+row_verbose) for row in result: print(row) def do_tasklist(self): options = self.__options with TSTS.LegacyAPI(self.__smbConnection, options.target_ip) as legacy: handle = legacy.hRpcWinStationOpenServer() r = legacy.hRpcWinStationGetAllProcesses(handle) if not len(r): return None maxImageNameLen = max([len(i['ImageName']) for i in r]) maxSidLen = max([len(i['pSid']) for i in r]) if options.verbose: self.get_session_list() self.enumerate_sessions_config() maxUserNameLen = max([len(self.sessions[i]['Username']+self.sessions[i]['Domain'])+1 for i in self.sessions])+1 if maxUserNameLen < 11: maxUserNameLen = 11 template = ('{imagename: <%d} ' '{pid: <6} ' '{sessid: <6} ' '{sessionName: <16} ' '{sessstate: <11} ' '{sessionuser: <%d} ' '{sid: <%d} ' '{workingset: <12}') % (maxImageNameLen, maxUserNameLen, maxSidLen) print(template.format(imagename = 'Image Name', pid = 'PID', sessionName = 'SessName', sessid = 'SessID', sessionuser = 'SessUser', sessstate = 'State', sid = 'SID', workingset = 'Mem Usage' ) ) print(template.replace(' <','=<').format(imagename = '', pid = '', sessionName = '', sessid = '', sessionuser = '', sessstate = '', sid = '', workingset = '' )+'\n' ) for procInfo in r: sessId = procInfo['SessionId'] fullUserName = '' if len(self.sessions[sessId]['Domain']): fullUserName += self.sessions[sessId]['Domain'] + '\\' if len(self.sessions[sessId]['Username']): fullUserName += self.sessions[sessId]['Username'] row = template.replace('{workingset: <12}','{workingset: >10,} K').format( imagename = procInfo['ImageName'], pid = procInfo['UniqueProcessId'], sessionName = self.sessions[sessId]['SessionName'], sessid = procInfo['SessionId'], sessstate = self.sessions[sessId]['state'].replace('Disconnected','Disc'), sid = procInfo['pSid'], sessionuser = fullUserName, workingset = procInfo['WorkingSetSize']//1000 ) print(row) else: template = '{: <%d} {: <8} {: <11} {: <%d} {: >12}' % (maxImageNameLen, maxSidLen) print(template.format('Image Name', 'PID', 'Session#', 'SID', 'Mem Usage')) print(template.replace(': ',':=').format('','','','','')+'\n') for procInfo in r: row = template.format( procInfo['ImageName'], procInfo['UniqueProcessId'], procInfo['SessionId'], procInfo['pSid'], '{:,} K'.format(procInfo['WorkingSetSize']//1000), ) print(row) def do_taskkill(self): options = self.__options if options.pid is None and options.name is None: LOG.error('One of the following is required: -pid, -name') return pidList = [] with TSTS.LegacyAPI(self.__smbConnection, options.target_ip) as legacy: handle = legacy.hRpcWinStationOpenServer() if options.pid is None and options.name is not None: r = legacy.hRpcWinStationGetAllProcesses(handle) if not len(r): LOG.error('Could not get process list') return pidList = [i['UniqueProcessId'] for i in r if i['ImageName'].lower() == options.name.lower()] if not len(pidList): LOG.error('Could not find %r in process list' % options.name) return else: pidList = [options.pid] for pid in pidList: print('Terminating PID: %d ...' % pid, end='') try: if legacy.hRpcWinStationTerminateProcess(handle, pid)['ErrorCode']: print('OK') else: print('FAIL') except Exception as e: LOG.error('Error terminating pid: %d' % pid) LOG.error(str(e)) def do_tscon(self): options = self.__options with TSTS.TermSrvSession(self.__smbConnection, options.target_ip) as TSSession: try: session_handle = None print('Connecting SessionID %d to %d ...' % (options.source, options.dest), end='') try: session_handle = TSSession.hRpcOpenSession(options.source) except Exception as e: print('FAIL') if e.error_code == 0x80070002: LOG.error('Could not find source SessionID: %d' % options.source) else: LOG.error(str(e)) return if TSSession.hRpcConnect(hSession = session_handle, TargetSessionId = options.dest, Password = options.password)['ErrorCode'] == 0: print('OK') else: print('FAIL') except Exception as e: print('FAIL') if e.error_code == 0x80070002: LOG.error('Could not find destination SessionID: %d' % options.dest) elif e.error_code == 0x8007139f: LOG.error('Session in the invalid state. Did you mean %d -> %d?' % (options.dest, options.source)) else: LOG.error(str(e)) def do_tsdiscon(self): options = self.__options with TSTS.TermSrvSession(self.__smbConnection, options.target_ip) as TSSession: try: print('Disconnecting SessionID: %d ...' % options.session, end='') session_handle = TSSession.hRpcOpenSession(options.session) if TSSession.hRpcDisconnect(session_handle)['ErrorCode'] == 0: print('OK') else: print('FAIL') except Exception as e: print('FAIL') if e.error_code == 1: LOG.error('Maybe it is already disconnected?') elif e.error_code == 0x80070002: LOG.error('Could not find SessionID: %d' % options.session) else: LOG.error(str(e)) def do_logoff(self): options = self.__options with TSTS.TermSrvSession(self.__smbConnection, options.target_ip) as TSSession: try: print('Signing-out SessionID: %d ...' % options.session, end='') session_handle = TSSession.hRpcOpenSession(options.session) if TSSession.hRpcLogoff(session_handle)['ErrorCode'] == 0: print('OK') else: print('FAIL') except Exception as e: if e.error_code == 0x10000000: print('OK') return print('FAIL') if e.error_code == 0x80070002: LOG.error('Could not find SessionID: %d' % options.session) else: LOG.error(str(e)) def do_shutdown(self): options = self.__options with TSTS.LegacyAPI(self.__smbConnection, options.target_ip) as legacy: handle = legacy.hRpcWinStationOpenServer() flags = 0 flagsList = [] ShutdownFlags = [options.logoff, options.shutdown, options.reboot, options.poweroff] for k,v in zip(ShutdownFlags, ['logoff', 'shutdown', 'reboot', 'poweroff']): if k: flagsList.append(v) flagsList = '|'.join(flagsList) for k,v in zip(ShutdownFlags, [1,2,4,8]): if k: flags |= v try: print('Sending shutdown (%s) event ...' % (flagsList), end='') resp = legacy.hRpcWinStationShutdownSystem(handle, 0, flags) if resp['ErrorCode']: print('OK') else: resp.dump() print('FAIL') except Exception as e: print('FAIL') LOG.error(str(e)) def do_msg(self): options = self.__options with TSTS.TermSrvSession(self.__smbConnection, options.target_ip) as TSSession: try: print('Sending message to SessionID: %d ...' % options.session, end='') session_handle = TSSession.hRpcOpenSession(options.session) if TSSession.hRpcShowMessageBox(session_handle, options.title, options.message)['ErrorCode'] == 0: print('OK') else: print('FAIL') except Exception as e: print('FAIL') if e.error_code == 0x80070002: LOG.error('Could not find SessionID: %d' % options.session) else: LOG.error(str(e)) if __name__ == '__main__': # Init the example's logger theme logger.init() # Explicitly changing the stdout encoding format if sys.stdout.encoding is None: # Output is redirected to a file sys.stdout = codecs.getwriter('utf8')(sys.stdout) print(version.BANNER) parser = argparse.ArgumentParser(add_help=True, description="Terminal Services manipulation tool.") parser.add_argument('target', action='store', help='[[domain/]username[:password]@]') parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') subparsers = parser.add_subparsers(help='actions', dest='action') # qwinsta: Display information about Remote Desktop Services sessions. qwinsta_parser = subparsers.add_parser('qwinsta', help='Display information about Remote Desktop Services sessions.') qwinsta_parser.add_argument('-v', action='store_true', dest='verbose', help='Turn VERBOSE output ON') # tasklist: Display a list of currently running processes on the system. tasklist_parser = subparsers.add_parser('tasklist', help='Display a list of currently running processes on the system.') tasklist_parser.add_argument('-v', action='store_true', dest='verbose', help='Turn VERBOSE output ON') # taskkill: Terminate tasks by process id (PID) or image name taskkill_parser = subparsers.add_parser('taskkill', help='Terminate tasks by process id (PID) or image name.') taskkill_parser.add_argument('-pid', action='store', metavar="PID", type=int, help='Specifies process id (PID)') taskkill_parser.add_argument('-name', action='store', help='Specifies process name (ImageName). Internally it will' 'execute tasklist to retrieve PID by ImageName.') # tscon: Attaches a user session to a remote desktop session tscon_parser = subparsers.add_parser('tscon', help='Attaches a user session to a remote desktop session.') tscon_parser.add_argument('-source', action='store', metavar="SessionID", type=int, required=True, help='Source SessionId') tscon_parser.add_argument('-dest', action='store', metavar="SessionID", type=int, required=True, help='Destination SessionId') tscon_parser.add_argument('-password', action='store', type=str, required=False, help='Destination Session\'s password') # tsdiscon: Disconnects a Remote Desktop Services session tsdiscon_parser = subparsers.add_parser('tsdiscon', help='Disconnects a Remote Desktop Services session.') tsdiscon_parser.add_argument('-session', action='store', metavar="SessionID", type=int, required=True, help='SessionId to disconnect') # logoff: Sign out a Remote Desktop Services session logoff_parser = subparsers.add_parser('logoff', help='Sign out a Remote Desktop Services session.') logoff_parser.add_argument('-session', action='store', metavar="SessionID", type=int, required=True, help='SessionId to sign out') # shutdown: Remote shutdown shutdown_parser = subparsers.add_parser('shutdown', help='Remote shutdown, affects ALL sessions and logged-in users!', description="Send Remote Shutdown event. Affects ALL sessions and logged-in users!") shutdown_parser_group = shutdown_parser.add_argument_group('Shutdown Flags [Multiple Choice]') shutdown_parser_group.add_argument('-logoff', action='store_true', help='Forces sessions to logoff.') shutdown_parser_group.add_argument('-shutdown', action='store_true', help='Shuts down the system.') shutdown_parser_group.add_argument('-reboot', action='store_true', help='Reboots after shutdown.') shutdown_parser_group.add_argument('-poweroff', action='store_true', help='Powers off after shutdown.') # msg: Send a message to Remote Desktop Services session (MSGBOX) msg_parser = subparsers.add_parser('msg', help='Send a message to Remote Desktop Services session (MSGBOX).') msg_parser.add_argument('-session', action='store', metavar="SessionID", type=int, required=True, help='Receiver SessionId') msg_parser.add_argument('-title', action='store', metavar="'Your Title'", type=str, required=False, help='Title of the MessageBox [Optional]') msg_parser.add_argument('-message', action='store', metavar="'Your Message'", type=str, required=True, help='Contents of the MessageBox') # Authentication options group = parser.add_argument_group('authentication') group.add_argument('-hashes', action="store", metavar="LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH') group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)') group.add_argument('-k', action="store_true", help='Use Kerberos authentication. Grabs credentials from ccache file (KRB5CCNAME) based on ' 'target parameters. If valid credentials cannot be found, it will use the ones specified ' 'in the command line') group.add_argument('-aesKey', action="store", metavar="hex key", help='AES key to use for Kerberos Authentication (128 or 256 bits)') group = parser.add_argument_group('connection') group.add_argument('-dc-ip', action='store', metavar="ip address", help='IP Address of the domain controller. If omitted it will use the domain part (FQDN) specified in ' 'the target parameter') group.add_argument('-target-ip', action='store', metavar="ip address", help='IP Address of the target machine. If omitted it will use whatever was specified as target. ' 'This is useful when target is the NetBIOS name and you cannot resolve it') group.add_argument('-port', choices=['139', '445'], nargs='?', default='445', metavar="destination port", help='Destination port to connect to SMB Server') if len(sys.argv) == 1: parser.print_help() sys.exit(1) options = parser.parse_args() if options.action is None: parser.print_help() LOG.error('Too few arguments...') sys.exit(1) if options.debug is True: logging.getLogger().setLevel(logging.DEBUG) # Print the Library's installation path logging.debug(version.getInstallationPath()) else: logging.getLogger().setLevel(logging.INFO) domain, username, password, remoteName = parse_target(options.target) if options.target_ip is None: options.target_ip = remoteName if domain is None: domain = '' if options.aesKey is not None: options.k = True if password == '' and username != '' and options.hashes is None and options.no_pass is False and options.aesKey is None: from getpass import getpass password = getpass("Password:") tsHandler = TSHandler(username, password, domain, options) try: tsHandler.run(remoteName, options.target_ip) except Exception as e: traceback.print_exc() logging.error(str(e))