From 6e5b662a4b31b857bf36c6429da3ea892c11123e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 26 Oct 2026 20:00:09 -0400 Subject: [PATCH] Match CybORG decoy exploit effects and SQL execution --- src/jaxborg/actions/red_exploit_unified.py | 64 ++---- tests/differential/harness.py | 10 + .../differential/test_exploit_decoy_parity.py | 212 ++++++++++++++++++ .../subsystems/test_red_exploit_remaining.py | 44 +++- 4 files changed, 271 insertions(+), 59 deletions(-) create mode 100644 tests/differential/test_exploit_decoy_parity.py diff --git a/src/jaxborg/actions/red_exploit_unified.py b/src/jaxborg/actions/red_exploit_unified.py index 7ae75a0a..e7a14be0 100644 --- a/src/jaxborg/actions/red_exploit_unified.py +++ b/src/jaxborg/actions/red_exploit_unified.py @@ -13,7 +13,6 @@ from jaxborg.state import SimulatorConst, SimulatorState _NO_SERVICE = -1 -_NO_DECOY = -1 _SERVICE_TABLE = jnp.array( [ @@ -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( @@ -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 @@ -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, ) @@ -107,8 +77,6 @@ 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), @@ -116,7 +84,7 @@ def apply_exploit_unified( ) 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], @@ -124,14 +92,16 @@ def apply_exploit_unified( ) 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, diff --git a/tests/differential/harness.py b/tests/differential/harness.py index 02290e7f..404f5721 100644 --- a/tests/differential/harness.py +++ b/tests/differential/harness.py @@ -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, @@ -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. diff --git a/tests/differential/test_exploit_decoy_parity.py b/tests/differential/test_exploit_decoy_parity.py new file mode 100644 index 00000000..a3985eed --- /dev/null +++ b/tests/differential/test_exploit_decoy_parity.py @@ -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}", {}), + ) diff --git a/tests/subsystems/test_red_exploit_remaining.py b/tests/subsystems/test_red_exploit_remaining.py index 4e53e177..5647e433 100644 --- a/tests/subsystems/test_red_exploit_remaining.py +++ b/tests/subsystems/test_red_exploit_remaining.py @@ -32,6 +32,7 @@ ) from jaxborg.constants import ( ACTIVITY_EXPLOIT, + ACTIVITY_NONE, COMPROMISE_USER, DECOY_IDS, GLOBAL_MAX_HOSTS, @@ -172,7 +173,7 @@ def test_sql_succeeds_with_mysql_and_apache(self, jax_const, scanned_sql): assert bool(new_state.host_has_malware[target]) assert int(new_state.red_activity_this_step[target]) == ACTIVITY_EXPLOIT - def test_sql_fails_without_apache(self, jax_const): + def test_sql_executes_with_mysql_without_native_apache(self, jax_const): target = _find_host_with_service(jax_const, SERVICE_IDS["MYSQLD"]) if target is None: pytest.fail("No host with MYSQLD") @@ -183,7 +184,8 @@ def test_sql_fails_without_apache(self, jax_const): action_idx = encode_red_action("ExploitRemoteService_cc4SQLInjection", target, 0) new_state = _jit_apply_red(state, jax_const, 0, action_idx, jax.random.PRNGKey(0)) - assert not bool(new_state.red_sessions[0, target]) + assert bool(new_state.red_sessions[0, target]) + assert int(new_state.host_compromised[target]) == COMPROMISE_USER def test_sql_fails_without_mysql(self, jax_const): target = _find_host_with_service(jax_const, SERVICE_IDS["APACHE2"]) @@ -244,21 +246,26 @@ def test_http_succeeds_with_rfi(self, jax_const): assert bool(new_state.red_sessions[0, target]) assert int(new_state.red_privilege[0, target]) == COMPROMISE_USER - def test_http_blocked_by_apache_decoy(self, jax_const): + def test_http_decoy_emits_network_alert_without_native_apache(self, jax_const): target = _find_host_with_service(jax_const, SERVICE_IDS["APACHE2"]) if target is None: pytest.fail("No host with APACHE2") - const = jax_const.replace(host_has_rfi=jax_const.host_has_rfi.at[target].set(True)) - state = _setup_scanned_state(const, target) + state = _setup_scanned_state(jax_const, target) decoys = state.host_decoys.at[target, DECOY_IDS["Apache"]].set(True) - state = state.replace(host_decoys=decoys) + state = state.replace( + host_services=state.host_services.at[target, SERVICE_IDS["APACHE2"]].set(False), + host_decoys=decoys, + host_activity_detected=state.host_activity_detected.at[target].set(False), + ) action_idx = encode_red_action("ExploitRemoteService_cc4HTTPRFI", target, 0) - new_state = _jit_apply_red(state, const, 0, action_idx, jax.random.PRNGKey(0)) + new_state = _jit_apply_red(state, jax_const, 0, action_idx, jax.random.PRNGKey(0)) assert not bool(new_state.red_sessions[0, target]) assert int(new_state.red_activity_this_step[target]) == ACTIVITY_EXPLOIT + assert bool(new_state.host_activity_detected[target]) + assert not bool(jnp.any(new_state.host_process_creation_pids[target] != -1)) class TestExploitHTTPS: @@ -271,13 +278,17 @@ def test_https_always_fails_no_service(self, jax_const, scanned_apache): def test_https_detected_by_tomcat_decoy(self, jax_const, scanned_apache): state, target = scanned_apache decoys = state.host_decoys.at[target, DECOY_IDS["Tomcat"]].set(True) - state = state.replace(host_decoys=decoys) + state = state.replace( + host_decoys=decoys, + host_activity_detected=state.host_activity_detected.at[target].set(False), + ) action_idx = encode_red_action("ExploitRemoteService_cc4HTTPSRFI", target, 0) new_state = _jit_apply_red(state, jax_const, 0, action_idx, jax.random.PRNGKey(0)) assert not bool(new_state.red_sessions[0, target]) assert int(new_state.red_activity_this_step[target]) == ACTIVITY_EXPLOIT + assert bool(new_state.host_activity_detected[target]) class TestExploitHaraka: @@ -290,13 +301,18 @@ def test_haraka_fails_no_vulnerable_version(self, jax_const, scanned_smtp): def test_haraka_detected_by_decoy(self, jax_const, scanned_smtp): state, target = scanned_smtp decoys = state.host_decoys.at[target, DECOY_IDS["HarakaSMPT"]].set(True) - state = state.replace(host_decoys=decoys) + state = state.replace( + host_services=state.host_services.at[target, SERVICE_IDS["SMTP"]].set(False), + host_decoys=decoys, + host_activity_detected=state.host_activity_detected.at[target].set(False), + ) action_idx = encode_red_action("ExploitRemoteService_cc4HarakaRCE", target, 0) new_state = _jit_apply_red(state, jax_const, 0, action_idx, jax.random.PRNGKey(0)) assert not bool(new_state.red_sessions[0, target]) assert int(new_state.red_activity_this_step[target]) == ACTIVITY_EXPLOIT + assert bool(new_state.host_activity_detected[target]) class TestExploitFTP: @@ -306,16 +322,20 @@ def test_ftp_always_fails_no_service(self, jax_const, scanned_sshd): new_state = _jit_apply_red(state, jax_const, 0, action_idx, jax.random.PRNGKey(0)) assert not bool(new_state.red_sessions[0, target]) - def test_ftp_detected_by_vsftpd_decoy(self, jax_const, scanned_sshd): + def test_ftp_ignores_vsftpd_web_decoy(self, jax_const, scanned_sshd): state, target = scanned_sshd decoys = state.host_decoys.at[target, DECOY_IDS["Vsftpd"]].set(True) - state = state.replace(host_decoys=decoys) + state = state.replace( + host_decoys=decoys, + host_activity_detected=state.host_activity_detected.at[target].set(False), + ) action_idx = encode_red_action("ExploitRemoteService_cc4FTPDirectoryTraversal", target, 0) new_state = _jit_apply_red(state, jax_const, 0, action_idx, jax.random.PRNGKey(0)) assert not bool(new_state.red_sessions[0, target]) - assert int(new_state.red_activity_this_step[target]) == ACTIVITY_EXPLOIT + assert int(new_state.red_activity_this_step[target]) == ACTIVITY_NONE + assert not bool(new_state.host_activity_detected[target]) class TestExploitEternalBlueBlueKeep: