-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathage_clock_ruling_test.go
More file actions
235 lines (201 loc) · 9.67 KB
/
Copy pathage_clock_ruling_test.go
File metadata and controls
235 lines (201 loc) · 9.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
package ja4plus
import (
"fmt"
"net"
"testing"
"time"
"github.com/gopacket/gopacket"
)
// The age clock of a bounded state table, under a packet timestamp that a sender chooses.
//
// **The maintainer ruled this question on 2026-08-14, and issue #577 holds the ruling.** The
// library accepts the property, and it documents it. No line of the age pass changes, and no
// clamp, no monotonic clock and no quorum reaches the tree. Each declined answer invents a
// rule that no FoxIO source states.
//
// The property: an age pass reads the capture timestamp of the packet that arrives. It
// compares that one timestamp against the last packet time of every key of the table. So one
// packet dated far in the future ages every key at once, and the pass removes every key it
// ages.
//
// One key survives the pass, and it is the key of the packet that carries the crafted
// timestamp. The fingerprinter records that key with the same crafted timestamp after the pass,
// so a later packet of the same key reads an age of zero. **So each test below asserts a count
// of 1 after the crafted packet, and never a count of 0.**
//
// Four sites of this repository hold it, and each test below drives one of them.
//
// - `state_bound.go` `agedKeys`, which serves JA4, JA4L and JA4S.
// - `ja4ssh.go` `evictAgedConnections`.
// - `ja4ts.go` `evictAgedConnections`.
// - `ja4h.go` `evictAgedRanges`.
//
// The port holds the same clock at `ja4plus/utils/state_table.py:414` of tag `v1.1.0`:
// `if now - entry[_LAST_SEEN] > self.max_connection_age:`. So a reader of either repository
// finds one property. `Crank-Git/ja4plus#618` carries the port half of this ruling.
//
// **Each test asserts the accepted behavior**, so a later change that hardens the clock fails
// it. That failure is the point: it sends the reader to issue #577, which is the reversal
// path.
//
// No vector of the FoxIO corpus reaches this value, so `.claude/rules/rulings.md`
// `## Where a ruling is recorded` puts the ruling in a test that builds the separating input.
// ageClockForwardJump is the forward jump that one crafted packet states.
//
// The ruling names no number. The value sits above every age bound of the tree, which is 600
// seconds at most, so one jump of this size ages every key of every table.
const ageClockForwardJump = 100 * 365 * 24 * time.Hour
// ageClockSeedPorts names the three connections that each test opens before the crafted
// packet arrives. Three keys prove that the pass reads every other key, and two do not
// separate "every other key" from "the one other key".
var ageClockSeedPorts = []uint16{1, 2, 3}
// ageClockHTTPRequest is the whole HTTP request that each segment of the JA4H test carries.
const ageClockHTTPRequest = "GET / HTTP/1.1\r\n" +
"Host: 192.168.235.136:8089\r\n" +
"Connection: keep-alive\r\n" +
"\r\n"
// TestOnePacketDatedFarInTheFutureEmptiesTheJA4FragmentTable drives `state_bound.go`
// `agedKeys` through the fragment table of JA4.
//
// JA4 runs the age pass on each datagram, so the crafted datagram reaches the pass at once.
func TestOnePacketDatedFarInTheFutureEmptiesTheJA4FragmentTable(t *testing.T) {
fingerprinter := NewJA4()
handshake := buildClientHelloPayload()[5:45]
for index := range ageClockSeedPorts {
packet := stateBoundQUICPacket(t, uint16(50000+index), stateBoundDCID(index), handshake,
stateBoundStart())
if _, err := fingerprinter.ProcessPacket(packet); err != nil {
t.Fatalf("the fingerprinter returns the error %v for seed connection %d", err, index)
}
}
if got := len(fingerprinter.quicFragments); got != len(ageClockSeedPorts) {
t.Fatalf("the fragment table holds %d connections before the crafted datagram, want %d",
got, len(ageClockSeedPorts))
}
crafted := stateBoundQUICPacket(t, 50099, stateBoundDCID(99), handshake,
stateBoundStart().Add(ageClockForwardJump))
if _, err := fingerprinter.ProcessPacket(crafted); err != nil {
t.Fatalf("the fingerprinter returns the error %v for the crafted datagram", err)
}
if got := len(fingerprinter.quicFragments); got != 1 {
t.Errorf("the fragment table holds %d connections after the crafted datagram, and the ruling of #577 states 1",
got)
}
}
// TestOnePacketDatedFarInTheFutureEmptiesTheJA4SSHConnectionTable drives `ja4ssh.go`
// `evictAgedConnections`.
//
// JA4SSH runs the age pass every `sshEvictionInterval` packets, so the crafted connection
// sends the packets that reach the interval. Every packet of that connection carries the same
// crafted timestamp, and one such packet is what the pass reads.
func TestOnePacketDatedFarInTheFutureEmptiesTheJA4SSHConnectionTable(t *testing.T) {
fingerprinter := NewJA4SSH(2)
crafted := time.Unix(1700000000, 0).Add(ageClockForwardJump)
for _, port := range ageClockSeedPorts {
if _, err := fingerprinter.ProcessPacket(sshBoundPacketAt(1024+port, time.Unix(1700000000, 0))); err != nil {
t.Fatalf("the fingerprinter returns the error %v for seed connection %d", err, port)
}
}
if got := len(fingerprinter.connections); got != len(ageClockSeedPorts) {
t.Fatalf("the state table holds %d connections before the crafted packet, want %d",
got, len(ageClockSeedPorts))
}
for index := len(ageClockSeedPorts); index < sshEvictionInterval; index++ {
if _, err := fingerprinter.ProcessPacket(sshBoundPacketAt(2000, crafted)); err != nil {
t.Fatalf("the fingerprinter returns the error %v for packet %d of the crafted connection", err, index)
}
}
for _, port := range ageClockSeedPorts {
if _, held := fingerprinter.connections[sshBoundConnKey(1024+port)]; held {
t.Errorf("the state table holds seed connection %d, and the ruling of #577 removes every key the crafted timestamp ages",
port)
}
}
if got := len(fingerprinter.connections); got != 1 {
t.Errorf("the state table holds %d connections after the crafted packet, and the ruling of #577 states 1",
got)
}
}
// TestOnePacketDatedFarInTheFutureEmptiesTheJA4TSConnectionTable drives `ja4ts.go`
// `evictAgedConnections`.
//
// JA4TS runs the age pass on each SYN-ACK, so the crafted SYN-ACK reaches the pass at once.
func TestOnePacketDatedFarInTheFutureEmptiesTheJA4TSConnectionTable(t *testing.T) {
fingerprinter := NewJA4TS()
for _, port := range ageClockSeedPorts {
packet := buildTCPPacketWithFlagsAtTime(t, ja4tsServerIP, ja4tsClientIP,
ja4tsServerPort, 20000+port, true, true, false, 65535, ja4tsOptions, ja4tsAt(0))
_ = ja4tsValue(t, fingerprinter, packet)
}
if got := len(fingerprinter.connections); got != len(ageClockSeedPorts) {
t.Fatalf("the table holds %d connections before the crafted SYN-ACK, want %d",
got, len(ageClockSeedPorts))
}
crafted := buildTCPPacketWithFlagsAtTime(t, ja4tsServerIP, ja4tsClientIP,
ja4tsServerPort, 20099, true, true, false, 65535, ja4tsOptions,
ja4tsAt(0).Add(ageClockForwardJump))
_ = ja4tsValue(t, fingerprinter, crafted)
if got := len(fingerprinter.connections); got != 1 {
t.Errorf("the table holds %d connections after the crafted SYN-ACK, and the ruling of #577 states 1",
got)
}
}
// TestOnePacketDatedFarInTheFutureEmptiesTheJA4HRangeTable drives `ja4h.go`
// `evictAgedRanges`.
//
// JA4H runs the age pass on each segment, so the crafted segment reaches the pass at once.
func TestOnePacketDatedFarInTheFutureEmptiesTheJA4HRangeTable(t *testing.T) {
fingerprinter := NewJA4H()
start := time.Unix(1500000000, 0)
for _, port := range ageClockSeedPorts {
if _, err := fingerprinter.ProcessPacket(ageClockHTTPSegment(t, 54000+port, start)); err != nil {
t.Fatalf("the fingerprinter returns the error %v for seed stream %d", err, port)
}
}
if got := len(fingerprinter.ranges); got != len(ageClockSeedPorts) {
t.Fatalf("the range table holds %d streams before the crafted segment, want %d",
got, len(ageClockSeedPorts))
}
crafted := ageClockHTTPSegment(t, 54099, start.Add(ageClockForwardJump))
if _, err := fingerprinter.ProcessPacket(crafted); err != nil {
t.Fatalf("the fingerprinter returns the error %v for the crafted segment", err)
}
if got := len(fingerprinter.ranges); got != 1 {
t.Errorf("the range table holds %d streams after the crafted segment, and the ruling of #577 states 1",
got)
}
}
// ageClockHTTPSegment returns one TCP segment that carries a whole HTTP request, at the client
// port and the capture timestamp the caller names.
//
// The client port names the stream, so a caller reaches a new range entry with a new port.
func ageClockHTTPSegment(t *testing.T, clientPort uint16, at time.Time) gopacket.Packet {
t.Helper()
packet := buildTCPPacketWithSeq(t, net.IP{192, 168, 235, 137}, net.IP{192, 168, 235, 136},
clientPort, 8089, 1000, []byte(ageClockHTTPRequest))
packet.Metadata().Timestamp = at
return packet
}
// TestTheEntryBoundHoldsTheMemoryWhateverTheTimestampStates holds the mitigation that the
// ruling of #577 records beside the property.
//
// The entry bound reads the recency order and no timestamp, so a crafted timestamp moves no
// entry count above the bound. The loss of the age property is the tracked state, and it is
// never the memory.
//
// The call states an eviction interval of 1, so the age pass runs on every key. Each key
// carries a timestamp further ahead than the key before it, so the pass ages the whole table
// on every call. The count stays at the bound whatever the pass removes.
func TestTheEntryBoundHoldsTheMemoryWhateverTheTimestampStates(t *testing.T) {
var table boundedKeys
drop := func(string) {}
for index := 0; index < 100; index++ {
key := fmt.Sprintf("connection-%d", index)
table.admit(key, time.Unix(1700000000, 0).Add(ageClockForwardJump*time.Duration(index)),
4, 600*time.Second, 1, drop)
if got := table.count(); got > 4 {
t.Fatalf("the table holds %d keys after key %d, and the entry bound of this call states 4 at most",
got, index)
}
}
}