<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://armysick.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://armysick.github.io/" rel="alternate" type="text/html" /><updated>2026-07-09T04:09:30+00:00</updated><id>https://armysick.github.io/feed.xml</id><title type="html">@ArmySick</title><subtitle>Security research, red team, offensive tooling.</subtitle><author><name>ArmySick</name></author><entry><title type="html">Go to sleep, Sliver! - Sleep masking Sliver and its Go runtime</title><link href="https://armysick.github.io/sliver-sleep-obfuscation/" rel="alternate" type="text/html" title="Go to sleep, Sliver! - Sleep masking Sliver and its Go runtime" /><published>2026-07-09T00:00:00+00:00</published><updated>2026-07-09T00:00:00+00:00</updated><id>https://armysick.github.io/sleep-obfuscation-sliver-go</id><content type="html" xml:base="https://armysick.github.io/sliver-sleep-obfuscation/"><![CDATA[<p>I set out to add in-memory sleep obfuscation to <a href="https://github.com/BishopFox/sliver">Sliver</a>’s Go implant. The plan was simple: port the well-known <a href="https://github.com/Cracked5pider/Ekko">Ekko</a> technique, wire it behind a <code class="language-plaintext highlighter-rouge">--sleep-obfuscation</code> flag, and call it a PR. Oh boy, was I wrong on it being simple.</p>

<p>What followed was a much longer investigation into an interaction between Windows’ <code class="language-plaintext highlighter-rouge">SuspendThread</code> API and Go’s runtime scheduler that, as far as I can tell, isn’t documented anywhere. This post is the story, ending in a working design and a blueprint for anyone else who wants to try this on a Go implant.</p>

<p><img src="/assets/images/2026-07-09-sleep-obfuscation/beacon.png" alt="Beacon established with sleep mask" /></p>

<h2 id="tldr">TL;DR</h2>

<ul>
  <li>Ekko’s design (SuspendThread the caller + NtContinue-ROP the encrypt/sleep/decrypt on a thread-pool worker) is fundamentally incompatible with Go’s runtime, for reasons that took a lot of dead ends to understand.</li>
  <li>The root cause is a race between Windows’ <code class="language-plaintext highlighter-rouge">SuspendThread</code> and Go’s <code class="language-plaintext highlighter-rouge">entersyscall</code>/<code class="language-plaintext highlighter-rouge">exitsyscall</code> P-handoff mechanism. It looks like a deadlock inside the Go scheduler waiting for a P from a thread we froze.</li>
  <li>The fix is a completely different sleep-mask design: run everything on the beacon’s own thread via <code class="language-plaintext highlighter-rouge">QueueUserAPC</code> + an alertable wait, with per-peer Windows-thread-pool timers as safety-net watchdogs.</li>
  <li>Bonus OPSEC: no RWX memory anywhere in the process image at any point in the cycle.</li>
</ul>

<p>If you’re only here for the code, it lives in <a href="https://github.com/armysick/sliver/tree/apc-pr/implant/sliver/apc"><code class="language-plaintext highlighter-rouge">implant/sliver/apc/apc.go</code></a> which should have by now been added to the <a href="https://github.com/BishopFox/sliver/pull/1805/">sliver’s repo PR</a>.</p>

<h2 id="setup-what-sleep-obfuscation-is-and-why-we-want-it">Setup: what sleep obfuscation is and why we want it</h2>

<p>Beacons are noisy. In-memory scanners (moneta, PE-sieve, HollowsHunter, and every commercial EDR that ships equivalent capability) hunt for signatures across every process’s committed memory: known-bad strings, distinctive <code class="language-plaintext highlighter-rouge">.text</code> bytes, RWX regions, PE headers in unexpected places.</p>

<p>But a <em>beacon at rest</em> - sleeping between check-ins, which is where it spends 99% of its time - is a giant, static target. Same bytes at the same offsets, cycle after cycle. Trivial to fingerprint.</p>

<p><strong>Sleep obfuscation</strong> flips the target for that 99%:</p>

<ol>
  <li>Right before entering the sleep window, encrypt the process image in place.</li>
  <li>Sleep for the configured interval.</li>
  <li>Right after waking up, decrypt and continue normally.</li>
</ol>

<p>For the 99% of the time you’re scannable, your <code class="language-plaintext highlighter-rouge">.text</code> and other sections look like random noise. Different noise every cycle if you rotate the key.</p>

<p>One of the most influential open-source implementation of this is <strong><a href="https://github.com/Cracked5pider/Ekko">Ekko</a></strong> by Cracked5pider. Its technique, roughly:</p>

<ul>
  <li>Suspends the beacon’s own thread and captures its register state.</li>
  <li>Queues a chain of thread-pool timers, each <code class="language-plaintext highlighter-rouge">NtContinue</code>-hijacked to run a specific step (encrypt, sleep, decrypt, restore).</li>
</ul>

<p>It works beautifully in a C beacon. It kept failing in a Go beacon. That’s where the story starts.</p>

<h2 id="first-attempt-naive-port">First attempt: naive port</h2>

<p>The first cut was mechanical: match Ekko’s C code in Go, using <code class="language-plaintext highlighter-rouge">syscall.LazyProc</code> to call the Windows APIs. Credit to scriptchildie for its work on <a href="https://github.com/scriptchildie/goEkko/tree/main/sleepobf">goEkko</a> which inspired me to attempt to follow this through.</p>

<p>Then, understand Sliver’s main beacon loop and how to integrate Ekko/sleep mask in it.</p>

<p>This runs cleanly for a while… then hangs.</p>

<p>It doesn’t crash: it <em>hangs</em>. The process is alive, threads are visible, some are running, but <code class="language-plaintext highlighter-rouge">ekko/apc.Sleep()</code> never returns, no check-ins arrive at the C2, and Process Hacker shows a handful of threads in <code class="language-plaintext highlighter-rouge">Wait:Suspended</code> state with suspend counts of 1 and some other(s) running/resumed.</p>

<p>Time to hang was random: sometimes cycle 5, sometimes cycle 500. Consistently a hang, never a crash.</p>

<h2 id="diagnostics-that-lied-to-us">Diagnostics that lied to us</h2>

<p>I burned a lot of time on ideas that turned out to solve <em>symptoms</em>, not the underlying problem.</p>

<ol>
  <li>
    <p><strong>Self-healing sweep.</strong> Query every peer thread’s <code class="language-plaintext highlighter-rouge">ThreadSuspendCount</code> via <code class="language-plaintext highlighter-rouge">NtQueryInformationThread</code> at the top of every cycle; drain any residual counts. Doesn’t change our behavior meaningfully: hangs still happen.</p>
  </li>
  <li>
    <p><strong>LockOSThread the goroutine.</strong> Prevents the goroutine from being scheduled onto a <em>different</em> OS thread between our <code class="language-plaintext highlighter-rouge">GetCurrentThreadId</code> and the syscalls that follow. Necessary — but not sufficient. Still hangs.</p>
  </li>
  <li>
    <p>Many more countless ideas which turned into nothing, like spawning “sidecar” processes and threads, implementing channels to signal thread states, re-implementing logic into goroutines, multiple sleeps/resumes for good luck (idk at this point), probably 500+ lines of debug messages and megabytes of logs.</p>
  </li>
</ol>

<p>Some of these were real bugs that I fixed on the way to the answer. Others were just a waste of time. A few were probably some wrongly-implemented mess. None was <em>the</em> bug.</p>

<h2 id="the-one-observation-that-broke-it-open">The one observation that broke it open</h2>

<p>I was staring at a hung beacon in Process Hacker. Frustrated, I right-clicked all of the suspended threads and hit “Resume” — just manually.</p>

<p><strong>The whole beacon unhung.</strong> Immediately went back to normal operation. Made a check-in. Ran for another 200 cycles before hanging again.</p>

<p>My manual resume - on some <em>other</em> thread, one that wasn’t even related to whichever thread was in <code class="language-plaintext highlighter-rouge">apc/ekko.Sleep</code> - unblocked the process. That’s only possible if the process wasn’t stuck in the kernel at all:</p>

<p><strong>It was stuck in userspace, in the Go runtime, waiting for something that only another running Go thread could provide.</strong></p>

<p>This confirmed the looming hypothesis that I had since 2024 - the first time I attempted to implement sleep mask naively in Sliver - there was a deadlock happening somewhere related to the Go runtime, and I didn’t understand enough about it to know a way forward.</p>

<p><img src="/assets/images/2026-07-09-sleep-obfuscation/resume.png" alt="Process Hacker Resume" /></p>

<h2 id="root-cause-gos-p-handoff-race-with-suspendthread">Root cause: Go’s P-handoff race with SuspendThread</h2>

<p>Here’s the Go runtime, in the shortest form that matters:</p>

<ul>
  <li><strong>M</strong> = an OS thread.</li>
  <li><strong>G</strong> = a goroutine.</li>
  <li><strong>P</strong> = a “processor slot” — a scheduling permit. Every G that wants to execute Go code needs an M <em>and</em> a P.</li>
</ul>

<p>When Go code makes a syscall - and every <code class="language-plaintext highlighter-rouge">LazyProc.Call</code> counts - the runtime executes this sequence:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>runtime.entersyscall()   // releases this M's P into an idle pool
                         // so other Ms can use it while we're blocked
&lt;kernel syscall&gt;
runtime.exitsyscall()    // tries to reacquire a P before returning
                         // to Go code
</code></pre></div></div>

<p>Under normal load this is invisible. Under our sleep mask conditions it’s lethal, and here’s exactly how:</p>

<ol>
  <li>Our loop calls <code class="language-plaintext highlighter-rouge">SuspendThread(peerN)</code>. <code class="language-plaintext highlighter-rouge">entersyscall</code> releases our P into the idle pool, while the operation is at kernel level, thus outside userspace.</li>
  <li>Between <code class="language-plaintext highlighter-rouge">entersyscall</code> and the kernel actually suspending <code class="language-plaintext highlighter-rouge">peerN</code>, some <strong>other M</strong> picks up our idle P. It’s a race with a very short window, but with dozens of syscalls per cycle we roll the dice a lot.</li>
  <li>Our <code class="language-plaintext highlighter-rouge">SuspendThread</code> returns. <code class="language-plaintext highlighter-rouge">exitsyscall</code> tries to reacquire a P. The pool is empty: our old P is held by whichever M grabbed it.</li>
  <li>Now the next iteration of our loop suspends <em>that</em> M. It’s frozen mid-execution, holding our P.</li>
  <li>Our <code class="language-plaintext highlighter-rouge">exitsyscall</code> can’t find an available P. It falls back to the scheduler’s “wait for someone to hand us a P” path — which relies on <code class="language-plaintext highlighter-rouge">sysmon</code> (Go’s runtime monitor thread) periodically checking for stuck Ms and reallocating Ps to them.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">sysmon</code> is one of the Ms we suspended.</strong> It can’t hand off. Nobody can. Our M is parked forever.</li>
</ol>

<p><img src="/assets/images/2026-07-09-sleep-obfuscation/comment.png" alt="https://github.com/golang/go/blob/c1e0cd12965106310acafbb9bd50ad0830a7283a/src/runtime/cgocall.go#L194" /></p>

<p>The whole process is running fine at the kernel level. Every thread’s state is what you’d expect. But the Go scheduler is deadlocked in userspace, and the beacon can’t make forward progress.</p>

<p>Manually resuming suspended threads - say, <code class="language-plaintext highlighter-rouge">sysmon</code> - unwedges the whole thing. <code class="language-plaintext highlighter-rouge">sysmon</code> wakes up, notices Ms parked in <code class="language-plaintext highlighter-rouge">exitsyscall</code>, hands out Ps, everything moves. It works because the manual resume came from <em>outside</em> our suspended set.</p>

<h2 id="the-same-race-second-flavor-the-resume-phase">The same race, second flavor: the resume phase</h2>

<p>The story above is what happens <em>going into</em> the sleep when we’re suspending peers. But there’s a second, symmetric race waiting on the <em>way out</em>: when we’re resuming them. Same underlying mechanism, different trigger, and I only found it because a run hung between “resume begin” and “Sleep returning” instead of before “suspend done”.</p>

<p>Setting: <code class="language-plaintext highlighter-rouge">.text</code> is decrypted and executable again. Peers are still all suspended. We iterate the peer list calling <code class="language-plaintext highlighter-rouge">ResumeThread</code> on each. Every <code class="language-plaintext highlighter-rouge">ResumeThread</code> is a <code class="language-plaintext highlighter-rouge">LazyProc.Call</code>, which as we established goes through <code class="language-plaintext highlighter-rouge">entersyscall</code>/<code class="language-plaintext highlighter-rouge">exitsyscall</code>.</p>

<p>Iteration <code class="language-plaintext highlighter-rouge">i</code>, resuming <code class="language-plaintext highlighter-rouge">peerN</code>:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">procResumeThread.Call(peerN)</code> — our M does <code class="language-plaintext highlighter-rouge">entersyscall</code> and <strong>releases its P</strong>.</li>
  <li>Kernel runs <code class="language-plaintext highlighter-rouge">ResumeThread</code>. <code class="language-plaintext highlighter-rouge">peerN</code>’s suspend count drops to 0. <strong><code class="language-plaintext highlighter-rouge">peerN</code> starts running.</strong></li>
  <li><code class="language-plaintext highlighter-rouge">peerN</code> is a Go M that was frozen mid-syscall (usually mid-<code class="language-plaintext highlighter-rouge">exitsyscall</code> from its own prior work). It wakes up still looking for a P to acquire. It looks at the idle pool → sees the P we just released → <strong>grabs it</strong>.</li>
  <li><code class="language-plaintext highlighter-rouge">peerN</code> returns to Go code with our P, and lands in a tight scheduler loop — spinning on a lock, walking work queues, whatever the runtime was doing when we froze it. It holds the P without yielding.</li>
  <li>Our <code class="language-plaintext highlighter-rouge">ResumeThread</code> syscall returns. Our <code class="language-plaintext highlighter-rouge">exitsyscall</code> looks for a P. Empty pool. The Ps that exist are held by <code class="language-plaintext highlighter-rouge">peerN</code> (running) and the <em>other</em> still-suspended peers we haven’t resumed yet.</li>
  <li>Normally <code class="language-plaintext highlighter-rouge">sysmon</code> would preempt <code class="language-plaintext highlighter-rouge">peerN</code>’s tight loop with a preemption signal and free the P for us. <strong>But <code class="language-plaintext highlighter-rouge">sysmon</code> is one of the still-suspended peers — next in the resume queue, but we can’t get there because we’re parked in <code class="language-plaintext highlighter-rouge">exitsyscall</code>.</strong></li>
</ol>

<p>Same permanent deadlock. Different mechanism to get there.</p>

<h3 id="the-difference-in-one-sentence">The difference in one sentence</h3>

<p>The suspend-phase deadlock is caused by our P being held by a thread we <em>froze</em>. The resume-phase deadlock is caused by our P being held by a thread we just <em>woke up</em> that can’t be preempted (i.e. forced to yield the P so it can be used elsewhere) because <code class="language-plaintext highlighter-rouge">sysmon</code> — the only thing that can preempt it — is still frozen from earlier in the same cycle. Same random-but-sometimes-happens outcome.</p>

<h2 id="building-it-right-the-apc-design">Building it right: the APC design</h2>

<p>Once you understand why Ekko can’t work in Go, the fix isn’t to patch Ekko — it’s to build a different design that doesn’t route the sensitive code through Go’s scheduler at all.</p>

<p>The design that stuck:</p>

<pre><code class="language-text">beacon goroutine calls apc.Sleep(ms)
  │
  ├─ LockOSThread                 (pin goroutine to this M)
  ├─ VirtualAlloc(RW) outside image, copy trampoline in, set RX
  ├─ Build apcArgs struct with trampoline args 
  ├─ Suspend every image-based peer thread
  │     (per-peer watchdog timer armed BEFORE each SuspendThread)
  ├─ Verify all peers are still suspended (no watchdog fired)
  ├─ QueueUserAPC(tramp, self, &amp;args)
  │
  ├─ NtWaitForSingleObject(processHandle, Alertable=TRUE, timeout=NULL)
  │     kernel dispatches trampoline on THIS thread:
  │       │
  │       ├─ VirtualProtect(image, PAGE_READWRITE)
  │       ├─ SystemFunction032 encrypt     ← RC4, key rotated per cycle
  │       ├─ NtDelayExecution(sleepMs)     ← the actual sleep
  │       ├─ SystemFunction032 decrypt     ← symmetric
  │       ├─ VirtualProtect(.text, PAGE_EXECUTE_READ)
  │       └─ ret
  │
  ├─ (kernel returns STATUS_USER_APC)
  ├─ Resume every peer, close handles
  └─ Return to caller
</code></pre>

<p>Three key differences from Ekko:</p>

<ol>
  <li><strong>The encrypt/sleep/decrypt runs on the beacon’s own thread</strong>, not on a thread-pool worker.</li>
  <li><strong>No context capture, no <code class="language-plaintext highlighter-rouge">NtContinue</code>, no ROP.</strong> The trampoline is dispatched by the kernel as an APC when the beacon thread enters an alertable wait - a documented, blessed Windows mechanism.</li>
  <li><strong>The trampoline is hand-written amd64 machine code</strong> in a separate <code class="language-plaintext highlighter-rouge">VirtualAlloc</code>‘d region outside the process image. It only calls into system dlls (kernel32, ntdll,..) addresses baked into its <code class="language-plaintext highlighter-rouge">apcArgs</code> struct — it never re-enters Go’s runtime.</li>
</ol>

<p>The P-handoff race can’t reach the trampoline: while the trampoline runs, no Go code is executing on this thread. The kernel is doing the dispatch; the trampoline is doing straight-line machine code with no scheduler awareness.</p>

<h2 id="the-watchdog-safety-net-for-both-p-handoff-races">The watchdog: safety net for both P-handoff races</h2>

<p>The APC design still needs peer suspension: with <code class="language-plaintext highlighter-rouge">.text</code> marked <code class="language-plaintext highlighter-rouge">PAGE_READWRITE</code> during encryption, any other Go thread that fetches an instruction from <code class="language-plaintext highlighter-rouge">.text</code> will cause an Access Violation (AV) and crash the process. But the peer-suspend loop AND the peer-resume loop are both Go code making <code class="language-plaintext highlighter-rouge">LazyProc.Call</code>s — meaning both can hit the P-handoff race, in <a href="#the-same-race-second-flavor-the-resume-phase">their respective flavors</a> described above.</p>

<p>We can’t eliminate the race. What we can do is convert it from a permanent hang into a “skip one cycle and self-recover” event. Same mechanism protects both phases: a per-peer Windows thread-pool timer whose callback forcibly <code class="language-plaintext highlighter-rouge">ResumeThread</code>s that peer if the beacon is still stuck when the deadline expires.</p>

<h3 id="the-callback-must-not-be-a-go-callback">The callback must not be a Go callback</h3>

<p>During a deadlock - if every Go M is suspended - no Ps are available. <strong>A Go callback would deadlock in the exact same way as the caller.</strong></p>

<p>As the watchdogs are our last line of defence against a deadlock, they are dispatched thread-pool worker (not one of our image-based Ms, so not suspended) and calls <code class="language-plaintext highlighter-rouge">ResumeThread(hThread)</code> directly, with zero Go runtime involvement. No P needed: this ironically uses the same primitive as Ekko, of using <code class="language-plaintext highlighter-rouge">CreateTimerQueueTimer</code>.</p>

<p>The same “no Go code allowed” invariant applies to the trampoline itself, for the same reason. Every function pointer it uses (<code class="language-plaintext highlighter-rouge">VirtualProtect</code>, <code class="language-plaintext highlighter-rouge">SystemFunction032</code>, <code class="language-plaintext highlighter-rouge">NtDelayExecution</code>) is baked into <code class="language-plaintext highlighter-rouge">apcArgs</code> at Sleep-call time and dispatched via <code class="language-plaintext highlighter-rouge">call [rbx+offset]</code> in machine code — no Go runtime involvement in the encrypted window, ever.</p>

<h3 id="suspend-phase-watchdog">Suspend-phase watchdog</h3>

<p>Per peer, in order:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">OpenThread</code> → handle to the peer.</li>
  <li><code class="language-plaintext highlighter-rouge">CreateTimerQueueTimer</code> with <code class="language-plaintext highlighter-rouge">procResumeThread.Addr()</code> as the callback and the handle as <code class="language-plaintext highlighter-rouge">lpParameter</code>. Deadline = <code class="language-plaintext highlighter-rouge">sleepMs + margin</code> (5–15s, proportional to sleep interval).</li>
  <li><code class="language-plaintext highlighter-rouge">SuspendThread(peer)</code>.</li>
</ol>

<p><strong>Order matters.</strong> The timer must be armed <em>before</em> the SuspendThread that could deadlock us — the P-handoff race manifests inside <code class="language-plaintext highlighter-rouge">exitsyscall</code> after the Suspend syscall, so if we tried to arm all watchdogs at the end of the loop, we’d never get there. The very first Suspend that could hang already has its rescue timer ticking.</p>

<p>Once the loop completes (or the watchdog rescues us out of a deadlock), we do two things before starting encryption:</p>

<ul>
  <li><strong>Cancel every timer.</strong> Using <code class="language-plaintext highlighter-rouge">INVALID_HANDLE_VALUE</code> as the CompletionEvent tells Windows to <em>block until any in-flight callback finishes</em>, so after this returns, each peer’s suspend count is stable — nothing else can race with our check.</li>
  <li><strong>Verify every peer is actually still suspended.</strong> Query each peer’s <code class="language-plaintext highlighter-rouge">ThreadSuspendCount</code> via <code class="language-plaintext highlighter-rouge">NtQueryInformationThread</code>. If any is 0, its watchdog fired during the suspend loop and the peer is now running.</li>
</ul>

<p>If any peer isn’t suspended, we abort the cycle: drain-resume everyone, skip this cycle’s obfuscation, return normally. Starting encryption with a peer running would cause an Avcess Violation on that peer’s next instruction fetch and take the whole process down. Losing one cycle of obfuscation beats a crash.</p>

<h3 id="resume-phase-watchdog">Resume-phase watchdog</h3>

<p>After the trampoline returns, <code class="language-plaintext highlighter-rouge">.text</code> is decrypted and executable again, but every peer is still frozen. The resume loop is exactly as vulnerable to the P-handoff race as the suspend loop was, just triggered by a different mechanism (freshly-resumed peer grabbing our P instead of frozen peer holding it).</p>

<p>So we do the same thing, symmetrically:</p>

<ol>
  <li>For each peer, <code class="language-plaintext highlighter-rouge">CreateTimerQueueTimer</code> targeting <code class="language-plaintext highlighter-rouge">procResumeThread.Addr()</code> with a fixed deadline of ~8 seconds. Shorter than the suspend-phase timeout because a well-behaved resume completes in milliseconds — any real deadlock here is caught quickly.</li>
  <li>Iterate the peer list calling <code class="language-plaintext highlighter-rouge">ResumeThread</code> + drain, then <code class="language-plaintext highlighter-rouge">CloseHandle</code>.</li>
</ol>

<p>Timers are cancelled inline as part of each peer’s cleanup.</p>

<p>If the resume loop deadlocks in <code class="language-plaintext highlighter-rouge">exitsyscall</code>, the watchdogs on the <em>still-suspended</em> peers fire and forcibly resume them. Whichever peer’s scheduler unwedges first (often <code class="language-plaintext highlighter-rouge">sysmon</code> waking up and immediately doing P-handoff to our stuck M) frees a P for our stuck resume call, and it unblocks.</p>

<h3 id="cost-in-the-healthy-path">Cost in the healthy path</h3>

<p>When nothing races — which is the vast majority of cycles — every timer gets created, then cancelled before its deadline, and no callback ever dispatches. The overhead is one <code class="language-plaintext highlighter-rouge">CreateTimerQueueTimer</code> + one <code class="language-plaintext highlighter-rouge">DeleteTimerQueueTimer</code> per peer per cycle.</p>

<p>The rescue path fired at roughly 0.05% of cycles in one of my larger tests (three rescues in 6694 cycles), so the overhead is negligible.</p>

<h2 id="opsec-hardening-no-rwx-anywhere-in-the-image">OPSEC hardening: no RWX anywhere in the image</h2>

<p>The trampoline lives outside the process image, so scanners might still flag it. To minimize that surface:</p>

<ol>
  <li>The trampoline region is <code class="language-plaintext highlighter-rouge">VirtualAlloc</code>‘d as <code class="language-plaintext highlighter-rouge">PAGE_READWRITE</code>, populated, then <code class="language-plaintext highlighter-rouge">VirtualProtect</code>‘d to <code class="language-plaintext highlighter-rouge">PAGE_EXECUTE_READ</code> before dispatch. At no point is it RWX.</li>
  <li>When the trampoline returns from its encryption cycle, its <em>last step</em> restores <code class="language-plaintext highlighter-rouge">.text</code> to <code class="language-plaintext highlighter-rouge">PAGE_EXECUTE_READ</code> — not to <code class="language-plaintext highlighter-rouge">PAGE_EXECUTE_READWRITE</code>. Some other sleep masks leave the whole image as RWX between cycles for simplicity; this one doesn’t.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">.rdata</code> and <code class="language-plaintext highlighter-rouge">.data</code>, as well as the starting PE/DOS header are left as <code class="language-plaintext highlighter-rouge">PAGE_READWRITE</code> between cycles rather than being restored to their loader-original protections. That’s a small OPSEC compromise (writable memory where writable memory shouldn’t be) but the primary IOC - RWX anywhere in the image - is eliminated.</p>

<p>Thanks to <a href="https://github.com/BishopFox/sliver/pull/1805/#issuecomment-2754423805">ethanlacerenza</a> for questioning this and pushing me to go the extra mile on OPSEC.</p>

<p>Unencrypted / check-in:
<img src="/assets/images/2026-07-09-sleep-obfuscation/3sections.png" alt="Awake state: three regions, no RWX visible in the image" /></p>

<p>Encrypted / sleeping:
<img src="/assets/images/2026-07-09-sleep-obfuscation/encrypted.png" alt="Sleeping state: single flat RW region containing RC4-encrypted image bytes" /></p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>This was an absolute journey. What started as (I thought) a quick win in late 2024 ended only close to two years later, after a local infosec conference talk inspired me to give this issue another shot.</p>

<p>While the solution is surely not the most elegant, I’m now confident it works.</p>

<p>Working code is in <a href="https://github.com/armysick/sliver/tree/apc-pr/implant/sliver/apc"><code class="language-plaintext highlighter-rouge">implant/sliver/apc/apc.go</code></a> heading into the <a href="https://github.com/BishopFox/sliver/pull/1805/">Sliver PR</a>.</p>

<p>If you’re building sleep obfuscation in Go and want to compare notes, or if you find a hang the watchdogs <em>don’t</em> catch, or even better a more elegant way of making Go go to sleep do reach out!</p>

<p><em>Thanks to the folks at BishopFox for maintaining Sliver, to C5pider whose Ekko implementation was the reference point even where I ultimately had to depart from its design, and to the infosec community who helped brainstorm ideas and push me to go through with this investigation.</em></p>]]></content><author><name>ArmySick</name></author><category term="sliver" /><category term="sleep-obfuscation" /><category term="ekko" /><category term="apc" /><category term="go-runtime" /><category term="windows" /><summary type="html"><![CDATA[How a naive port of the Ekko sleep-mask doesn't work — and the APC-based design that fixes it.]]></summary></entry><entry><title type="html">CVE-2021-33231 — Stored XSS in EasyVista Service Manager</title><link href="https://armysick.github.io/cve-2021-33231/" rel="alternate" type="text/html" title="CVE-2021-33231 — Stored XSS in EasyVista Service Manager" /><published>2021-05-07T00:00:00+00:00</published><updated>2021-05-07T00:00:00+00:00</updated><id>https://armysick.github.io/cve-2021-33231</id><content type="html" xml:base="https://armysick.github.io/cve-2021-33231/"><![CDATA[<h2 id="overview">Overview</h2>

<p>A stored cross-site scripting (XSS) is available when registering a new equipment on EasyVista Service Manager 2018.1.181.1, on the Note field, allowing arbitrary JavaScript code to be executed on any user that visits the details page of the registered equipment.</p>

<p>A filter is in place that attempts to prevent this, but it is incomplete, which makes it bypassable.</p>

<h2 id="details">Details</h2>

<p>After logging into the platform, a user is able to, among other functions, consult its own equipment. There is also a function available that allows registration of new equipment:</p>

<p><img src="/cve-2021-33231/images/img1.png" alt="New equipment registration form" /></p>

<p>In the “Note” field there are protections in-place that correctly disallow (through filtering) the usage of JavaScript tags such as <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code>. However, it is possible to bypass it and inject valid JavaScript code by using different syntaxes that do not use the <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag.</p>

<p>The payload tested, shown below, was <code class="language-plaintext highlighter-rouge">&lt;img src=x onerror="alert('xss')"/&gt;</code>:</p>

<p><img src="/cve-2021-33231/images/img2.png" alt="Payload injected into the Note field" /></p>

<p>After submitting this new equipment entry, we incur into a case of stored cross-site scripting (XSS) where any other user — including administrators — that would consult the user’s equipment would have arbitrary JavaScript code being executed in their own browser. Please refer to the proceeding image where the <code class="language-plaintext highlighter-rouge">alert('xss')</code> code that was input in the “Note” field popped up an alert on the victim’s browser with the <code class="language-plaintext highlighter-rouge">xss</code> text in it. Notice that this JavaScript code can be replaced by more malicious code.</p>

<p><img src="/cve-2021-33231/images/img3.png" alt="XSS alert triggered on the victim's browser" /></p>

<p>There is great variety of attacks based on XSS. These commonly include transmitting private data — such as cookies or other session information — to the attacker, redirecting the victim to web content controlled by the attacker, or performing other malicious operations on the user’s machine under the guise of the vulnerable website.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>Dec 22, 2020</strong> — Reached out to EasyVista to identify the best process to report a security vulnerability.</li>
  <li><strong>Feb 11, 2021</strong> — After a second attempt, EasyVista suggested reaching out to their security e-mail address to report the issue.</li>
  <li><strong>Feb 11, 2021</strong> — Vulnerability was reported to EasyVista’s Security team.</li>
  <li><strong>May 7, 2021</strong> — Contacted again EasyVista asking for updates.</li>
  <li><strong>May 7, 2021</strong> — EasyVista Senior executive confirmed that the issue was successfully reproduced and fixed on the latest EasyVista Service Manager version.</li>
</ul>

<h2 id="final-notes">Final notes</h2>

<p>I wish to relay all my best wishes to EasyVista’s security team for its straightforward approach to the reported security issue and to the security senior executive for its cordial relaying of the actions that were taken.</p>]]></content><author><name>ArmySick</name></author><category term="cve" /><category term="xss" /><category term="easyvista" /><summary type="html"><![CDATA[Stored cross-site scripting in the Note field when registering a new equipment on EasyVista Service Manager 2018.1.181.1.]]></summary></entry></feed>