Ukraine’s State Data-Exchange System Targeted by Russian-Speaking Operator via Graylog Log4Shell

Ukraine’s State Data-Exchange System Targeted by Russian-Speaking Operator via Graylog Log4Shell

Published on

Ukraine’s State Data-Exchange System Targeted by Russian-Speaking Operator via Graylog Log4Shell

Disclosure note: Before publishing this research, we shared our findings and the victim data we recovered with the national CERTs of the affected countries. The CERTs are handling notification of the affected organizations in their jurisdictions.


Between May and September 2026, one operator worked from a single rented server to find a specific piece of software across Ukrainian address space. The operator swept the whole country for one port and filtered 7,576 live hosts down to two using one string: "UXP Verifier".

The operator got execution on at least two hosts. The one we document in full was a Graylog logging server, and its Elasticsearch cluster held 6.8 million documents belonging to Ukraine’s state data-exchange platform.

We know what happened because the operator served the working directory over HTTP for 17 hours, and the access log of that server was inside it.

Key findings

  • Execution on two hosts. A two-stage CommonsBeanutils2 chain delivered through the Graylog sessions API ran on logging servers at two Ukrainian organizations, one of which also serves the UXP Verifier. In both cases the target fetched the stager within seconds of the trigger. The interactive shells are not directly recorded.

  • Targeting built around the data-exchange ecosystem. The pipeline filtered for "UXP Verifier", the operator resolved data-exchange gateway hostnames of several state bodies by name, and the queries on the logging cluster assumed a transaction schema the operator already knew.

  • No exfiltration observed from the data-exchange logging cluster. The operator queried indices with 6,833,058 documents and pulled aggregate counts only. That is absence of evidence, not proof nothing moved.

  • A borrowed chain did the work. Seven of the eight exploit directories are unmodified public PoCs. The operator's one piece of original exploit research, against MikroTik RouterOS, targeted a binary that does not contain the bug.

  • The operator exposed himself. The staging directory served 15,254 requests to 224 addresses. Third parties took the operator's tunnel private key 27 seconds before the victim did.

  • Hotel management systems. Using an existing ASP.NET session of unknown origin, the operator attempted SQLi, XXE and webshell uploads against a hotel management platform, and used its chat services as pentest01@proton.me (account created 2025/11/28).

  • E-government integration services. Bash history shows enumeration of a national e-government integration service’s infrastructure including attempts to validate integration credentials, retrieve WSDL metadata (the getdoc9003* files show this may have been successful), and interaction with document processing interfaces.

How we found it

The operator started a Python HTTP server on port 8888 at 11:50:36 UTC on 3 September 2026, pointed at /root. The first external visitor arrived 27 seconds later. Our AttackCapture indexed the directory before it went offline at 04:47:28 UTC on 4 September.

Figure 1. AttackCapture view of the operator's staging directory on 31[.]77[.]220[.]141:8888.

Over those 17 hours the server handed out 15,254 requests to 224 distinct addresses. Crawlers copied the exploit trees, Tor exit nodes showed up, and at least a dozen unrelated parties downloaded the operator's reconnaissance output.

There is no implant in the directory. No loader, no beacon, no persistence. It is an operator's working folder: a Java payload builder, two serialized payloads, a shell stager, an orchestration script, 1,682 lines of shell history, the full output of a national scan, and the staging server's own access log.

We hold 193 of the files. The platform indexed 361, and most of the gap is a go/ tooling tree of 169 items that we did not retrieve. Everything below is based on the files we hold.

Mapping the data-exchange ecosystem

A country as a target list

The operator did not pick victims one by one. The starting point was a public list of Ukrainian IP ranges, downloaded, renamed to cidr-ua.txt, and counted before anything else ran.

Figure 12

Figure 2. The setup session. The wget line names the source of the target list, a public range of one country's address space.

The file holds 2,874 networks and 9,102,336 addresses. The same pipeline pointed at another country's list would have produced the same campaign.

The operator swept it twice with masscan 1.3.2 on 3 September, four minutes apart, for a single port: 5000. After deduplication the two runs left 7,576 live addresses.

One port, one string

Then came the step that shows intent. The filter was not a banner or a vulnerability check. The operator requested every one of the 7,576 hosts over HTTPS on port 5000, 60 at a time, and kept only those whose response contained "UXP Verifier". UXP is the Cybernetica platform the data-exchange system is built on. The UXP Verifier is the web interface for a Security Server's message log, where every signed message the organization exchanges is stored. A host that returns that string is almost certainly a participant in the data-exchange system, and it sits right next to its transaction records.

Figure 3. The fingerprint filter in the operator's shell history. Result addresses are redacted.

The result file, loot/verifiers.txt, holds two addresses. Both sit in Ukrainian networks and both show the same port layout: 22, 80, 5000 and 5432. That looks like a packaged product deployed at more than one organization.

A third host running the same product does not appear in the result file. The operator had already worked it from the earlier server covered under "An earlier staging server", and by the time of the sweep it no longer answered on port 5000 in our scan data.

One of the two had been on the operator's radar since May, working by hand before masscan was even installed. The country-wide sweep confirmed a target already chosen.

A hand-built shortlist

On 2 September, the day before the national sweep, the operator probed a short, hand-built list for ports 5500, 5577 and 4000, under a Russian comment that calls them the UXP ports.

Figure 4. The priority probe, under the operator's comment ("quick probe on UXP ports").

Those three ports are the standard security-server ports of X-Road, the platform UXP is built on: 5500 for message exchange between servers, 5577 for OCSP queries, and 4000 for the management UI and REST API, which the documentation says must not be reachable from the internet. The operator was probing the machine-to-machine surface of the data-exchange product itself, not the "UXP Verifier" web page that served on port 5000.

Reverse DNS on that list returns three Ukrainian government names across different state bodies. This is the only place in the capture where a state body looks selected rather than encountered. We have no evidence the operator accessed any of them.

The attacker also resolved and port-scanned three data-exchange hostnames by name, belonging to different state bodies. Again, the capture shows resolution and scanning only.

The chain against Graylog

The host covered in this section was not a UXP Verifier host. It was a Graylog server in the same /24 as one of them, reached by a different route. An earlier run of the same chain against a host that does serve the Verifier is covered under "An earlier staging server". The operator's automated and manual work ended up in the same organization's network.

Delivery

The orchestration script, fire3.sh, is 61 lines of bash with Russian comments. It sends one POST to the Graylog sessions endpoint (/api/system/sessions) with a JNDI string placed in the username field of the JSON body. One header matters for detection: the request carries X-Requested-By: cli, which the target application enforces as a CSRF control. Without it the request is rejected instead of parsed, so its presence is a reliable indicator of an attempt against this platform.

Figure 5. The trigger function, the stager generator and the pre-flight check.

Delivery is split across two serialized objects with a deliberate pause between them. Stage 1 makes the target fetch a shell script from the operator's server. Stage 2 executes it, opening a reverse shell. The payloads had a small compiled Java class, MakeCB2.class, that takes a command string and writes the serialized object. Only two lines differ from the stock public gadget, both changes that make the payload more reliable.

Figure 6. The payload builder. Only the highlighted lines differ from the public version.

One detail is worth stating for defenders. The command runs from the translet's static initializer, so it fires when the class is loaded, before any method hook could observe it. EDR rules written against Runtime.exec call sites in application logic will miss it. A spawned child process is the only reliable signal. The stable content string across payloads built this way is the template name ysoserial/payloads/util/Gadgets$StubTransletPayload.

The proof

The operator had no automatic signal that a blind deserialisation had worked, so the script includes one: sleep, print the tail of the staging access log, then wait. That decision put the decisive record in a file we recovered.

Figure 7. The staging access log. The target fetch is the sixth line, with its address redacted.

The first lines are the operator's own smoke test and the script's pre-flight check from loopback. The sixth line is the target, 212[.]26[.]x[.]x, fetching the stager six seconds after the trigger. Three things line up: the six-second gap matches the script's own timing, the host made exactly three requests in 17 hours while every other visitor made hundreds and walked the tree alphabetically, and the file it took is the one the operator's own compiled payload was written to fetch.

Two limits stay on the record. The reverse shell has no listener log in the capture, so the interactive session is not confirmed by direct record. And the LDAP channel is inferred from the payload mode. A hunt for this technique should use the staging server's access log, not the JNDI listener log, which shows only unrelated scanner noise.

What the operator did on the host

The operator's first commands measured the environment before touching anything specific. A date histogram over graylog_* returned monthly volume peaking at 33,306,711 in September 2025 and falling to zero from March 2026 on. A second aggregation named the owner:

{“key”:”[redacted]”,”doc_count”:6833058}

                
Copy

The third query is the one that shows prior knowledge. Against a uxp index the operator issued five aggregations at once, over client_member_code, service_member_code, service_code, message_user_id, and succeeded. An analyst exploring an unfamiliar index asks for a mapping or runs one test aggregation. Five at once means the schema was already known. Those fields describe a transaction system: who sends, who receives, what kind of message, under which identity, and whether it worked. The succeeded aggregation in particular is the question you ask when deciding whether a dataset is worth taking.

On collection the capture is clear. There is no elasticdump, no redirect of a query to a file, no tar, no scp, no upload of any kind, in a shell history that otherwise records trivial commands. The queries returned aggregate counts. A twenty-bucket aggregation over six million documents measures a dataset, it does not move one. We record no exfiltration, and note that this is an absence of evidence rather than proof nothing left.

Forty-six minutes after the stager, the same host collected the operator's tunnel keypair, public half first and private half second. That order is what a machine being configured as an SSH tunnel client looks like. The account built for it has no login shell and no TTY but allows remote port forwarding. It is a pivot, not persistence, and the capture shows the key fetched but never shows it installed or used.

Other activity in the capture

Three lines of work sit around the main chain and none of them landed.

Subnet expansion. The operator took the 137 addresses on the curated list, expanded each into its surrounding /27, and swept the resulting 3,472 addresses for Winbox and RouterOS web ports, feeding whatever answered into a CVE-2018-14847 loop. Those 3,472 hosts were targets only by proximity to hosts already chosen.

Fortinet. The most-referenced target in the archive is a FortiGate SSL-VPN, touched 170 times with six different tools, including a WordPress plugin scanner pointed at a firewall with its placeholder target list intact. Nothing held. The effort is out of proportion to everything else and shows no plan connecting the tools.

RouterOS. This is the operator's only original exploit research, and it is the most interesting failure in the capture. It deserves its own writeup, so we cover it briefly here. The target was CVE-2021-41987, a heap overflow in the RouterOS SCEP service, on a device running an affected version. The operator understood the bug well: the crash-test payloads derive the overflow arithmetic explicitly.

Figure 8. The crash-test payloads, each annotated with the operator's own derivation.

The problem is the binary under analysis. The operator extracted /nova/bin/www, the RouterOS web server, and searched it for SCEP strings. They are not there. That binary is a plugin host: it loads its handlers from /nova/lib/www/ at run time, and the SCEP handler is one of those modules. The operator analysed the loader, not the module.

Figure 9. The servlet loader. The binary loads its handlers from disk; it does not contain them.

The operator's own scanner ran the search that answers the question. The strings it looks for (base64, scep, PKIOperation, unable to decode, malloc, free) are a precise description of the defect, and none of them are in the file. No completed chain was recovered. The operator spent weeks on a component that cannot contain the bug being chased.

Attribution

We do not attribute this activity to a tracked actor.

We assess with high confidence that 31[.]77[.]220[.]141 and 85[.]239[.]144[.]170 were run by the same operator. The earlier server holds the direct predecessors of fire3.sh, built with the same payload builder, stage names, listener ports and Russian comments, and the later server's shell history uses the earlier one as its JNDI callback.

We assess with medium confidence that the objective was collection. The starting point is a published list of Ukrainian ranges, the work stayed mostly inside it, and it points at transactional and logging infrastructure rather than commodity targets. On the host documented in full, the operator's first act was to measure two business indices. There was no tooling deployed, no persistence, and no credential hunting on that host.

Detection and hunting

The durable signatures are in the payload content and the request patterns. The addresses and paths do not survive a rebuild of the operator's server and are for retrohunting.

  • On the target application: POST requests to /api/system/sessions carrying a ${jndi: substring in the username field, paired with the X-Requested-By: cli header.

  • On the host: a child process spawned from the Java service, since the command fires from class initialisation and not from a method call site.

  • Content match: the template name ysoserial/payloads/util/Gadgets$StubTransletPayload, stable across payloads built this way.

  • Operator infrastructure: the port combination 8888 for staging alongside 1389 and 8890 for a JNDI listener has no common legitimate use.

  • For this platform's exposure: searching for the product's page title across a region measures how widely it is deployed.

The product's title is rare in the address space the operator searched, and the operator's own tools appear nowhere else in our open-directory coverage. To check this exposure in your own region, run the following HuntSQL query, which returns HTTP records whose page title contains "UXP Verifier":

SELECT
  ip,
  html.head.title
FROM
  httpv2
WHERE
  html.head.title LIKE '%UXP Verifier%'
  AND timestamp gt '2026-01-16'

                
Copy

Output example:

Figure 10. HTTP records whose page title contains the product string. Addresses redacted.

An earlier staging server

The operator's shell history names a second server. The first Log4Shell attempts against the Graylog target point to the JNDI lookup at 85[.]239[.]144[.]170, under the operator's own Russian comment "username in the session is the cleanest one". That server, on AS207043 (DEDIK SERVICES LIMITED), served its own open directory on port 8888, indexed 16 days before this one, and it holds the earlier versions of the same chain.

Figure 11. AttackCapture view of the earlier open directory on 85[.]239[.]144[.]170.

Its dl/ folder contains fire.sh and fire2.sh, the direct predecessors of fire3.sh: same payload builder, same stage names, same rogue JNDI listener on 1389 and 8890, same trigger against the Graylog sessions API. They were aimed at a different Ukrainian organization, at a host that serves the UXP Verifier page on port 5000, Graylog on 9000, and the UXP ports 4000, 5500 and 5577.

The logs show the chain landing there as well. On 24 August 2026 the target fetched the stager six seconds after the operator's own loopback check, the same pattern as in Figure 7, and the JNDI listener log records a Graylog instance resolving the lookups. A script named tun4000.sh then opens an SSH tunnel as root to that host with a key named lab_[redacted], forwarding port 4000, the UXP management interface. The tunnel log records no authentication error, but nothing in the capture shows what crossed it.

The same server also holds the 1.5 GB exfil/ directory and tooling against internal hosts, which point to activity outside the scope of this report.

Indicators of compromise

Victim addresses are generalized to sectors in this public version. Operator infrastructure is listed in full.

IndicatorTypeRoleConfidence
31[.]77[.]220[.]141IPStaging server. Hosted the payload, keypair and directory.Confirmed
31[.]77[.]220[.]141:8888IP:portStaging HTTP server. Holds the access log.Confirmed
31[.]77[.]220[.]141:1389IP:portRogue JNDI listener.Confirmed
31[.]77[.]220[.]141:8890IP:portRogue HTTP listener. Configured, never contacted.Confirmed
31[.]77[.]220[.]141:8442IP:portReverse shell port. No listener log recovered.Confirmed
85[.]239[.]144[.]170IPEarlier staging server. Hosted fire.sh and fire2.sh, the JNDI listener and the stager used against a second target. Also the JNDI reference in later attempts against the Graylog target.Confirmed
85[.]239[.]144[.]170:1389IP:portRogue JNDI listener.Confirmed
85[.]239[.]144[.]170:8889IP:portStager HTTP server and reverse shell port.Confirmed
scanitex[.]comDomainPublic IP-range service. Source of the target list. Do not block.Legitimate
pentest01@proton[.]meEmailAccount used by the operator on a hotel platform’s chat services. Created 2025/11/28.Confirmed
AS218731 (qwins[.]co)ASNHosting for the operator's server, per Hunt.io's current record.Infrastructure
AS207043 (dedik[.]io)ASNHosting for the earlier staging server 85[.]239[.]144[.]170.Infrastructure

Victim and target hosts (the Graylog logging server, a second Graylog server that also serves the UXP Verifier, the two UXP Verifier hosts from the sweep, a FortiGate, two RouterOS devices, and the probed government hosts) were shared with the relevant CERTs and are held back here.

File hashes

Directory Location + FilenameHash
dl/fire3.shAB8C39638608738349FDDB02F73A0BB5712809B22CD2863F57829E8D6F35C24D
dl/pwn.sh70AF26F3FD65472877BA5A3676A60D4A7EA7BBCCEDDC8E363CA795F36BB96AC4
cb2/MakeCB2.class6DDE072A64933967456A57610DCE8978DAB195F9F9DE66A0ECD1B1C7BFACACAE
cb2/s1.ser40103A4AE0ABBEF8F35A7ADCE7F50A9F536198A850B6EEB9761D6470D1FFFA9E
cb2/s2.serF1F5F46F5E5E3D111779310CF3B57461A18F12F903A88290A035993DC72D6A14
dl/fire.sh (85[.]239[.]144[.]170)D2753DC8BB3FFC5B6AEEA02A6BD2FAA9D83C4D6F4C9E8AE207767FE6614609E3
dl/fire2.sh (85[.]239[.]144[.]170)7E43FE3888AC282B04074916FC4C5E33697A9F254403537E552F11B7CBD4AC4B

Summary

This was a solo, low-cost campaign against Ukrainian infrastructure, built on a published IP list and a few hundred lines of shell and Python, and it succeeded at least twice. The reconnaissance was competent, but the only chain that produced access was a reused public one. The operator's own RouterOS exploit never landed.

The operation was exposed by the operator's own mistake, not by a defender. The working directory sat open for 17 hours, and that is where the evidence lived: the record that proves execution, dates the campaign, and maps the target list. Watching for exposed attacker infrastructure is not a side channel here. It is the whole case.

Our AttackCapture indexed both of this operator's staging servers while they were still online. If you want to watch for exposed attacker infrastructure targeting your sector, book a demo and we'll walk through it with your own data.

Disclosure note: Before publishing this research, we shared our findings and the victim data we recovered with the national CERTs of the affected countries. The CERTs are handling notification of the affected organizations in their jurisdictions.


Between May and September 2026, one operator worked from a single rented server to find a specific piece of software across Ukrainian address space. The operator swept the whole country for one port and filtered 7,576 live hosts down to two using one string: "UXP Verifier".

The operator got execution on at least two hosts. The one we document in full was a Graylog logging server, and its Elasticsearch cluster held 6.8 million documents belonging to Ukraine’s state data-exchange platform.

We know what happened because the operator served the working directory over HTTP for 17 hours, and the access log of that server was inside it.

Key findings

  • Execution on two hosts. A two-stage CommonsBeanutils2 chain delivered through the Graylog sessions API ran on logging servers at two Ukrainian organizations, one of which also serves the UXP Verifier. In both cases the target fetched the stager within seconds of the trigger. The interactive shells are not directly recorded.

  • Targeting built around the data-exchange ecosystem. The pipeline filtered for "UXP Verifier", the operator resolved data-exchange gateway hostnames of several state bodies by name, and the queries on the logging cluster assumed a transaction schema the operator already knew.

  • No exfiltration observed from the data-exchange logging cluster. The operator queried indices with 6,833,058 documents and pulled aggregate counts only. That is absence of evidence, not proof nothing moved.

  • A borrowed chain did the work. Seven of the eight exploit directories are unmodified public PoCs. The operator's one piece of original exploit research, against MikroTik RouterOS, targeted a binary that does not contain the bug.

  • The operator exposed himself. The staging directory served 15,254 requests to 224 addresses. Third parties took the operator's tunnel private key 27 seconds before the victim did.

  • Hotel management systems. Using an existing ASP.NET session of unknown origin, the operator attempted SQLi, XXE and webshell uploads against a hotel management platform, and used its chat services as pentest01@proton.me (account created 2025/11/28).

  • E-government integration services. Bash history shows enumeration of a national e-government integration service’s infrastructure including attempts to validate integration credentials, retrieve WSDL metadata (the getdoc9003* files show this may have been successful), and interaction with document processing interfaces.

How we found it

The operator started a Python HTTP server on port 8888 at 11:50:36 UTC on 3 September 2026, pointed at /root. The first external visitor arrived 27 seconds later. Our AttackCapture indexed the directory before it went offline at 04:47:28 UTC on 4 September.

Figure 1. AttackCapture view of the operator's staging directory on 31[.]77[.]220[.]141:8888.

Over those 17 hours the server handed out 15,254 requests to 224 distinct addresses. Crawlers copied the exploit trees, Tor exit nodes showed up, and at least a dozen unrelated parties downloaded the operator's reconnaissance output.

There is no implant in the directory. No loader, no beacon, no persistence. It is an operator's working folder: a Java payload builder, two serialized payloads, a shell stager, an orchestration script, 1,682 lines of shell history, the full output of a national scan, and the staging server's own access log.

We hold 193 of the files. The platform indexed 361, and most of the gap is a go/ tooling tree of 169 items that we did not retrieve. Everything below is based on the files we hold.

Mapping the data-exchange ecosystem

A country as a target list

The operator did not pick victims one by one. The starting point was a public list of Ukrainian IP ranges, downloaded, renamed to cidr-ua.txt, and counted before anything else ran.

Figure 12

Figure 2. The setup session. The wget line names the source of the target list, a public range of one country's address space.

The file holds 2,874 networks and 9,102,336 addresses. The same pipeline pointed at another country's list would have produced the same campaign.

The operator swept it twice with masscan 1.3.2 on 3 September, four minutes apart, for a single port: 5000. After deduplication the two runs left 7,576 live addresses.

One port, one string

Then came the step that shows intent. The filter was not a banner or a vulnerability check. The operator requested every one of the 7,576 hosts over HTTPS on port 5000, 60 at a time, and kept only those whose response contained "UXP Verifier". UXP is the Cybernetica platform the data-exchange system is built on. The UXP Verifier is the web interface for a Security Server's message log, where every signed message the organization exchanges is stored. A host that returns that string is almost certainly a participant in the data-exchange system, and it sits right next to its transaction records.

Figure 3. The fingerprint filter in the operator's shell history. Result addresses are redacted.

The result file, loot/verifiers.txt, holds two addresses. Both sit in Ukrainian networks and both show the same port layout: 22, 80, 5000 and 5432. That looks like a packaged product deployed at more than one organization.

A third host running the same product does not appear in the result file. The operator had already worked it from the earlier server covered under "An earlier staging server", and by the time of the sweep it no longer answered on port 5000 in our scan data.

One of the two had been on the operator's radar since May, working by hand before masscan was even installed. The country-wide sweep confirmed a target already chosen.

A hand-built shortlist

On 2 September, the day before the national sweep, the operator probed a short, hand-built list for ports 5500, 5577 and 4000, under a Russian comment that calls them the UXP ports.

Figure 4. The priority probe, under the operator's comment ("quick probe on UXP ports").

Those three ports are the standard security-server ports of X-Road, the platform UXP is built on: 5500 for message exchange between servers, 5577 for OCSP queries, and 4000 for the management UI and REST API, which the documentation says must not be reachable from the internet. The operator was probing the machine-to-machine surface of the data-exchange product itself, not the "UXP Verifier" web page that served on port 5000.

Reverse DNS on that list returns three Ukrainian government names across different state bodies. This is the only place in the capture where a state body looks selected rather than encountered. We have no evidence the operator accessed any of them.

The attacker also resolved and port-scanned three data-exchange hostnames by name, belonging to different state bodies. Again, the capture shows resolution and scanning only.

The chain against Graylog

The host covered in this section was not a UXP Verifier host. It was a Graylog server in the same /24 as one of them, reached by a different route. An earlier run of the same chain against a host that does serve the Verifier is covered under "An earlier staging server". The operator's automated and manual work ended up in the same organization's network.

Delivery

The orchestration script, fire3.sh, is 61 lines of bash with Russian comments. It sends one POST to the Graylog sessions endpoint (/api/system/sessions) with a JNDI string placed in the username field of the JSON body. One header matters for detection: the request carries X-Requested-By: cli, which the target application enforces as a CSRF control. Without it the request is rejected instead of parsed, so its presence is a reliable indicator of an attempt against this platform.

Figure 5. The trigger function, the stager generator and the pre-flight check.

Delivery is split across two serialized objects with a deliberate pause between them. Stage 1 makes the target fetch a shell script from the operator's server. Stage 2 executes it, opening a reverse shell. The payloads had a small compiled Java class, MakeCB2.class, that takes a command string and writes the serialized object. Only two lines differ from the stock public gadget, both changes that make the payload more reliable.

Figure 6. The payload builder. Only the highlighted lines differ from the public version.

One detail is worth stating for defenders. The command runs from the translet's static initializer, so it fires when the class is loaded, before any method hook could observe it. EDR rules written against Runtime.exec call sites in application logic will miss it. A spawned child process is the only reliable signal. The stable content string across payloads built this way is the template name ysoserial/payloads/util/Gadgets$StubTransletPayload.

The proof

The operator had no automatic signal that a blind deserialisation had worked, so the script includes one: sleep, print the tail of the staging access log, then wait. That decision put the decisive record in a file we recovered.

Figure 7. The staging access log. The target fetch is the sixth line, with its address redacted.

The first lines are the operator's own smoke test and the script's pre-flight check from loopback. The sixth line is the target, 212[.]26[.]x[.]x, fetching the stager six seconds after the trigger. Three things line up: the six-second gap matches the script's own timing, the host made exactly three requests in 17 hours while every other visitor made hundreds and walked the tree alphabetically, and the file it took is the one the operator's own compiled payload was written to fetch.

Two limits stay on the record. The reverse shell has no listener log in the capture, so the interactive session is not confirmed by direct record. And the LDAP channel is inferred from the payload mode. A hunt for this technique should use the staging server's access log, not the JNDI listener log, which shows only unrelated scanner noise.

What the operator did on the host

The operator's first commands measured the environment before touching anything specific. A date histogram over graylog_* returned monthly volume peaking at 33,306,711 in September 2025 and falling to zero from March 2026 on. A second aggregation named the owner:

{“key”:”[redacted]”,”doc_count”:6833058}

                
Copy

The third query is the one that shows prior knowledge. Against a uxp index the operator issued five aggregations at once, over client_member_code, service_member_code, service_code, message_user_id, and succeeded. An analyst exploring an unfamiliar index asks for a mapping or runs one test aggregation. Five at once means the schema was already known. Those fields describe a transaction system: who sends, who receives, what kind of message, under which identity, and whether it worked. The succeeded aggregation in particular is the question you ask when deciding whether a dataset is worth taking.

On collection the capture is clear. There is no elasticdump, no redirect of a query to a file, no tar, no scp, no upload of any kind, in a shell history that otherwise records trivial commands. The queries returned aggregate counts. A twenty-bucket aggregation over six million documents measures a dataset, it does not move one. We record no exfiltration, and note that this is an absence of evidence rather than proof nothing left.

Forty-six minutes after the stager, the same host collected the operator's tunnel keypair, public half first and private half second. That order is what a machine being configured as an SSH tunnel client looks like. The account built for it has no login shell and no TTY but allows remote port forwarding. It is a pivot, not persistence, and the capture shows the key fetched but never shows it installed or used.

Other activity in the capture

Three lines of work sit around the main chain and none of them landed.

Subnet expansion. The operator took the 137 addresses on the curated list, expanded each into its surrounding /27, and swept the resulting 3,472 addresses for Winbox and RouterOS web ports, feeding whatever answered into a CVE-2018-14847 loop. Those 3,472 hosts were targets only by proximity to hosts already chosen.

Fortinet. The most-referenced target in the archive is a FortiGate SSL-VPN, touched 170 times with six different tools, including a WordPress plugin scanner pointed at a firewall with its placeholder target list intact. Nothing held. The effort is out of proportion to everything else and shows no plan connecting the tools.

RouterOS. This is the operator's only original exploit research, and it is the most interesting failure in the capture. It deserves its own writeup, so we cover it briefly here. The target was CVE-2021-41987, a heap overflow in the RouterOS SCEP service, on a device running an affected version. The operator understood the bug well: the crash-test payloads derive the overflow arithmetic explicitly.

Figure 8. The crash-test payloads, each annotated with the operator's own derivation.

The problem is the binary under analysis. The operator extracted /nova/bin/www, the RouterOS web server, and searched it for SCEP strings. They are not there. That binary is a plugin host: it loads its handlers from /nova/lib/www/ at run time, and the SCEP handler is one of those modules. The operator analysed the loader, not the module.

Figure 9. The servlet loader. The binary loads its handlers from disk; it does not contain them.

The operator's own scanner ran the search that answers the question. The strings it looks for (base64, scep, PKIOperation, unable to decode, malloc, free) are a precise description of the defect, and none of them are in the file. No completed chain was recovered. The operator spent weeks on a component that cannot contain the bug being chased.

Attribution

We do not attribute this activity to a tracked actor.

We assess with high confidence that 31[.]77[.]220[.]141 and 85[.]239[.]144[.]170 were run by the same operator. The earlier server holds the direct predecessors of fire3.sh, built with the same payload builder, stage names, listener ports and Russian comments, and the later server's shell history uses the earlier one as its JNDI callback.

We assess with medium confidence that the objective was collection. The starting point is a published list of Ukrainian ranges, the work stayed mostly inside it, and it points at transactional and logging infrastructure rather than commodity targets. On the host documented in full, the operator's first act was to measure two business indices. There was no tooling deployed, no persistence, and no credential hunting on that host.

Detection and hunting

The durable signatures are in the payload content and the request patterns. The addresses and paths do not survive a rebuild of the operator's server and are for retrohunting.

  • On the target application: POST requests to /api/system/sessions carrying a ${jndi: substring in the username field, paired with the X-Requested-By: cli header.

  • On the host: a child process spawned from the Java service, since the command fires from class initialisation and not from a method call site.

  • Content match: the template name ysoserial/payloads/util/Gadgets$StubTransletPayload, stable across payloads built this way.

  • Operator infrastructure: the port combination 8888 for staging alongside 1389 and 8890 for a JNDI listener has no common legitimate use.

  • For this platform's exposure: searching for the product's page title across a region measures how widely it is deployed.

The product's title is rare in the address space the operator searched, and the operator's own tools appear nowhere else in our open-directory coverage. To check this exposure in your own region, run the following HuntSQL query, which returns HTTP records whose page title contains "UXP Verifier":

SELECT
  ip,
  html.head.title
FROM
  httpv2
WHERE
  html.head.title LIKE '%UXP Verifier%'
  AND timestamp gt '2026-01-16'

                
Copy

Output example:

Figure 10. HTTP records whose page title contains the product string. Addresses redacted.

An earlier staging server

The operator's shell history names a second server. The first Log4Shell attempts against the Graylog target point to the JNDI lookup at 85[.]239[.]144[.]170, under the operator's own Russian comment "username in the session is the cleanest one". That server, on AS207043 (DEDIK SERVICES LIMITED), served its own open directory on port 8888, indexed 16 days before this one, and it holds the earlier versions of the same chain.

Figure 11. AttackCapture view of the earlier open directory on 85[.]239[.]144[.]170.

Its dl/ folder contains fire.sh and fire2.sh, the direct predecessors of fire3.sh: same payload builder, same stage names, same rogue JNDI listener on 1389 and 8890, same trigger against the Graylog sessions API. They were aimed at a different Ukrainian organization, at a host that serves the UXP Verifier page on port 5000, Graylog on 9000, and the UXP ports 4000, 5500 and 5577.

The logs show the chain landing there as well. On 24 August 2026 the target fetched the stager six seconds after the operator's own loopback check, the same pattern as in Figure 7, and the JNDI listener log records a Graylog instance resolving the lookups. A script named tun4000.sh then opens an SSH tunnel as root to that host with a key named lab_[redacted], forwarding port 4000, the UXP management interface. The tunnel log records no authentication error, but nothing in the capture shows what crossed it.

The same server also holds the 1.5 GB exfil/ directory and tooling against internal hosts, which point to activity outside the scope of this report.

Indicators of compromise

Victim addresses are generalized to sectors in this public version. Operator infrastructure is listed in full.

IndicatorTypeRoleConfidence
31[.]77[.]220[.]141IPStaging server. Hosted the payload, keypair and directory.Confirmed
31[.]77[.]220[.]141:8888IP:portStaging HTTP server. Holds the access log.Confirmed
31[.]77[.]220[.]141:1389IP:portRogue JNDI listener.Confirmed
31[.]77[.]220[.]141:8890IP:portRogue HTTP listener. Configured, never contacted.Confirmed
31[.]77[.]220[.]141:8442IP:portReverse shell port. No listener log recovered.Confirmed
85[.]239[.]144[.]170IPEarlier staging server. Hosted fire.sh and fire2.sh, the JNDI listener and the stager used against a second target. Also the JNDI reference in later attempts against the Graylog target.Confirmed
85[.]239[.]144[.]170:1389IP:portRogue JNDI listener.Confirmed
85[.]239[.]144[.]170:8889IP:portStager HTTP server and reverse shell port.Confirmed
scanitex[.]comDomainPublic IP-range service. Source of the target list. Do not block.Legitimate
pentest01@proton[.]meEmailAccount used by the operator on a hotel platform’s chat services. Created 2025/11/28.Confirmed
AS218731 (qwins[.]co)ASNHosting for the operator's server, per Hunt.io's current record.Infrastructure
AS207043 (dedik[.]io)ASNHosting for the earlier staging server 85[.]239[.]144[.]170.Infrastructure

Victim and target hosts (the Graylog logging server, a second Graylog server that also serves the UXP Verifier, the two UXP Verifier hosts from the sweep, a FortiGate, two RouterOS devices, and the probed government hosts) were shared with the relevant CERTs and are held back here.

File hashes

Directory Location + FilenameHash
dl/fire3.shAB8C39638608738349FDDB02F73A0BB5712809B22CD2863F57829E8D6F35C24D
dl/pwn.sh70AF26F3FD65472877BA5A3676A60D4A7EA7BBCCEDDC8E363CA795F36BB96AC4
cb2/MakeCB2.class6DDE072A64933967456A57610DCE8978DAB195F9F9DE66A0ECD1B1C7BFACACAE
cb2/s1.ser40103A4AE0ABBEF8F35A7ADCE7F50A9F536198A850B6EEB9761D6470D1FFFA9E
cb2/s2.serF1F5F46F5E5E3D111779310CF3B57461A18F12F903A88290A035993DC72D6A14
dl/fire.sh (85[.]239[.]144[.]170)D2753DC8BB3FFC5B6AEEA02A6BD2FAA9D83C4D6F4C9E8AE207767FE6614609E3
dl/fire2.sh (85[.]239[.]144[.]170)7E43FE3888AC282B04074916FC4C5E33697A9F254403537E552F11B7CBD4AC4B

Summary

This was a solo, low-cost campaign against Ukrainian infrastructure, built on a published IP list and a few hundred lines of shell and Python, and it succeeded at least twice. The reconnaissance was competent, but the only chain that produced access was a reused public one. The operator's own RouterOS exploit never landed.

The operation was exposed by the operator's own mistake, not by a defender. The working directory sat open for 17 hours, and that is where the evidence lived: the record that proves execution, dates the campaign, and maps the target list. Watching for exposed attacker infrastructure is not a side channel here. It is the whole case.

Our AttackCapture indexed both of this operator's staging servers while they were still online. If you want to watch for exposed attacker infrastructure targeting your sector, book a demo and we'll walk through it with your own data.