Skip to content

Commit 7b2d572

Browse files
committed
Added 'JA4Server Fingerprint' operation
1 parent d13218c commit 7b2d572

File tree

6 files changed

+348
-16
lines changed

6 files changed

+348
-16
lines changed

src/core/config/Categories.json

+1
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@
238238
"JA3 Fingerprint",
239239
"JA3S Fingerprint",
240240
"JA4 Fingerprint",
241+
"JA4Server Fingerprint",
241242
"HASSH Client Fingerprint",
242243
"HASSH Server Fingerprint",
243244
"Format MAC addresses",

src/core/lib/JA4.mjs

+108-10
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export function toJA4(bytes) {
2525
let tlsr = {};
2626
try {
2727
tlsr = parseTLSRecord(bytes);
28+
if (tlsr.handshake.value.handshakeType.value !== 0x01) {
29+
throw new Error();
30+
}
2831
} catch (err) {
2932
throw new OperationError("Data is not a valid TLS Client Hello. QUIC is not yet supported.\n" + err);
3033
}
@@ -48,16 +51,7 @@ export function toJA4(bytes) {
4851
break;
4952
}
5053
}
51-
switch (version) {
52-
case 0x0304: version = "13"; break; // TLS 1.3
53-
case 0x0303: version = "12"; break; // TLS 1.2
54-
case 0x0302: version = "11"; break; // TLS 1.1
55-
case 0x0301: version = "10"; break; // TLS 1.0
56-
case 0x0300: version = "s3"; break; // SSL 3.0
57-
case 0x0200: version = "s2"; break; // SSL 2.0
58-
case 0x0100: version = "s1"; break; // SSL 1.0
59-
default: version = "00"; // Unknown
60-
}
54+
version = tlsVersionMapper(version);
6155

6256
/* SNI
6357
If the SNI extension (0x0000) exists, then the destination of the connection is a domain, or “d” in the fingerprint.
@@ -99,6 +93,7 @@ export function toJA4(bytes) {
9993
if (ext.type.value === "application_layer_protocol_negotiation") {
10094
alpn = parseFirstALPNValue(ext.value.data);
10195
alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1);
96+
if (alpn.charCodeAt(0) > 127) alpn = "99";
10297
break;
10398
}
10499
}
@@ -164,3 +159,106 @@ export function toJA4(bytes) {
164159
"JA4_ro": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${originalCiphersRaw}_${originalExtensionsRaw}`,
165160
};
166161
}
162+
163+
164+
/**
165+
* Calculate the JA4Server from a given TLS Server Hello Stream
166+
* @param {Uint8Array} bytes
167+
* @returns {string}
168+
*/
169+
export function toJA4S(bytes) {
170+
let tlsr = {};
171+
try {
172+
tlsr = parseTLSRecord(bytes);
173+
if (tlsr.handshake.value.handshakeType.value !== 0x02) {
174+
throw new Error();
175+
}
176+
} catch (err) {
177+
throw new OperationError("Data is not a valid TLS Server Hello. QUIC is not yet supported.\n" + err);
178+
}
179+
180+
/* QUIC
181+
“q” or “t”, which denotes whether the hello packet is for QUIC or TCP.
182+
TODO: Implement QUIC
183+
*/
184+
const ptype = "t";
185+
186+
/* TLS Version
187+
TLS version is shown in 3 different places. If extension 0x002b exists (supported_versions), then the version
188+
is the highest value in the extension. Remember to ignore GREASE values. If the extension doesn’t exist, then
189+
the TLS version is the value of the Protocol Version. Handshake version (located at the top of the packet)
190+
should be ignored.
191+
*/
192+
let version = tlsr.version.value;
193+
for (const ext of tlsr.handshake.value.extensions.value) {
194+
if (ext.type.value === "supported_versions") {
195+
version = parseHighestSupportedVersion(ext.value.data);
196+
break;
197+
}
198+
}
199+
version = tlsVersionMapper(version);
200+
201+
/* Number of Extensions
202+
2 character number of cipher suites, so if there’s 6 cipher suites in the hello packet, then the value should be “06”.
203+
If there’s > 99, which there should never be, then output “99”.
204+
*/
205+
let extLen = tlsr.handshake.value.extensions.value.length;
206+
extLen = extLen > 99 ? "99" : extLen.toString().padStart(2, "0");
207+
208+
/* ALPN Extension Chosen Value
209+
The first and last characters of the ALPN (Application-Layer Protocol Negotiation) first value.
210+
If there are no ALPN values or no ALPN extension then we print “00” as the value in the fingerprint.
211+
*/
212+
let alpn = "00";
213+
for (const ext of tlsr.handshake.value.extensions.value) {
214+
if (ext.type.value === "application_layer_protocol_negotiation") {
215+
alpn = parseFirstALPNValue(ext.value.data);
216+
alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1);
217+
if (alpn.charCodeAt(0) > 127) alpn = "99";
218+
break;
219+
}
220+
}
221+
222+
/* Chosen Cipher
223+
The hex value of the chosen cipher suite
224+
*/
225+
const cipher = toHexFast(tlsr.handshake.value.cipherSuite.data);
226+
227+
/* Extension hash
228+
A 12 character truncated sha256 hash of the list of extensions.
229+
The extension list is created using the 4 character hex values of the extensions, lower case, comma delimited.
230+
*/
231+
const extensionsList = [];
232+
for (const ext of tlsr.handshake.value.extensions.value) {
233+
extensionsList.push(toHexFast(ext.type.data));
234+
}
235+
const extensionsRaw = extensionsList.join(",");
236+
const extensionsHash = runHash(
237+
"sha256",
238+
Utils.strToArrayBuffer(extensionsRaw)
239+
).substring(0, 12);
240+
241+
return {
242+
"JA4S": `${ptype}${version}${extLen}${alpn}_${cipher}_${extensionsHash}`,
243+
"JA4S_r": `${ptype}${version}${extLen}${alpn}_${cipher}_${extensionsRaw}`,
244+
};
245+
}
246+
247+
248+
/**
249+
* Takes a TLS version value and returns a JA4 TLS version string
250+
* @param {Uint8Array} version - Two byte array of version number
251+
* @returns {string}
252+
*/
253+
function tlsVersionMapper(version) {
254+
switch (version) {
255+
case 0x0304: return "13"; // TLS 1.3
256+
case 0x0303: return "12"; // TLS 1.2
257+
case 0x0302: return "11"; // TLS 1.1
258+
case 0x0301: return "10"; // TLS 1.0
259+
case 0x0300: return "s3"; // SSL 3.0
260+
case 0x0200: return "s2"; // SSL 2.0
261+
case 0x0100: return "s1"; // SSL 1.0
262+
default: return "00"; // Unknown
263+
}
264+
}

src/core/lib/TLS.mjs

+105-4
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,11 @@ function parseHandshake(bytes) {
7070

7171
// Handshake type
7272
h.handshakeType = {
73-
description: "Client Hello",
73+
description: "Handshake Type",
7474
length: 1,
7575
data: b.getBytes(1),
7676
value: s.readInt(1)
7777
};
78-
if (h.handshakeType.value !== 0x01)
79-
throw new OperationError("Not a Client Hello.");
8078

8179
// Handshake length
8280
h.handshakeLength = {
@@ -86,8 +84,33 @@ function parseHandshake(bytes) {
8684
value: s.readInt(3)
8785
};
8886
if (s.length !== h.handshakeLength.value + 4)
89-
throw new OperationError("Not enough data in Client Hello.");
87+
throw new OperationError("Not enough data in Handshake message.");
88+
89+
90+
switch (h.handshakeType.value) {
91+
case 0x01:
92+
h.handshakeType.description = "Client Hello";
93+
parseClientHello(s, b, h);
94+
break;
95+
case 0x02:
96+
h.handshakeType.description = "Server Hello";
97+
parseServerHello(s, b, h);
98+
break;
99+
default:
100+
throw new OperationError("Not a known handshake message.");
101+
}
90102

103+
return h;
104+
}
105+
106+
/**
107+
* Parse a TLS Client Hello
108+
* @param {Stream} s
109+
* @param {Stream} b
110+
* @param {Object} h
111+
* @returns {JSON}
112+
*/
113+
function parseClientHello(s, b, h) {
91114
// Hello version
92115
h.helloVersion = {
93116
description: "Client Hello Version",
@@ -171,6 +194,79 @@ function parseHandshake(bytes) {
171194
return h;
172195
}
173196

197+
/**
198+
* Parse a TLS Server Hello
199+
* @param {Stream} s
200+
* @param {Stream} b
201+
* @param {Object} h
202+
* @returns {JSON}
203+
*/
204+
function parseServerHello(s, b, h) {
205+
// Hello version
206+
h.helloVersion = {
207+
description: "Server Hello Version",
208+
length: 2,
209+
data: b.getBytes(2),
210+
value: s.readInt(2)
211+
};
212+
213+
// Random
214+
h.random = {
215+
description: "Server Random",
216+
length: 32,
217+
data: b.getBytes(32),
218+
value: s.getBytes(32)
219+
};
220+
221+
// Session ID Length
222+
h.sessionIDLength = {
223+
description: "Session ID Length",
224+
length: 1,
225+
data: b.getBytes(1),
226+
value: s.readInt(1)
227+
};
228+
229+
// Session ID
230+
h.sessionID = {
231+
description: "Session ID",
232+
length: h.sessionIDLength.value,
233+
data: b.getBytes(h.sessionIDLength.value),
234+
value: s.getBytes(h.sessionIDLength.value)
235+
};
236+
237+
// Cipher Suite
238+
h.cipherSuite = {
239+
description: "Selected Cipher Suite",
240+
length: 2,
241+
data: b.getBytes(2),
242+
value: CIPHER_SUITES_LOOKUP[s.readInt(2)] || "Unknown"
243+
};
244+
245+
// Compression Method
246+
h.compressionMethod = {
247+
description: "Selected Compression Method",
248+
length: 1,
249+
data: b.getBytes(1),
250+
value: s.readInt(1) // TODO: Compression method name here
251+
};
252+
253+
// Extensions Length
254+
h.extensionsLength = {
255+
description: "Extensions Length",
256+
length: 2,
257+
data: b.getBytes(2),
258+
value: s.readInt(2)
259+
};
260+
261+
// Extensions
262+
h.extensions = {
263+
description: "Extensions",
264+
length: h.extensionsLength.value,
265+
data: b.getBytes(h.extensionsLength.value),
266+
value: parseExtensions(s.getBytes(h.extensionsLength.value))
267+
};
268+
}
269+
174270
/**
175271
* Parse Cipher Suites
176272
* @param {Uint8Array} bytes
@@ -748,6 +844,11 @@ export const GREASE_VALUES = [
748844
export function parseHighestSupportedVersion(bytes) {
749845
const s = new Stream(bytes);
750846

847+
// The Server Hello supported_versions extension simply contains the chosen version
848+
if (s.length === 2) {
849+
return s.readInt(2);
850+
}
851+
751852
// Length
752853
let i = s.readInt(1);
753854

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @author n1474335 [[email protected]]
3+
* @copyright Crown Copyright 2024
4+
* @license Apache-2.0
5+
*/
6+
7+
import Operation from "../Operation.mjs";
8+
import Utils from "../Utils.mjs";
9+
import {toJA4S} from "../lib/JA4.mjs";
10+
11+
/**
12+
* JA4Server Fingerprint operation
13+
*/
14+
class JA4ServerFingerprint extends Operation {
15+
16+
/**
17+
* JA4ServerFingerprint constructor
18+
*/
19+
constructor() {
20+
super();
21+
22+
this.name = "JA4Server Fingerprint";
23+
this.module = "Crypto";
24+
this.description = "Generates a JA4Server Fingerprint (JA4S) to help identify TLS servers or sessions based on hashing together values from the Server Hello.<br><br>Input: A hex stream of the TLS or QUIC Server Hello packet application layer.";
25+
this.infoURL = "https://medium.com/foxio/ja4-network-fingerprinting-9376fe9ca637";
26+
this.inputType = "string";
27+
this.outputType = "string";
28+
this.args = [
29+
{
30+
name: "Input format",
31+
type: "option",
32+
value: ["Hex", "Base64", "Raw"]
33+
},
34+
{
35+
name: "Output format",
36+
type: "option",
37+
value: ["JA4S", "JA4S Raw", "Both"]
38+
}
39+
];
40+
}
41+
42+
/**
43+
* @param {string} input
44+
* @param {Object[]} args
45+
* @returns {string}
46+
*/
47+
run(input, args) {
48+
const [inputFormat, outputFormat] = args;
49+
input = Utils.convertToByteArray(input, inputFormat);
50+
const ja4s = toJA4S(new Uint8Array(input));
51+
52+
// Output
53+
switch (outputFormat) {
54+
case "JA4S":
55+
return ja4s.JA4S;
56+
case "JA4S Raw":
57+
return ja4s.JA4S_r;
58+
case "Both":
59+
default:
60+
return `JA4S: ${ja4s.JA4S}\nJA4S_r: ${ja4s.JA4S_r}`;
61+
}
62+
}
63+
64+
}
65+
66+
export default JA4ServerFingerprint;

tests/operations/index.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import "./tests/HKDF.mjs";
8383
import "./tests/Image.mjs";
8484
import "./tests/IndexOfCoincidence.mjs";
8585
import "./tests/JA3Fingerprint.mjs";
86-
import "./tests/JA4Fingerprint.mjs";
86+
import "./tests/JA4.mjs";
8787
import "./tests/JA3SFingerprint.mjs";
8888
import "./tests/JSONBeautify.mjs";
8989
import "./tests/JSONMinify.mjs";

0 commit comments

Comments
 (0)