Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 17 additions & 47 deletions src/jaxborg/actions/red_exploit_unified.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from jaxborg.state import SimulatorConst, SimulatorState

_NO_SERVICE = -1
_NO_DECOY = -1

_SERVICE_TABLE = jnp.array(
[
Expand All @@ -28,35 +27,7 @@
],
dtype=jnp.int32,
)
_EXTRA_SERVICE_TABLE = jnp.array(
[
_NO_SERVICE,
_NO_SERVICE,
_NO_SERVICE,
_NO_SERVICE,
_NO_SERVICE,
SERVICE_IDS["APACHE2"],
_NO_SERVICE,
_NO_SERVICE,
],
dtype=jnp.int32,
)
_DECOY_TABLE = jnp.array(
[
_NO_DECOY,
DECOY_IDS["Vsftpd"],
DECOY_IDS["Apache"],
DECOY_IDS["Tomcat"],
DECOY_IDS["HarakaSMPT"],
_NO_DECOY,
_NO_DECOY,
_NO_DECOY,
],
dtype=jnp.int32,
)
_DECOY_GATE_SERVICE = jnp.array([False, False, True, False, True, False, False, False])
_HAS_REAL_EXPLOIT = jnp.array([True, False, True, False, False, True, False, False])
_HAS_ROLLS = jnp.array([False, False, True, False, False, True, False, False])


def apply_exploit_unified(
Expand All @@ -70,6 +41,8 @@ def apply_exploit_unified(
is_noop = exploit_subtype >= 6
is_ssh = exploit_subtype == 0
is_http = exploit_subtype == 2
is_https = exploit_subtype == 3
is_haraka = exploit_subtype == 4

base_ok = (
exploit_common_preconditions(state, const, agent_id, target_host, key=jax.random.fold_in(key, 777)) & ~is_noop
Expand All @@ -81,21 +54,18 @@ def apply_exploit_unified(
state.host_services[target_host, jnp.clip(service_idx, 0, state.host_services.shape[1] - 1)],
True,
)
extra_service_idx = _EXTRA_SERVICE_TABLE[exploit_subtype]
has_extra_service = jnp.where(
extra_service_idx >= 0,
state.host_services[target_host, jnp.clip(extra_service_idx, 0, state.host_services.shape[1] - 1)],
True,
)
decoy_idx = _DECOY_TABLE[exploit_subtype]
has_decoy = jnp.where(
decoy_idx >= 0,
state.host_decoys[target_host, jnp.clip(decoy_idx, 0, state.host_decoys.shape[1] - 1)],
False,
decoys = state.host_decoys[target_host]
# Native processes precede appended decoys in CybORG's process list.
# Vsftpd advertises a webserver on port 80, not an FTP process on port 21.
decoy_process = (
(is_http & ~has_service & (decoys[DECOY_IDS["Apache"]] | decoys[DECOY_IDS["Vsftpd"]]))
| (is_https & decoys[DECOY_IDS["Tomcat"]])
| (is_haraka & ~has_service & decoys[DECOY_IDS["HarakaSMPT"]])
)
decoy_detected = base_ok & decoy_process

host_activity_detected = jnp.where(
is_ssh & base_ok & has_service,
(is_ssh & base_ok & has_service) | decoy_detected,
state.host_activity_detected.at[target_host].set(True),
state.host_activity_detected,
)
Expand All @@ -107,31 +77,31 @@ def apply_exploit_unified(
state,
)

service_gate = jnp.where(_DECOY_GATE_SERVICE[exploit_subtype], has_service, True)
decoy_detected = base_ok & has_decoy & service_gate & ~is_ssh
activity = jnp.where(
decoy_detected,
state.red_activity_this_step.at[target_host].set(ACTIVITY_EXPLOIT),
state.red_activity_this_step,
)
state = state.replace(red_activity_this_step=activity)

candidate_success = base_ok & has_service & has_extra_service & _HAS_REAL_EXPLOIT[exploit_subtype]
candidate_success = base_ok & has_service & _HAS_REAL_EXPLOIT[exploit_subtype]
candidate_success = jnp.where(
is_ssh,
candidate_success & const.host_has_bruteforceable_user[target_host],
candidate_success,
)
candidate_success = jnp.where(
is_http,
candidate_success & const.host_has_rfi[target_host] & ~has_decoy,
candidate_success & const.host_has_rfi[target_host],
candidate_success,
)

has_rolls = _HAS_ROLLS[exploit_subtype]
has_rolls = ~is_ssh
# sim_exploit tests the success rate before its EXPLOIT-decoy early return.
reaches_success_roll = has_rolls & (candidate_success | decoy_detected)
success_key = jax.random.fold_in(key, 0)
process_event_key = jax.random.fold_in(key, 1)
success_roll_ok, state = sample_sim_exploit_success_roll(state, const, has_rolls & candidate_success, success_key)
success_roll_ok, state = sample_sim_exploit_success_roll(state, const, reaches_success_roll, success_key)
process_event_detected, state = sample_exploit_process_event_roll(
state,
const,
Expand Down
10 changes: 10 additions & 0 deletions tests/differential/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
ACTION_TYPE_DISCOVER,
ACTION_TYPE_DISCOVER_DECEPTION,
ACTION_TYPE_EXPLOIT_BLUEKEEP,
ACTION_TYPE_EXPLOIT_HARAKA,
ACTION_TYPE_EXPLOIT_HTTP,
ACTION_TYPE_EXPLOIT_HTTPS,
ACTION_TYPE_EXPLOIT_SQL,
ACTION_TYPE_EXPLOIT_SSH,
ACTION_TYPE_IMPACT,
Expand Down Expand Up @@ -1628,6 +1630,14 @@ def _sync_red_action_randoms(self, random_sync_report, red_actions):
f"red_agent_{usage.agent_idx}:{usage.action_type} used {usage.summary()}"
)
continue
if action_type in {ACTION_TYPE_EXPLOIT_HTTPS, ACTION_TYPE_EXPLOIT_HARAKA}:
if len(usage.random_calls) == 1 and not usage.choice_sizes and not usage.integer_ranges:
synced_randoms.extend(usage.random_calls)
continue
random_sync_report.unsupported_random_actions.append(
f"red_agent_{usage.agent_idx}:{usage.action_type} used {usage.summary()}"
)
continue
if action_type == ACTION_TYPE_PRIVESC:
if not usage.random_calls and not usage.integer_ranges and len(usage.choice_sizes) == 1:
# Sync the privesc session choice index so JAX picks the same session.
Expand Down
212 changes: 212 additions & 0 deletions tests/differential/test_exploit_decoy_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import jax
import jax.numpy as jnp
import numpy as np
import pytest
from CybORG import CybORG
from CybORG.Agents import SleepAgent
from CybORG.Agents.Wrappers.BlueFlatWrapper import BlueFlatWrapper
from CybORG.Shared.Enums import OperatingSystemType, ProcessType, TernaryEnum
from CybORG.Simulator.Actions.AbstractActions.Monitor import Monitor
from CybORG.Simulator.Actions.ConcreteActions.DecoyActions.DecoyApache import DecoyApache
from CybORG.Simulator.Actions.ConcreteActions.DecoyActions.DecoyHarakaSMPT import DecoyHarakaSMPT
from CybORG.Simulator.Actions.ConcreteActions.DecoyActions.DecoyTomcat import DecoyTomcat
from CybORG.Simulator.Actions.ConcreteActions.DecoyActions.DecoyVsftpd import DecoyVsftpd
from CybORG.Simulator.Actions.ConcreteActions.ExploitActions.FTPDirectoryTraversal import FTPDirectoryTraversal
from CybORG.Simulator.Actions.ConcreteActions.ExploitActions.HarakaRCE import HarakaRCE
from CybORG.Simulator.Actions.ConcreteActions.ExploitActions.HTTPRFI import HTTPRFI
from CybORG.Simulator.Actions.ConcreteActions.ExploitActions.HTTPSRFI import HTTPSRFI
from CybORG.Simulator.Actions.ConcreteActions.ExploitActions.SQLInjection import SQLInjection
from CybORG.Simulator.Process import Process
from CybORG.Simulator.Scenarios import EnterpriseScenarioGenerator
from CybORG.Simulator.Service import Service

from jaxborg.actions.blue_decoys import apply_blue_decoy
from jaxborg.actions.blue_monitor import apply_blue_monitor
from jaxborg.actions.red_exploit_unified import apply_exploit_unified
from jaxborg.actions.rng import rng_impls
from jaxborg.constants import COMPROMISE_NONE, COMPROMISE_USER, DECOY_IDS, NUM_BLUE_AGENTS
from jaxborg.env import _init_red_state
from jaxborg.observations import get_blue_obs
from jaxborg.parity.translate import build_mappings_from_cyborg
from jaxborg.scenarios.cc4.topology import build_const_from_cyborg
from jaxborg.state import create_initial_state
from tests.differential.parity_rng_replay import RNGTape
from tests.differential.recording_rng import RecordingNPRandom


class _FixedRandom(RecordingNPRandom):
def __init__(self, original, value):
super().__init__(original)
self.value = value

def random(self):
self.log.append(("random", self.value, None))
return self.value


_DECOYS = {
"Apache": DecoyApache,
"Vsftpd": DecoyVsftpd,
"Tomcat": DecoyTomcat,
"HarakaSMPT": DecoyHarakaSMPT,
}


def _scenario(native_rfi, with_mysql):
env = CybORG(
scenario_generator=EnterpriseScenarioGenerator(
blue_agent_class=SleepAgent,
green_agent_class=SleepAgent,
red_agent_class=SleepAgent,
steps=500,
),
seed=42,
)
native = env.environment_controller.state
hostname = next(
name
for name, host in sorted(native.hosts.items())
if name.startswith("public_access_zone_subnet_server_host_") and host.os_type == OperatingSystemType.LINUX
)
host = native.hosts[hostname]
removed = {
process.pid
for process in host.processes
if process.process_type in {ProcessType.WEBSERVER, ProcessType.SMTP, ProcessType.FEMITTER}
}
host.processes = [process for process in host.processes if process.pid not in removed]
host.services = {name: service for name, service in host.services.items() if service.process not in removed}
if native_rfi is not None:
pid = max(process.pid for process in host.processes) + 10
host.processes.append(
Process(
process_name="apache2",
pid=pid,
username="user",
process_type="http",
open_ports=[{"local_address": "0.0.0.0", "local_port": 80}],
properties=["rfi"] if native_rfi else [],
)
)
host.services["APACHE2"] = Service(process=pid)
if with_mysql and not any(process.process_type == ProcessType.MYSQL for process in host.processes):
pid = max(process.pid for process in host.processes) + 10
host.processes.append(
Process(
process_name="mysqld",
pid=pid,
username="user",
process_type="mysql",
open_ports=[{"local_address": "0.0.0.0", "local_port": SQLInjection.PORT}],
)
)
host.services["MYSQLD"] = Service(process=pid)

const = build_const_from_cyborg(env)
mappings = build_mappings_from_cyborg(env)
target = mappings.hostname_to_idx[hostname]
state = create_initial_state().replace(
host_services=const.initial_services,
host_max_pid=const.host_initial_max_pid,
)
state = _init_red_state(const, state)
source = int(const.red_start_hosts[0])
state = state.replace(
red_discovered_hosts=state.red_discovered_hosts.at[0, target].set(True),
red_scanned_hosts=state.red_scanned_hosts.at[0, target].set(True),
red_scanned_source_hosts=state.red_scanned_source_hosts.at[0, target, source].set(True),
red_pending_visible_sessions=state.red_pending_visible_sessions.at[0].set(1),
)
blue = next(b for b in range(NUM_BLUE_AGENTS) if bool(const.blue_agent_hosts[b, target]))
return env, const, state, hostname, target, blue


@pytest.mark.parametrize(
"exploit,subtype,decoys,native_rfi,roll,event_count,random_count,success",
[
(HTTPRFI, 2, ("Apache",), None, 0.5, 2, 1, False),
(HTTPRFI, 2, ("Vsftpd",), None, 0.5, 2, 1, False),
(HTTPRFI, 2, ("Apache", "Vsftpd"), None, 0.5, 2, 1, False),
(HTTPRFI, 2, ("Apache",), None, 0.0, 1, 1, False),
(HTTPSRFI, 3, ("Tomcat",), None, 0.5, 2, 1, False),
(HarakaRCE, 4, ("HarakaSMPT",), None, 0.5, 2, 1, False),
(FTPDirectoryTraversal, 1, ("Vsftpd",), None, 0.5, 0, 0, False),
(HTTPRFI, 2, ("Vsftpd",), True, 0.5, 0, 2, True),
(HTTPRFI, 2, ("Vsftpd",), False, 0.5, 0, 0, False),
(SQLInjection, 5, ("Apache",), None, 0.5, 0, 2, True),
(SQLInjection, 5, ("Vsftpd",), None, 0.5, 0, 2, True),
(SQLInjection, 5, ("Tomcat",), None, 0.5, 0, 2, True),
],
ids=[
"http-apache",
"http-vsftpd",
"http-apache-before-vsftpd",
"http-decoy-failed-success-roll",
"https-tomcat",
"haraka-decoy",
"ftp-vsftpd-has-no-port21",
"http-native-rfi-before-vsftpd",
"http-native-nonrfi-before-vsftpd",
"sql-native-mysql-with-apache-decoy",
"sql-native-mysql-with-vsftpd-decoy",
"sql-native-mysql-with-tomcat-decoy",
],
)
def test_exploit_decoy_alerts_and_native_process_precedence(
exploit, subtype, decoys, native_rfi, roll, event_count, random_count, success
):
env, const, state, hostname, target, blue = _scenario(native_rfi, exploit is SQLInjection)
native = env.environment_controller.state
host = native.hosts[hostname]
for name in decoys:
result = _DECOYS[name](session=0, agent=f"blue_agent_{blue}", hostname=hostname).execute(native)
assert result.data["success"] == TernaryEnum.TRUE
with jax.disable_jit():
state = apply_blue_decoy(state, const, blue, target, DECOY_IDS[name])
assert bool(state.host_decoys[target, DECOY_IDS[name]])

ports = [connection.local_port for process in host.processes for connection in process.connections]
if exploit is SQLInjection:
assert SQLInjection.PORT in ports
assert 80 in ports or 443 in ports
assert SQLInjection.get_weight(ports) > 0

recorder = _FixedRandom(native.np_random, roll)
native.np_random = recorder
ip = native.hostname_ip_map[hostname]
result = exploit(session=0, agent="red_agent_0", ip_address=ip).execute(native)
randoms = [call[1] for call in recorder.log if call[0] == "random"]
assert len(randoms) == random_count
assert len(host.events.network_connections) == event_count
assert (result.data["success"] == TernaryEnum.TRUE) == success
assert len([session for session in native.sessions["red_agent_0"].values() if session.hostname == hostname]) == int(
success
)
assert bool(host.events.process_creation) == success

tape = RNGTape()
tape.push_uniforms(randoms)
with rng_impls(uniform=tape.uniform), jax.disable_jit():
state = apply_exploit_unified(state, const, 0, target, jax.random.PRNGKey(0), subtype)
assert tape.remaining == 0
assert tape.consumed == random_count
assert bool(state.host_activity_detected[target]) == bool(event_count)
assert bool(state.red_sessions[0, target]) == success
assert int(state.red_session_count[0, target]) == int(success)
assert int(state.host_compromised[target]) == (COMPROMISE_USER if success else COMPROMISE_NONE)
assert bool(jnp.any(state.host_process_creation_pids[target] != -1)) == success

for b in range(NUM_BLUE_AGENTS):
Monitor(session=0, agent=f"blue_agent_{b}").execute(native)
state = apply_blue_monitor(state, const, b)
assert not host.events.network_connections
assert len(host.events.old_network_connections) == event_count
assert not bool(state.host_activity_detected[target])
assert bool(state.old_host_activity_detected[target]) == bool(event_count)

wrapper = BlueFlatWrapper(env=env, pad_spaces=True)
for b in range(NUM_BLUE_AGENTS):
np.testing.assert_array_equal(
get_blue_obs(state, const, b),
wrapper.observation_change(f"blue_agent_{b}", {}),
)
Loading
Loading