~/tutorials/041-the-callback.md
041: The callback
Command and control is one idea. Your code on their box checks in, takes instructions, reports back. Everything else is implementation, and the implementations are where defenders catch you.
The shape
A loop with three stations. Ask for work, run it, answer. In Python, honestly ten lines:
import urllib.request, time
while True:
task = urllib.request.urlopen("http://127.0.0.1:8091/task").read()
out = run_it(task) # the only line that is yours
urllib.request.urlopen("http://127.0.0.1:8091/answer", out)
time.sleep(5)
The listener on the other end is smaller than the client, one route that hands out work and one that collects answers. Tooling that looks complicated in a demo is mostly packaging around this loop: encryption, staging, resumes, icons.
Five seconds is the interval everybody picks, which is the first reason it gets caught. Regular as a metronome, across every host you run it on. Aggregate the logs of a thousand machines and your callback is the only line on the page with perfect rhythm.
The receipt
This is not a claim about theory. The lab for this lesson built exactly that loop and a listener that logs arrival times, ran the metronome version, then a jittered one, and read the intervals off the listener's own log, which is the defender's seat. Metronome first, two second interval:
intervals: 2.001 2.001 2.001 2.0 2.001 2.001 2.0 2.001
mean 2.00s, stdev 0.00s, one distinct value in eight
Eight checks, and every interval within one millisecond of every other. A detector does not need to see your traffic, only its rhythm, and rhythm like that survives any log pipeline. The jittered client, same base interval plus a uniform random up to two seconds:
intervals: 2.57 2.21 3.97 3.34 3.94
mean 3.21s, stdev 0.71s, five distinct values in five
Same loop, same listener, same log. One line of random.uniform moved the traffic from the only regular thing on the page to just another entry in it. The full code for both clients and the listener is behind the reveal at the bottom, and running them yourself is the exercise.
Jitter
Real traffic is uneven. A printer polls, a laptop sleeps, a human clicks. So the loop sleeps a base interval plus a random amount, retries on failure with backoff, and stays quiet during the hours the compromised user would not be at the machine.
None of that is hidden by magic. It is hidden by statistics. The defender's tooling flags what does not look like noise, and your job, in the attacker seat, is to look like noise. A large part of the modern detection industry is that one sentence at scale.
The honest corollary, and the reason this lesson lives where it does: jitter is a tax the defender collects either way. You will run the experiment, see the metronome for the beacon it is, add jitter, and watch it dissolve into the page. Then you will sit in the defender's seat for the next lesson and realize the dissolution only works against interval statistics. Volume, destination rarity, and first-seen-time still tell on you, because noise has a shape too, and matching its shape is a modeling problem, not a sleep call.
What the defender sees
The first hop matters most. Whatever the callback touches first, dns, an https host, a content delivery network, is where the aggregate picture lives, and the picture is built from the same data your loop generates. Beacon interval. User agent. Jitter profile. Volume. A defender with your traffic for one day can describe your implant better than its documentation does.
The receipt above is one column of that picture, and the cheapest one: arrival times. Two more come free with the same log. The user agent, which the toy client never chose and real tooling forgets to vary, one constant string across every host it touches. And volume, which jitter does not touch at all, because sleeping randomly around two seconds still averages three checks a minute, and almost nothing honest polls a fresh domain three times a minute for a day.
Try it
- Build the lab from the reveal: listener, metronome client, jitter client. Twenty seconds of each, and read the interval column off the listener's log yourself.
- Compute the two summaries by hand or with a three line script: mean, and how many distinct values in how many samples. The metronome's one-of-eight and the jitter's five-of-five are the whole lesson in two fractions.
- Break the jitter the honest way. Add volume detection to the listener, a simple count of arrivals per minute, and watch both clients read identically. You have just found the limit of the trick, from the seat that finds it in real life.
Then flip seats, because that is the next lesson.
Reveal the answer
The listener, in full, the defender's log and the work handout in twenty lines:
import http.server, time
LOG = open("arrivals.log", "a", buffering=1)
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/task":
LOG.write(f"{time.time():.3f} GET {self.headers.get('User-Agent')}\n")
body = b'{"task": "id"}' # the work handed out
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass # quiet the default noise
http.server.HTTPServer(("127.0.0.1", 8091), H).serve_forever()
The metronome client:
import urllib.request, time
while True:
try: urllib.request.urlopen("http://127.0.0.1:8091/task", timeout=3).read()
except Exception: pass
time.sleep(2.0)
The jittered client, one line different:
import urllib.request, time, random
while True:
try: urllib.request.urlopen("http://127.0.0.1:8091/task", timeout=3).read()
except Exception: pass
time.sleep(2.0 + random.uniform(0, 2.0))
The swallow on except is deliberate and worth noticing twice: a callback that crashes on a failed check is a callback that dies on the first network blip, and its dying is the loudest event it can produce. Real implants fail quietly and try again.
The measurement, three lines, the same numbers the receipt section printed:
import statistics
ts = [float(l.split()[0]) for l in open("arrivals.log")]
iv = [b - a for a, b in zip(ts, ts[1:])]
print(statistics.mean(iv), statistics.pstdev(iv), len(set(round(x, 1) for x in iv)))
Everything here is loopback and both sides are yours. The same discipline at scale, against machines that are not yours, is the line this tier opened with, and this lesson does not move it.