Malware · · 25 min read
How PoeLLM turns a GitHub poem into its next command server
PoeLLM breaks into exposed LiteLLM, Ollama, Gotenberg and Gitea hosts, then reads four words of a GitHub poem to learn which command server to call.

On October 7, 2026, Black Lotus Labs at Lumen published a campaign they named PoeLLM. Infected servers read a poem in a GitHub fork, pull four words out of fixed slots, and turn those words into the IPv4 address of the command server they should call next. Edit the poem, and the machines move, without a new binary. The same hosts mine cryptocurrency and scan for the next server to take.
Beelzebub Labs, starting from one request a honeypot kept, then found three older poem channels, a history that begins in December 2025, and two remote-access programs the Lumen report does not take apart. This post follows both papers, the LiteLLM and Ollama advisories, and the places the accounts do not match. The samples were not run here.
If you do not run LiteLLM, Ollama, Gotenberg, or Gitea where a network can reach them, the hunting list at the end is not yours. The mechanism is still the piece worth having, because the poem is a dead drop, and a dead drop is a thing you can search for.
The miner and the manager are different programs
Lumen’s sample is an ELF named libgcrypt. It carries a remote shell, the miners XMRig and Iron, an HTTP scanner, and the code that sends the next exploit. SHA-256 6fab94577364beec314afae3b082dd680933f08a8349b9f35b92667e8231b501. In a public sandbox it reports on TCP port 9999 and mines against Kryptex. Lumen saw victims reach 5.180.174[.]162 port 8029 and 46.21.245[.]211 port 7029, the second of which hosts edge-ru-07.kryptex[.]network.
Beelzebub’s delivery requests do not install that file. They install a manager. Static analysis of the two newest manager builds, which they call Bridge 3.0.7 and Sentinel 1.5.2, shows a remote-access client and a process supervisor. Neither build contains mining code. No similarity hash ties libgcrypt to either of them. They share one habit with each other and with the miner: each writes the server address it just computed into a file, so the next start does not have to parse the poem again. The miner uses /tmp/.srvip. Bridge uses a name beginning bridge_srv_ip_.
That split is the practical fact. A host that is mining can also be running the supervisor. Deleting the miner leaves the operator’s access in place. Beelzebub is explicit that the static reading shows what the code can do, not which of those commands ran on any one machine.
What the infected servers were
After Lumen found the first command server, device data on the machines calling it came back as LiteLLM, Ollama, Gotenberg, and Gitea. LiteLLM is a proxy that puts many model providers behind one API. Ollama runs models on a machine you administer. Gotenberg converts documents to PDF. Gitea is a Git server. The key findings also list Ivanti Sentry as possible targeting. The body is narrower. They were already investigating CVE-2026-10520, a defect in Ivanti’s Sentry gateway, when in early June 2026 one compromised Sentry contacted 5.78.73[.]122 and then began scanning. That sentence is a victim that started scanning. It does not say PoeLLM’s exploit code aims at Sentry.
The scan itself concentrated on ports 3000 and 4000. Lumen identifies those as the default listen ports of Gotenberg and LiteLLM. Gotenberg’s installation guide, which Lumen reproduces, tells operators not to expose the service to the internet. Hundreds of Gotenberg and Gitea servers were in the victim set anyway. Lumen does not name a CVE for either product. The published common factor is that the service answered from the internet.
Victims clustered in the United States and Western Europe. Lumen’s reading is adoption: that is where these tools are left facing the network. They also say the GPU behind an AI workload is part of the attraction for a miner, and that the same servers may hold prompts, keys, and whatever the application was trusted with. The campaign they measured is mining and scanning. The data on those hosts is the reason the access matters after the coins.
The LiteLLM route is a connection test that starts a program
CVE-2026-42271 is GHSA-v4p8-mg3p-g94g, published April 21, 2026. Affected versions are 1.74.2 up to, and not including, 1.83.7. The CVE record scores it CVSS 4.0 at 8.7:
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:N/SA:N
Network, low complexity, a precondition, low privileges, no click. High impact on the confidentiality, integrity, and availability of the proxy, and high confidentiality impact scored on a subsequent system. The weakness is CWE-78: an operating-system command that includes text the requester supplied.
Two routes exist so an operator can preview an MCP server before saving it. MCP is the Model Context Protocol. POST /mcp-rest/test/connection and POST /mcp-rest/test/tools/list accepted a full server configuration, including command, args, and env. A server that uses the stdio transport is a local program, not a URL. Previewing it means starting that program as a subprocess of the proxy, with the proxy’s privileges.
The advisory says the routes checked for a valid proxy API key and did not check the key’s role. A low-privilege internal user could run a command. The PR:L in the vector is that key. AT:P records a precondition. The advisory’s precondition is a proxy in that version range, reached by someone who already holds a key.
Version 1.83.7, which Horizon3 dates to May 8, 2026, requires the PROXY_ADMIN role on both routes. Rapid7’s module notes add a second control they attribute to that release: the stdio command is limited to an allowlist of interpreters (deno, docker, node, npx, python, python3, uvx), and a different command is rejected even for an admin. The GitHub advisory does not mention the allowlist. The role change is the vendor’s statement. The allowlist is Rapid7’s reading of the patch. Rapid7 also notes that the spawned process is not an MCP server the route can talk to, so the HTTP response is a failed connection test after the command has already been started. A defender who only watches for a 200 will miss it.
A key is still a key. Horizon3 published a chain with CVE-2026-48710, the Starlette bug X41 calls BadHost. Starlette before 1.0.1 builds request.url by joining the Host header to the path. Routing uses the path on the request line. Code that asks “does this path require a login?” by reading request.url.path can be shown a different path from the one that runs. LiteLLM’s auth helper did that, in get_request_route(). Horizon3 says they confirmed on June 1, 2026 that the chain reaches the MCP test routes with no key, on LiteLLM 1.74.2 through 1.83.6, when the dependency tree has Starlette 1.0.0 or older.
LiteLLM’s own note is separate. They say the change that stops deriving the route from the Host-built URL shipped in 1.84.0, with further path handling backported to 1.84.3, 1.85.2, 1.86.2, and 1.83.10-stable.patch.3. The Python SDK is outside it. A proxy in front that rejects a malformed Host reduces the chance, and LiteLLM says whether that fully blocks the bypass depends on the front. Horizon3’s advice to move to 1.83.7 and LiteLLM’s advice to take a release that names the Host-header fix are both published. Closing both holes means a release on LiteLLM’s Host-header list, which is also past 1.83.7.
Beelzebub’s honeypot recorded POST /mcp-rest/test/connection from July 18 onward. A honeypot stores the request it was sent. It does not say whether a key was attached, or whether the Host header was the BadHost form. From August 14 the body is an MCP configuration whose server name is system-monitor. The command it names downloads a program called manager from TCP port 81 and starts it. On August 20 the loader is wrapped so the visible command is a short decoder. On August 24 it selects an x86-64 or AArch64 build. If the first save-and-run fails, it tries a second directory, and if that fails it runs the bytes from an anonymous memory file. That last step is what still works when /tmp is mounted so files there cannot be executed, and a failed attempt can leave the file behind. The request bodies are in Beelzebub’s post. The path, the port, the server name, and the program name are the parts a log search needs.
Lumen had already tied one sample to the same route. The libgcrypt hash above contains a POST aimed at /mcp-rest/test/connection. Download URLs on port 81, paths /private/python3.6 and /private/bins.sh, showed up first on 5.78.73[.]122 and later on 120.224.114[.]212. Beelzebub’s hashes line the names up with builds: bins.sh as a Bridge loader (169bb98a3e27c68ccb8cefbabf4d4211c258729b2825aa1e17d36f13b7166c6b), and a file they identify as Bridge 3.0.7 under the name python3.6 (3af9f25a4d45bb4f1ec5627cdbc6703cf3b4be75a892162d299d80ddfb266f42).
Discovery, in the same honeypot logs, is older than delivery. From June 24, clients identifying as axios/1.14.0 requested /v1/models and then /openapi.json. Beelzebub counts 48 such addresses in June. In August, 16 of 29 delivery requests arrived less than ten minutes after both discovery requests had hit the same honeypot. Three July delivery sources had queried an Ollama honeypot’s /api/version between three and 27 hours earlier. Three command or staging hosts sent the axios discovery themselves.
Three ways to hide an address in a poem
A dead drop here is a public page both sides can read, so the command server’s address does not have to live in the binary. The program fetches the page, parses it, and connects. The operator edits the page. GitHub hosts it. The upstream projects were not changed. The operator forked them and added one file, then rewrote that file in GitHub’s web editor.
Lumen followed one file. Beelzebub followed three more. The encodings are not the same poem with the nouns swapped. They are three generations.
Numbers glued to words
Beelzebub dates the first verse to December 9, 2025, in a fork of discord.js. Selected words carry integers. Read in the order place, ghost, verb, object, those integers are the four octets. Their example decodes to 194.69.203[.]32. Later lines of the same verse repeat the same four numbers. From that day through June 17, 2026 they count 39 numbered verses encoding 31 addresses. Twenty of those addresses went out before April 13, the day Lumen’s poem is committed. On April 13 itself the discord.js channel published another verse.
They point at public malware records from December 6, three days before the verse: URLhaus, MalwareBazaar, and VirusTotal logged 194.69.203[.]32 port 81 serving a file named colonna. Those feeds were not re-queried for this post. The corroboration is theirs. colonna is the program they describe as a modified Mirai, and it beacons on TCP 3778. A 446-byte script, SHA-256 c9d5637305f9638f37023fe625d8c5e825de3ede0c2b4ffb8c0f902bd476da21, which VirusTotal received on February 15, pulls colonna from 191.37.28[.]163 port 81. That address is in the same /24 as 191.37.28[.]160, the first command server Lumen decoded from the April poem. In the sandbox, colonna then beaconed to port 3778 and fetched the discord.js channel through a relay.
Bridge still parses this verse. Beelzebub quotes two format strings in the binary: The %63s %63s the %63s and %63[^,], %63s and deep,. The second is the verse’s second line, with the numbered words replaced by slots. %63s reads a token of at most 63 characters. %63[^,] reads up to 63 characters that are not a comma. The parser stores the address and reconnects when the poem changes.
Four words, a dictionary, an address
Lumen’s channel is a fork of the nodejs.org website source, GitHub account ejejejdfbbebe, file dash.css. That filename is not in the upstream tree, which is how it stands out in a diff against Node’s site. The poem is titled On the Nature of Connection. The first commit is April 13, 2026. Lumen counted 11 edits. Across those edits the scaffold stays, and four words move. A diff of the file is the rotation.
The function they name extract_poem_phrase_field takes three spans, without regard to case:
- the text between
In the silent hum ofand the next comma - the text between
each pulse ofandthreading - the text between
Beyond the wall ofand the next comma
A fourth span, which they place at file offsets 0x44a8db through 0x44a99b, searches for of distant servers, walks back to the previous word, and keeps that word only when the four bytes in front of it are the .
Each word is looked up in a dictionary compiled into the sample. The four numbers are the four octets, in that order. Lumen’s worked example, from a sample they date to June 23, is driver = 92, diode = 119, decryption = 165, string = 74, which is 92.119.165[.]74. That address is on their list from July 1 to July 23. The June 23 sample held the dictionary. A later poem spent it. The verse they print as the September text uses different words in the same slots: driver, diode, encryption, and tick. The caption under the June mapping calls that mapping the current poem. The prose calls 92.119.165[.]74 a previous address. The prose matches the dates. The caption does not.
Lumen says the anchors had not changed across the 11 edits, only the keywords. Beelzebub calls this the second generation: the digits are gone, and each keyword still stands for one octet. They do not publish the dictionary. This post does not either. The four anchors and one historical mapping are enough to see the mechanism. They are not enough to turn an arbitrary later verse into an address, which is the point of keeping the dictionary in the binary.
A stanza whose meaning is two tables
Sentinel reads a four-line stanza. Each line pairs an opening phrase with an ending. Each half is drawn from a table of 16 entries. Beelzebub gives the combination as octet = (opening_index << 4) | ending_index. A line they mark as invented, “the lighthouse rests at dawn.”, with opening 12 and ending 0, encodes 192. The real lines read as verse about moons, rivers, sparrows, and lanterns. Without the two tables the stanza does not yield an address. They counted eight stanzas between June 16 and August 27, naming seven command addresses. The check on the decoder is the operator’s own overlap: the same address appeared as numbered verse on June 17 and as a stanza on June 18.
The parser also accepts a plain dotted address or a simpler numeric form, and it checks the result with inet_pton before it connects. The poem is the path that survives a takedown of one server. A literal address still works when the operator wants the machines to move immediately.
Sentinel fetches each dead drop from GitHub directly, then through five relays on TCP port 1458, HTTPS before HTTP, with certificate verification off. Three passes use timeouts of 2, 5, and 30 seconds. It sleeps a random interval under half a second before each request and rotates four browser User-Agent strings. Two of those strings are malformed. One claims Edge/124.0. The Edge the browser actually sends is Edg/. A proxy log that matches Edge/ and a GitHub raw URL in the same request is not a person reading a poem.
Bridge fetches the same three drops in the other order: relays first, then direct. Both builds store the drop list and the relay list in configuration XOR’d with the single byte 0x5A. Some of Sentinel’s strings are assembled on the stack at runtime, so they are absent from the file until it runs. The packed Sentinel build is UPX 4.21. Strings for the unpacked rules exist after upx -d. Beelzebub publishes the YARA and Sigma under the Detection Rule License 1.1. The hashes they attach to the builds are below.
Four forks, and the upstream projects did not change
Both manager builds carry the same three locations. Beelzebub’s table:
| Parent | Fork account | Added file | Commits | Window |
|---|---|---|---|---|
discordjs/discord.js | franchgamer34 | packages/api-extractor-model/src/items/NewAPi.readme | 30 | Dec 9, 2025 to Aug 27, 2026 |
wszqkzqk/deepin-wine-ubuntu | alessio756 | deepin-wine-plugin-virtual_1.0deepin1_all/DEBIAN/control.readme | 12 | Mar 7 to Aug 27, 2026 |
getumbrel/umbrel | francoryt3 | packages/umbreld/source/modules/blacklist-uas/blacklist-uas.readme | 6 | Mar 12 to Aug 19, 2026 |
Lumen’s dash.css fork is the fourth. Note the capital P in NewAPi.readme. All four accounts have since been deleted. A deleted fork’s commits can remain readable through the parent project’s repository, because GitHub shows a fork’s commits under the parent’s URL. Beelzebub says the 48 commits they saved still resolved on October 12, and that their copy of the history stops on September 2, the last day they could list the commits. Those URLs are not a sign that discord.js, Umbrel, or deepin-wine changed. Blocking the upstream project because a fork added a readme blocks the wrong thing.
The three fork accounts were created within 14 minutes of each other, and the same verses appear in more than one repository. Five of Lumen’s twelve command addresses also appear in these channels. Three match Lumen’s first-seen day. 120.224.114[.]212 is in a poem on May 7 and in Lumen’s telemetry on May 9. 103.249.201[.]108 is in Lumen’s telemetry on August 13 and in a poem on August 19.
Together the three channels name 37 command addresses, 32 of them absent from Lumen’s twelve. Some show the operator returning to the same networks for months. The February verse that names 191.37.28[.]163 sits beside Lumen’s April server 191.37.28[.]160. 101.47.160[.]163, named in a poem in April, scanned Beelzebub’s honeypots in June and was a staging host in a delivery request in September.
Four of the seven addresses named by stanzas had already been running an AI service, in the scan and DNS history Beelzebub cites, before a poem named them: LiteLLM, n8n, ComfyUI, or Jupyter. 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. They say they cannot tell whether the operator compromised those servers or set them up. Lumen’s separate observation is that several command servers were routers with a vulnerable administration page. The first, 191.37.28[.]160, geolocated to Brazil and exposed a Boa web server on port 2222. That page was vulnerable to CVE-2018-21027 and CVE-2018-21028. Lumen did not find direct evidence those two were used. The same shape showed up again, including on 120.224.114[.]212 in China. Their assessment is that the operator kept using compromised routers as the command servers, rather than renting machines for the role.
What the manager can do once it has an address
Bridge 3.0.7 is a static x86-64 binary built on Alpine with musl, linked to mbedTLS 2.28.10. Its command channel is TCP port 5001, and it also speaks UDP. The dispatcher names Beelzebub recovered are SHELL_CMD, SUBNET_SCAN, TCP_TUNNEL_OPEN, and HTTP_REQUEST, plus a file transfer that can resume after a break. Nothing it scans, and nothing it relays to, is hardcoded. The controller supplies the target. That is why a victim becomes a scanner without a new poem: the poem finds the controller, and the controller names the next port and the next address.
Sentinel 1.5.2 is the supervisor. The build they unpacked is AArch64, UPX-packed, the same Alpine and musl and the same mbedTLS. Packed SHA-256 0ce90d89c1b4258b14f541c9add80ff13760590f4cf6bf338e22c2258ffe9eca. Unpacked, f30bf5814c265ab9bb68ebaacd2793db560f68a24d4591e73802f30418cd1751. An earlier 1.5.1 build is credited to a report by Caronte, packed d4629c28b9a505f6ea65966a3def61e1f6a218f67da1a7afb27d50b95cb73350. The default command port is TCP 5002. Each side opens with four random bytes. Sentinel mixes both nonces with a 16-byte secret from the binary into two RC4 keys, one for each direction. A recording of the bytes is not readable from the recording alone. The secret and the two nonces from the start of that session are required. Inside the stream, Sentinel registers with a 134-byte frame: magic 0x5EC4, its process id, the --id the loader passed, and the hostname. Later commands are colon-separated text.
It supervises up to 16 processes. Each is tagged MANAGED_BY_SENTINEL, gets a log of 1,000 lines, and is recorded as exited, crashed, or killed by something else on the host. The operator can add, kill, and restart those processes, run a shell command, push a file, replace Sentinel itself, point it at a new server, or tell it to remove its own traces. The miner can be one of the 16. It installs under one of 16 names borrowed from real daemons, avahi-daemon and packagekit among them, in directories such as /usr/libexec and /var/lib/dbus. The binary also contains strings for injecting a launcher into a running python3. Beelzebub says that path was not traced.
The unit file is the part that makes systemctl stop a bad test. Sentinel’s unit sets KillSignal to SIGCONT and SendSIGKILL to no. Stopping the unit delivers a signal that does not terminate a running process, and the unit does not follow with SIGKILL. An @reboot cron entry and a five-minute pgrep check, both tagged #snt-, start it again. chattr +i marks the binary immutable, so a delete fails until that flag is cleared. Bridge’s set is a systemd unit, a five-minute cron restart if systemctl is-active fails, a PostgreSQL archive_command hook, a shell fragment at /etc/profile.d/dbus-mgr.sh, and the same immutable flag. Files worth the names: /tmp/.snt.lck, /tmp/.snt_x_*, /tmp/.bce_*.
On June 16 the Umbrel channel published 178.128.14[.]204 as a Sentinel server. On June 17 and 18 the miner, in a public sandbox, connected to that same address. That is the published join between the poem channels and the program Lumen analyzed.
The miner’s own spread, in those two sandbox runs, was about 300 random hosts on port 4000, and the MCP test request sent to 14 of them. It installs as a bare file named libgcrypt under ~/.local/share/.syslib/. The real library ships as libgcrypt.so with a version suffix. A file whose name is exactly libgcrypt is the one this campaign uses.
| SHA-256 | What Beelzebub says it is |
|---|---|
7ff0414dd57c9811a3711f3f12a66a611dd0adb00925b44cd728bc81e36e07fa | colonna.test, static x86-64 |
c9d5637305f9638f37023fe625d8c5e825de3ede0c2b4ffb8c0f902bd476da21 | colonna downloader, 446 bytes |
169bb98a3e27c68ccb8cefbabf4d4211c258729b2825aa1e17d36f13b7166c6b | Bridge loader bins.sh |
3af9f25a4d45bb4f1ec5627cdbc6703cf3b4be75a892162d299d80ddfb266f42 | Bridge 3.0.7 |
7858164459843fd1d708f1ca604cf5e795501a4c881160755b869e2835e04f5d | an earlier manager, x86-64, UPX |
0ce90d89c1b4258b14f541c9add80ff13760590f4cf6bf338e22c2258ffe9eca | Sentinel 1.5.2, packed AArch64 |
6fab94577364beec314afae3b082dd680933f08a8349b9f35b92667e8231b501 | Lumen’s libgcrypt miner |
How large, and which sentence you are quoting
Lumen’s key findings say the malware impacted more than 3,400 victim servers, with a peak above 800 active in a day. The body of the same post says that at the mid-June peak the campaign involved almost 2,200 affected servers, with nearly 800 active per day, and later that since April it has impacted almost 2,200. The chart caption mentions peaks in late June and early October, over a chart labeled April through September. BleepingComputer used “more than 3,400” and “as many as 800,” and “at least 11” command servers. Lumen’s list has 12. CyberScoop used “more than 3,400” and “at least a dozen” poem edits, against Lumen’s count of 11 edits after the first commit. Beelzebub quoted the mid-June “almost 2,200.”
Both victim totals are in Lumen’s post. The post does not say how 3,400 and 2,200 were counted. “More than 3,400” is the takeaway. “Almost 2,200” is what the narrative uses for the mid-June total and, a few sections later, for the campaign since April. The 800-a-day peak is the figure the two sections share. Quote the sentence you mean.
They list 12 command addresses and say three were still active at publication:
92.119.164[.]50, first seen July 23, 2026103.249.201[.]108, first seen August 13, 2026178.128.14[.]204, first seen June 16, 2026
The others, with the windows they give: 191.37.28[.]160 (April 13 to May 16), 89.39.253[.]46 (April 14 to June 24), 120.224.114[.]212 (May 9 to September 8), 5.78.73[.]122 (June 8 to August 22), 15.204.178[.]28 (June 14 to August 17), 92.119.165[.]74 (July 1 to July 23), 45.133.73[.]28 (July 18 to July 22), 185.132.53[.]158 (July 19 to September 12), 136.148.69[.]233 (September 12 to September 25).
Beelzebub’s further 32 addresses are in their article. A poem’s date is when the verse named the address, not a measurement of how long it was used, and some addresses may belong to third parties who have since cleaned up. Search old logs with them. Confirm current traffic before a block. Lumen says they blocked traffic to and from the command servers they had identified, on their backbone, and that Lumen Defender customers had been covered from the point the malware was found.
Delivery requests in Beelzebub’s honeypots came from 66 source addresses between July 18 and September 30. They withheld those sources, and the target ids that would identify the sensors.
The Italian comments, and what they are evidence of
About an hour after the April 13 commit, the Brazilian router contacted 57.131.5[.]211 port 80, geolocated to Italy. That host serves malwarescan[.]xyz, registered in February 2026. Lumen saw no other notable clients of the endpoint and assesses that the operator may run it. Inbound connections to the router’s port 81 then arrived from Tor exits and from a few dedicated servers. Port 81 is where victims are told to download the next file, so they read those flows as tests of the download path.
The sample they reviewed contains comments in Italian. The comments are in a figure, not transcribed in the text that was fetched for this post, so they are not quoted here. From May, command servers also kept a connection to 185.119.19[.]171, also in Italy, which had hosted Prometheus and Uptime Kuma. 5.78.73[.]122 and 178.128.14[.]204 connected there as well. Lumen’s confidence that this machine is the administrative interface is moderate, and they say so: language, netflow, and those dashboards.
Later, groups of victims probed SSH and other login pages. Many of the targets were dedicated servers in Italy. Lumen reads that as a distributed brute-force feature still being exercised, and they say its maturity is uncertain. Beelzebub’s fork account names fit an Italian-speaking operator. Account names are cheap. The comments and the two Italian servers are the evidence Lumen actually weighs. “Italian-speaking” is their assessment. It is not a name.
Ollama pulls, nine days before the report
Ollama’s POST /api/pull fetches a model from whatever registry the body names, including plain HTTP when insecure is true. The route does not require a login.
From September 28 through October 5, Beelzebub’s Ollama honeypots received 94 such pulls from 67 addresses, aimed at three registries on TCP port 9882. One model name, gate/tensorgate, was the same for every target. The others were r-orig- plus an id, then usr or usrlocal. Those two last segments are Ollama’s install prefixes. Each honeypot kept one id for eight days, across all three registries and every source address. That is what a target list looks like. All three registries had already appeared as port-81 staging hosts in the LiteLLM delivery requests. One of them, 103.249.201[.]108, is on Lumen’s command-server list. The other two they name are 62.31.247[.]107 and 76.13.49[.]213. From October 4, a POST /api/generate for a model named does-not-exist-zz arrived first. The name is a hunt key.
On October 8, CERT Polska published CVE-2026-103663. Ollama’s /api/pull passes layer digests through digestToPath without enough checking (CWE-23, relative path traversal). An unauthenticated client can supply a digest that walks out of the model store, and the layer is written somewhere else. If the process can write /usr/lib/ollama, which CERT Polska says is the default in most Ollama Docker images, the file lands where the server loads it on the next restart, and that load runs as root. The notice’s table says versions from 0.34.2 to 0.35.0. The same notice says the issue was fixed in 0.35.0. The CVE text that has circulated follows the fix sentence: 0.35.0 is the patched release. Beelzebub tells operators to move to 0.35.0 or later. The table’s upper bound is the line that does not match the fix line. Confirm the binary, and treat 0.35.0 as the first release the prose calls fixed.
Beelzebub’s honeypots refused the pulls, so the layers were never stored. They say the requests by themselves do not show whether CVE-2026-103663 was the goal. The timing sits next to the disclosure. It does not identify the bug.
A timeline the papers will support
- December 6, 2025. Beelzebub: public feeds log
colonnaserved from194.69.203[.]32port 81. - December 9, 2025. First numbered verse, in the
discord.jsfork. - February 15, 2026. The 446-byte downloader, aimed at
191.37.28[.]163. - February 2026.
malwarescan[.]xyzis registered, per Lumen. - March 7 and March 12. The deepin-wine and Umbrel poem files begin.
- April 13.
dash.cssis committed. Lumen decodes191.37.28[.]160. About an hour later that address contacts the Italian host. Thediscord.jschannel publishes another verse the same day. - April 21. GHSA-v4p8-mg3p-g94g.
- May 8. LiteLLM 1.83.7, by Horizon3’s dating.
- May. Lumen: scanning widens, especially against LiteLLM and Gotenberg. The poem names
120.224.114[.]212on May 7. Telemetry sees it on May 9. - May 26. Public write-ups of CVE-2026-48710. Horizon3 dates the chained confirmation to June 1.
- Early June. A compromised Ivanti Sentry contacts
5.78.73[.]122and starts scanning. That is Lumen’s entry into the campaign. - June 16 to 18. The Umbrel poem and the miner’s sandbox session meet at
178.128.14[.]204. The stanza format begins. Mid-June is the peak in Lumen’s narrative. - July 18. First delivery request on Beelzebub’s LiteLLM honeypot.
- August 14, 20, and 24. The request gains the name
system-monitor, a short decoder around the loader, and a choice of architecture. - September 28 to October 5. The Ollama pulls.
- October 7. Lumen’s report, by Beelzebub’s date. October 7, 2026 was a Wednesday, which matches the weekday CyberScoop used for the report. Lumen’s page, as fetched for this post, does not print a dateline in the article text.
- October 8. CERT Polska on CVE-2026-103663.
- October 12. Beelzebub: the saved poem commits still resolved.
- October 13. Beelzebub’s write-up.
If one of these is on a machine you run
- A LiteLLM proxy older than 1.83.7, reachable from a network, will start a program for a holder of an ordinary key. Take a release LiteLLM lists for the Host-header fix as well: 1.84.0 or newer, or a backport they name, including
1.83.10-stable.patch.3, 1.84.3, 1.85.2, and 1.86.2. If the upgrade waits, refusePOST /mcp-rest/test/connectionandPOST /mcp-rest/test/tools/listat the proxy in front. That is the workaround in the advisory. A key embedded in an application is not a reason to publish the proxy. - Ollama reachable from a network you do not trust should be 0.35.0 or newer. The pull route has no login. CERT Polska’s condition for execution as root is a process that can write
/usr/lib/ollama. Removing that write removes the restart step on a version you have not upgraded yet. The upgrade is still the fix for the traversal. - Lumen does not publish a PoeLLM-specific patch for Gotenberg or Gitea. Both were in the victim set because they answered from the internet. Gotenberg’s installation guide already says not to do that. Ports 3000 and 4000 open to the world were the scan.
- Upgrading does not remove a program that is already installed. Look for a bare file named
libgcrypt, a process started with an id beginningllrig-and a port argument of 10, and hidden executables under/tmpor/dev/shmwhose names begin with a dot. Look for/tmp/.srvip, files namedbridge_srv_ip_,/etc/profile.d/dbus-mgr.sh, crontab lines tagged#snt-, and a systemd unit whoseKillSignalisSIGCONT.MANAGED_BY_SENTINELin a process environment is the supervisor’s own label. Clear the immutable flag before deleting the binary, and remove the unit, the cron lines, and the shell hook in the same pass. A miner that is gone, with the supervisor still supervised, is the failure this campaign is built to survive. - In access logs, a POST to either MCP test route whose body defines a server, especially one named
system-monitor, is the delivery Beelzebub recorded.GET /v1/modelsand then/openapi.jsonfrom a client calling itselfaxios/1.14.0is the discovery pass. On Ollama, a pull withinsecureset, toward port 9882, namedgate/tensorgateorr-orig-, is the September stage. Logs that drop the body will not show the model name or the server name. A hit is a reason to do the file check. It does not, by itself, prove the command ran. - The command ports in the two reports are 3778, 5001, 5002, and 9999. Staging is TCP 81. The poem relays use 1458. The Ollama registries they name use 9882. A server process fetching the same raw GitHub file, over and over, is the dead drop. The upstream repository is not the indicator. The added file in the fork is.
- If a manager or a shell ran as the proxy’s user, rotate the credentials that user could read. A LiteLLM proxy holds provider keys. An Ollama host holds models and often whatever else was mounted into that container. Mining time on a GPU is the noisy half. The quiet half is the supervisor, and the keys.
Sources
- Black Lotus Labs, Canto incognito: tracking the PoeLLM malware, October 7, 2026 by Beelzebub’s dating.
- Beelzebub Labs, Zachary Gardner, Reading the Rest of the Poem, October 13, 2026.
- BerriAI, GHSA-v4p8-mg3p-g94g, April 21, 2026, and the Host-header note.
- CVE-2026-42271.
- Horizon3, CVE-2026-42271 chained with CVE-2026-48710.
- X41, BadHost, October 7, 2026.
- CERT Polska, CVE-2026-103663, October 8, 2026.
- CyberScoop, PoeLLM malware has assembled a sweeping botnet.
- BleepingComputer, PoeLLM malware infects exposed AI servers.
- Rapid7, LiteLLM MCP test endpoint module notes.
New posts, by email.
About once a month. Plain-language privacy notes, nothing else. I never share your address.