A TCP-terminating proxy answers the origin's handshake using its own TCP connection. The browser behind it can be waiting on a different connection, a different path and a different clock.
That matters when a detector compares a browser's claimed location or operating system with transport measurements. A browser profile can present a different timezone. The SYN arriving at the detector still comes from a particular TCP endpoint. Before interpreting either value, we need to identify that endpoint.
We built a small experiment to make the distinction visible. A local relay waited 150 milliseconds before forwarding a challenge and another 150 milliseconds before forwarding its response. The origin's application exchange took a median 306.497 ms. Its SYN-to-ACK interval was 0.009875 ms.
These are results from a controlled Linux loopback fixture. They establish which connection completed the handshake. They are not measurements of Internet distance or a comparison of anti-detect browsers.
Two connections, one application exchange
The relay accepts a TCP connection from the client and opens a second connection to the origin:
client TCP endpoint terminating relay origin
| | |
|------ connection A ---->| |
| |------ connection B ---->|
| |<-------- SYN-ACK --------|
| |----------- ACK -------->|
| | |
| |<---- application data ---|
|<---- forwarded data ----| |
|------ application echo ->| |
| |------ forwarded echo --->|The origin's TCP peer is the relay. Connection A has its own handshake and sequence numbers. Forwarding application bytes between the connections does not combine them into one TCP session. The basic handshake exchanges SYN, SYN-ACK and ACK between the peers of each connection. RFC 9293, connection establishment.
Our relay's relevant operation is short. read_exact waits for the complete 32-byte fixture payload; upstream is the relay's connection to the origin and downstream is its accepted client connection.
request = read_exact(upstream, 32)
time.sleep(0.15)
downstream.sendall(request)
response = read_exact(downstream, 32)
time.sleep(0.15)
upstream.sendall(response)Both sockets already exist before these sleeps. The kernel can acknowledge received TCP segments while the relay process is waiting to forward their contents.
Follow the packets
In the first delayed-relay run, the client connected from port 49860 to the relay's listener at 52945. The relay opened its origin connection from 59306 to 46015. All addresses were loopback addresses inside a container with external networking disabled.
The origin saw this handshake in the captured packet stream:
59306 -> 46015 SYN seq 1008077717
46015 -> 59306 SYN-ACK seq 1229990398 ack 1008077718
59306 -> 46015 ACK seq 1008077718 ack 1229990399The last ACK came from the relay's outgoing socket. The original client's port, 49860, does not appear in that exchange. The relay completed it before forwarding the application challenge to the client. Its later echo on the same origin connection arrived after the two forwarding pauses.
We captured packets using Linux kernel receive timestamps. The experiment checks that the selected empty ACK acknowledges the captured SYN-ACK, retains the complete packet captures, and fails if the packet socket reports drops. All 20 origin-facing connections completed those checks. The ten relay trials also used a separate client-to-relay connection, for 30 TCP connections in total. Packet timestamps came from SO_TIMESTAMPNS; application durations came from a separate monotonic timer. We compare durations within each clock, not absolute readings between them. Linux timestamping documentation.
A slow client gives the same timing shape
There were five repetitions of four cases. The delayed-client control connects directly and waits 300 ms before returning the challenge. It has no relay.
| Case | Median SYN-to-empty-ACK interval | Median application echo |
|---|---|---|
| Direct client | 0.019500 ms | 0.313 ms |
| Relay with no imposed delay | 0.013125 ms | 6.961 ms |
| Relay waits 150 ms each way | 0.009875 ms | 306.497 ms |
| Direct client waits 300 ms | 0.020625 ms | 304.573 ms |
The first measurement stays below 0.03 ms in every case. The application response takes more than 300 ms in both delayed cases. Subtracting the handshake interval from the application duration would therefore produce a large difference for the direct client too.
We know where this delay came from because we inserted it. In an unknown session, application scheduling, processing and forwarding can all contribute to the interval. A large difference alone cannot identify which contribution caused it.
The no-delay relay also takes longer than the direct case in this fixture. Python threads, socket forwarding and the container's one-CPU limit contribute scheduling overhead. Those values do not estimate the cost of a production proxy. Full ranges and all individual observations are in the experiment and captures.
The interval Heretic records
Heretic records the arrival of a client SYN and the first later empty ACK for that flow. Its handshake field is the difference between those receive timestamps. It does not start the interval from a recorded outgoing SYN-ACK timestamp.
For the simple handshake above, the interval can be written as:
t_empty_ACK_received - t_SYN_received
= time from receiving SYN to sending SYN-ACK
+ time from sending SYN-ACK to receiving the peer's ACKThe first term includes work at the observing endpoint. The second includes the return path to the TCP peer and the peer's response. If a proxy terminates the connection, that peer is the proxy. Browser-side processing behind it is not required to complete this exchange.
This is also why the observation point matters to a physical lower bound. A form such as RTT >= 2 * d_min / c needs a justified minimum distance between the participating endpoints. Heretic compares the received connection's timing with a distance derived from a claimed timezone region. The claim and the received connection are the two things being compared. The measurement does not authenticate the hidden browser's geographic position.
A short observed interval can contradict the distance premise. A longer interval does not prove that distance, because processing and delay can make an exchange slower. The direct delayed-client control shows the analogous problem at the application layer.
A forwarding VPN changes the question
This experiment uses TCP termination. A VPN that forwards IP packets without terminating the TCP flow has a different topology. In that case the client host remains the TCP endpoint, even if a gateway translates its source address or changes its path. The label "VPN" alone does not tell us whether termination occurs.
The same distinction applies to TCP fingerprints. On our terminating relay, the SYN at the origin came from the relay's outgoing socket. Browser property overrides in the client would not alter that already-captured SYN. Changing the actual outgoing stack or packets could change it. Our fixture uses one Linux kernel throughout, so it does not establish an OS-classification difference between its endpoints.
A useful comparison of Apostate or CloakBrowser therefore has to record the browser configuration and the actual connection topology. A captured signature needs the endpoint that generated it; a latency contradiction needs the endpoints that participated in the timed exchange. Such a comparison needs pinned browser builds and controlled direct, terminating-proxy and forwarding paths.
The reproduction package contains the complete Python program, pinned container command, all 20 packet captures, individual observations and capture checks. It uses only loopback traffic and known fixture payloads. No browser, physical network path, VPN implementation or production Heretic verdict was tested in this experiment.