Chameleon. Watch my video on YouTube.

I can get a Linux box to answer a ping and an OS check to recognise the disguise I'm aiming for. Then I try loading a page over HTTP, it works, and another attempt doesn't. Why? Good question. It was working, well, it worked a minute ago. A promising scan is a start. Can I leave it running?

This is Chameleon, a network decoy I'm working on for a client. The idea is to make a machine look like an interesting target, then give defenders something to investigate when somebody contacts it unexpectedly. The box is a Raspberry Pi running Linux, and its network behaviour has to suggest a Windows server.

I can change the page and the HTTP Server header easily enough. The application can introduce itself however I like, but Linux is already answering packets before it gets involved. Those answers have a personality too.

An OS scanner looks at those answers too. TCP is the protocol keeping an ordinary HTTP connection ordered and reliable. Nmap's fingerprinting examines combinations of details, including TCP options, window sizes and the way sequence numbers change.

So Linux is doing the talking. I want to change what that conversation reveals, and swapping a name in a header leaves me with plenty of other details to work on.

I'm using eBPF to run small programs in Linux's network path. One handles outgoing packets before they leave through the network interface controller, or NIC. Another handles incoming packets. This lets me adjust the behaviour and keep the ordinary application behind it without replacing the whole networking stack, provided I don't break its connections in the process.

I have to lie both ways

TCP uses sequence numbers to keep track of the bytes in each direction. The other machine sends acknowledgements saying which position it expects next. Changing those numbers on the way out affects what comes back.

Say Linux sends its connection-opening reply with sequence number 1000. I rewrite that number to 5000 for the remote machine. It sees 5000, so it acknowledges 5001. The opening SYN flag consumes one sequence position even without application data, which accounts for the extra one.

Linux expects 1001 because it never sent the 5000 the other machine is acknowledging.

Now I have to rewrite the reply as well, turning that 5001 back into 1001 before Linux processes it. The remote machine gets the numbers I showed it, while Linux gets the numbers belonging to its connection. These sequence and acknowledgement rules still need to hold on both sides.

Linux sends sequence 1000, Chameleon shows the remote machine 5000, then translates its acknowledgement of 5001 back to 1001 for Linux
Figure 1 - Two views of one connection. Follow the server's sequence numbers through part of a TCP handshake. The numbers are invented.

This is the double lying, fooling the remote machine is only half the job because its perfectly reasonable reply can now make no sense to Linux.

The handshake isn't the whole conversation. The application still has data to send. I have to carry the difference between the two sets of numbers with that connection and recognise which connection each reply belongs to. The translation lives as long as the connection does.

I save that difference as seq_offset. For the numbers above it's -4000, so the two directions use it like this:

/* Outgoing: show the remote machine the fake numbers */
fake_seq = real_seq - state->seq_offset;

/* Incoming: hand Linux back its own numbers */
real_ack = fake_ack + state->seq_offset;

Each edit also needs a matching bpf_l4_csum_replace call so the TCP checksum still adds up.

I'd love to make a handful of edits to the outgoing packets and send them off. But every time I change something the other machine will answer, I've given myself another thing to translate on its way back.

Sometimes I modify a response Linux has produced. For particular probes I construct the response myself. Both paths meet in the same machine and keeping track of which one is handling a packet has been difficult.

An answer I've constructed mustn't get treated as an untouched Linux response and changed again. A packet I leave for Linux needs to reach the networking code that handles it. I have guards against processing certain responses twice, but even understanding where to put the rules takes work.

I keep asking who answered. Was it Linux's response? Did I rewrite it, or construct one myself? I can't follow one neat path through my own code because Linux is doing some of the answering too.

Meanwhile, the visible problem can be a page that no longer loads. Did the HTTP handler even see the request? I can't assume so. Working backwards means following a conversation I've been rewriting as it happens.

Stable for how long?

Timing is particularly temperamental.

TCP timestamps are counters carried in packets, and a scanner can examine how quickly they advance across several responses, so the disguise has to hold as those counters advance too.

TCP timestamps aren't always uptime. Where they're tied to it, time keeps moving underneath the disguise.

If the Windows clock I'm imitating ticks at a different rate to Linux's, a constant offset won't be enough. The remote machine echoes my timestamps back, so I'd have to scale the counter and keep enough information to translate those echoes too. Rounding can lose information from the original value.

Some evenings I go to sleep thinking I've cracked it, then come back the next day and things don't behave as I expect. It is quite defeating. All that effort, and the feeling of having finished is gone by the next morning.

I find it harder once I've mentally finished something. I've already spent the relief and started thinking about something else, and now I have to persuade myself to pick this up again.

I'd quite like a little longer to enjoy it.

I've been reading more about fingerprinting after wrestling with this. Getting it stable in the now is already taking effort, and I'm starting to wonder how much of an operating system I'm signing up to imitate.

Linux still decides when to resend a lost packet. Its congestion control decides how quickly to send when the network gets busy, and its TCP code handles unexpected packets mid-connection. My rewrites ride on top of them.

p0f takes another route, watching ordinary TCP traffic without sending probes of its own, so now I'm thinking about what the decoy gives away during a normal connection. What does it reveal beyond the answers I've been concentrating on for Nmap?

Niels Provos's 2004 Honeyd paper describes a tool that handles TCP connections itself, then changes outgoing packets to match a chosen system. Its TCP handling is deliberately limited, and the paper discusses how behaviour during a connection could expose the simulation.

Reading that makes my handful of rewrite rules feel rather optimistic.

And the software keeps changing. A Linux kernel update means checking the host's behaviour again and a change to the system I'm imitating can mean checking the disguise too. I don't control either side. Keeping them in agreement becomes my job.

Would a real Windows VM behind the decoy save me from reproducing all that behaviour? Let Windows answer the connections, leave the TCP decisions to Windows.... Then I'd have another operating system to look after. I'm still working out whether that's a better trade than taking responsibility for more of Linux's side of the conversation.

I still need to be able to open the page tomorrow. Preferably without spending the morning finding out who answered it.

This is how I imagine our NIC trying to talk to the Linux kernel. Watch on YouTube.

This entry came over from blog.haddon.me. I’ve tidied up the grammar and flow, bringing it closer to how I write now. I used AI to help with the grammar, structure and flow. I have dyslexia, and my writing often comes out as a rambling scrawl, so it can be hard to see what I was actually working on at the time. I think the tidied versions are easier to digest and more interesting to read. The artefacts got the same tidy-up while I was at it.

  • Read next

    Some assembly still required

    I'm building asmgen to keep amd64 and arm64 implementations together, with fewer copied instructions and stale numbers to chase after a Go refactor.