diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 8f8a14f9..a519cf09 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -238,6 +238,7 @@ "JA3 Fingerprint", "JA3S Fingerprint", "JA4 Fingerprint", + "JA4Server Fingerprint", "HASSH Client Fingerprint", "HASSH Server Fingerprint", "Format MAC addresses", diff --git a/src/core/lib/JA4.mjs b/src/core/lib/JA4.mjs index b0b423a2..5e606f46 100644 --- a/src/core/lib/JA4.mjs +++ b/src/core/lib/JA4.mjs @@ -25,6 +25,9 @@ export function toJA4(bytes) { let tlsr = {}; try { tlsr = parseTLSRecord(bytes); + if (tlsr.handshake.value.handshakeType.value !== 0x01) { + throw new Error(); + } } catch (err) { throw new OperationError("Data is not a valid TLS Client Hello. QUIC is not yet supported.\n" + err); } @@ -48,16 +51,7 @@ export function toJA4(bytes) { break; } } - switch (version) { - case 0x0304: version = "13"; break; // TLS 1.3 - case 0x0303: version = "12"; break; // TLS 1.2 - case 0x0302: version = "11"; break; // TLS 1.1 - case 0x0301: version = "10"; break; // TLS 1.0 - case 0x0300: version = "s3"; break; // SSL 3.0 - case 0x0200: version = "s2"; break; // SSL 2.0 - case 0x0100: version = "s1"; break; // SSL 1.0 - default: version = "00"; // Unknown - } + version = tlsVersionMapper(version); /* SNI 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) { if (ext.type.value === "application_layer_protocol_negotiation") { alpn = parseFirstALPNValue(ext.value.data); alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1); + if (alpn.charCodeAt(0) > 127) alpn = "99"; break; } } @@ -164,3 +159,106 @@ export function toJA4(bytes) { "JA4_ro": `${ptype}${version}${sni}${cipherLen}${extLen}${alpn}_${originalCiphersRaw}_${originalExtensionsRaw}`, }; } + + +/** + * Calculate the JA4Server from a given TLS Server Hello Stream + * @param {Uint8Array} bytes + * @returns {string} + */ +export function toJA4S(bytes) { + let tlsr = {}; + try { + tlsr = parseTLSRecord(bytes); + if (tlsr.handshake.value.handshakeType.value !== 0x02) { + throw new Error(); + } + } catch (err) { + throw new OperationError("Data is not a valid TLS Server Hello. QUIC is not yet supported.\n" + err); + } + + /* QUIC + “q” or “t”, which denotes whether the hello packet is for QUIC or TCP. + TODO: Implement QUIC + */ + const ptype = "t"; + + /* TLS Version + TLS version is shown in 3 different places. If extension 0x002b exists (supported_versions), then the version + is the highest value in the extension. Remember to ignore GREASE values. If the extension doesn’t exist, then + the TLS version is the value of the Protocol Version. Handshake version (located at the top of the packet) + should be ignored. + */ + let version = tlsr.version.value; + for (const ext of tlsr.handshake.value.extensions.value) { + if (ext.type.value === "supported_versions") { + version = parseHighestSupportedVersion(ext.value.data); + break; + } + } + version = tlsVersionMapper(version); + + /* Number of Extensions + 2 character number of cipher suites, so if there’s 6 cipher suites in the hello packet, then the value should be “06”. + If there’s > 99, which there should never be, then output “99”. + */ + let extLen = tlsr.handshake.value.extensions.value.length; + extLen = extLen > 99 ? "99" : extLen.toString().padStart(2, "0"); + + /* ALPN Extension Chosen Value + The first and last characters of the ALPN (Application-Layer Protocol Negotiation) first value. + If there are no ALPN values or no ALPN extension then we print “00” as the value in the fingerprint. + */ + let alpn = "00"; + for (const ext of tlsr.handshake.value.extensions.value) { + if (ext.type.value === "application_layer_protocol_negotiation") { + alpn = parseFirstALPNValue(ext.value.data); + alpn = alpn.charAt(0) + alpn.charAt(alpn.length - 1); + if (alpn.charCodeAt(0) > 127) alpn = "99"; + break; + } + } + + /* Chosen Cipher + The hex value of the chosen cipher suite + */ + const cipher = toHexFast(tlsr.handshake.value.cipherSuite.data); + + /* Extension hash + A 12 character truncated sha256 hash of the list of extensions. + The extension list is created using the 4 character hex values of the extensions, lower case, comma delimited. + */ + const extensionsList = []; + for (const ext of tlsr.handshake.value.extensions.value) { + extensionsList.push(toHexFast(ext.type.data)); + } + const extensionsRaw = extensionsList.join(","); + const extensionsHash = runHash( + "sha256", + Utils.strToArrayBuffer(extensionsRaw) + ).substring(0, 12); + + return { + "JA4S": `${ptype}${version}${extLen}${alpn}_${cipher}_${extensionsHash}`, + "JA4S_r": `${ptype}${version}${extLen}${alpn}_${cipher}_${extensionsRaw}`, + }; +} + + +/** + * Takes a TLS version value and returns a JA4 TLS version string + * @param {Uint8Array} version - Two byte array of version number + * @returns {string} + */ +function tlsVersionMapper(version) { + switch (version) { + case 0x0304: return "13"; // TLS 1.3 + case 0x0303: return "12"; // TLS 1.2 + case 0x0302: return "11"; // TLS 1.1 + case 0x0301: return "10"; // TLS 1.0 + case 0x0300: return "s3"; // SSL 3.0 + case 0x0200: return "s2"; // SSL 2.0 + case 0x0100: return "s1"; // SSL 1.0 + default: return "00"; // Unknown + } +} diff --git a/src/core/lib/TLS.mjs b/src/core/lib/TLS.mjs index e3f18eb3..6373bfa2 100644 --- a/src/core/lib/TLS.mjs +++ b/src/core/lib/TLS.mjs @@ -70,13 +70,11 @@ function parseHandshake(bytes) { // Handshake type h.handshakeType = { - description: "Client Hello", + description: "Handshake Type", length: 1, data: b.getBytes(1), value: s.readInt(1) }; - if (h.handshakeType.value !== 0x01) - throw new OperationError("Not a Client Hello."); // Handshake length h.handshakeLength = { @@ -86,8 +84,33 @@ function parseHandshake(bytes) { value: s.readInt(3) }; if (s.length !== h.handshakeLength.value + 4) - throw new OperationError("Not enough data in Client Hello."); + throw new OperationError("Not enough data in Handshake message."); + + switch (h.handshakeType.value) { + case 0x01: + h.handshakeType.description = "Client Hello"; + parseClientHello(s, b, h); + break; + case 0x02: + h.handshakeType.description = "Server Hello"; + parseServerHello(s, b, h); + break; + default: + throw new OperationError("Not a known handshake message."); + } + + return h; +} + +/** + * Parse a TLS Client Hello + * @param {Stream} s + * @param {Stream} b + * @param {Object} h + * @returns {JSON} + */ +function parseClientHello(s, b, h) { // Hello version h.helloVersion = { description: "Client Hello Version", @@ -171,6 +194,79 @@ function parseHandshake(bytes) { return h; } +/** + * Parse a TLS Server Hello + * @param {Stream} s + * @param {Stream} b + * @param {Object} h + * @returns {JSON} + */ +function parseServerHello(s, b, h) { + // Hello version + h.helloVersion = { + description: "Server Hello Version", + length: 2, + data: b.getBytes(2), + value: s.readInt(2) + }; + + // Random + h.random = { + description: "Server Random", + length: 32, + data: b.getBytes(32), + value: s.getBytes(32) + }; + + // Session ID Length + h.sessionIDLength = { + description: "Session ID Length", + length: 1, + data: b.getBytes(1), + value: s.readInt(1) + }; + + // Session ID + h.sessionID = { + description: "Session ID", + length: h.sessionIDLength.value, + data: b.getBytes(h.sessionIDLength.value), + value: s.getBytes(h.sessionIDLength.value) + }; + + // Cipher Suite + h.cipherSuite = { + description: "Selected Cipher Suite", + length: 2, + data: b.getBytes(2), + value: CIPHER_SUITES_LOOKUP[s.readInt(2)] || "Unknown" + }; + + // Compression Method + h.compressionMethod = { + description: "Selected Compression Method", + length: 1, + data: b.getBytes(1), + value: s.readInt(1) // TODO: Compression method name here + }; + + // Extensions Length + h.extensionsLength = { + description: "Extensions Length", + length: 2, + data: b.getBytes(2), + value: s.readInt(2) + }; + + // Extensions + h.extensions = { + description: "Extensions", + length: h.extensionsLength.value, + data: b.getBytes(h.extensionsLength.value), + value: parseExtensions(s.getBytes(h.extensionsLength.value)) + }; +} + /** * Parse Cipher Suites * @param {Uint8Array} bytes @@ -748,6 +844,11 @@ export const GREASE_VALUES = [ export function parseHighestSupportedVersion(bytes) { const s = new Stream(bytes); + // The Server Hello supported_versions extension simply contains the chosen version + if (s.length === 2) { + return s.readInt(2); + } + // Length let i = s.readInt(1); diff --git a/src/core/operations/JA4ServerFingerprint.mjs b/src/core/operations/JA4ServerFingerprint.mjs new file mode 100644 index 00000000..662285a8 --- /dev/null +++ b/src/core/operations/JA4ServerFingerprint.mjs @@ -0,0 +1,66 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2024 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import Utils from "../Utils.mjs"; +import {toJA4S} from "../lib/JA4.mjs"; + +/** + * JA4Server Fingerprint operation + */ +class JA4ServerFingerprint extends Operation { + + /** + * JA4ServerFingerprint constructor + */ + constructor() { + super(); + + this.name = "JA4Server Fingerprint"; + this.module = "Crypto"; + this.description = "Generates a JA4Server Fingerprint (JA4S) to help identify TLS servers or sessions based on hashing together values from the Server Hello.

Input: A hex stream of the TLS or QUIC Server Hello packet application layer."; + this.infoURL = "https://medium.com/foxio/ja4-network-fingerprinting-9376fe9ca637"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Input format", + type: "option", + value: ["Hex", "Base64", "Raw"] + }, + { + name: "Output format", + type: "option", + value: ["JA4S", "JA4S Raw", "Both"] + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [inputFormat, outputFormat] = args; + input = Utils.convertToByteArray(input, inputFormat); + const ja4s = toJA4S(new Uint8Array(input)); + + // Output + switch (outputFormat) { + case "JA4S": + return ja4s.JA4S; + case "JA4S Raw": + return ja4s.JA4S_r; + case "Both": + default: + return `JA4S: ${ja4s.JA4S}\nJA4S_r: ${ja4s.JA4S_r}`; + } + } + +} + +export default JA4ServerFingerprint; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 9d0e418b..ab6dac68 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -83,7 +83,7 @@ import "./tests/HKDF.mjs"; import "./tests/Image.mjs"; import "./tests/IndexOfCoincidence.mjs"; import "./tests/JA3Fingerprint.mjs"; -import "./tests/JA4Fingerprint.mjs"; +import "./tests/JA4.mjs"; import "./tests/JA3SFingerprint.mjs"; import "./tests/JSONBeautify.mjs"; import "./tests/JSONMinify.mjs"; diff --git a/tests/operations/tests/JA4Fingerprint.mjs b/tests/operations/tests/JA4.mjs similarity index 63% rename from tests/operations/tests/JA4Fingerprint.mjs rename to tests/operations/tests/JA4.mjs index dba84a38..0fb4624e 100644 --- a/tests/operations/tests/JA4Fingerprint.mjs +++ b/tests/operations/tests/JA4.mjs @@ -1,5 +1,5 @@ /** - * JA4Fingerprint tests. + * JA4 tests. * * @author n1474335 [n1474335@gmail.com] * @copyright Crown Copyright 2024 @@ -52,4 +52,70 @@ TestRegister.addTests([ } ], }, + { + name: "JA4Server Fingerprint: TLS 1.2 h2 ALPN", + input: "16030300640200006003035f0236c07f47bfb12dc2da706ecb3fe7f9eeac9968cc2ddf444f574e4752440120b89ff1ab695278c69b8a73f76242ef755e0b13dc6d459aaaa784fec9c2dfce34cca900001800000000ff01000100000b00020100001000050003026832", + expectedOutput: "t1204h2_cca9_1428ce7b4018", + recipeConfig: [ + { + "op": "JA4Server Fingerprint", + "args": ["Hex", "JA4S"] + } + ] + }, + { + name: "JA4Server Fingerprint: TLS 1.2 h2 ALPN Raw", + input: "16030300640200006003035f0236c07f47bfb12dc2da706ecb3fe7f9eeac9968cc2ddf444f574e4752440120b89ff1ab695278c69b8a73f76242ef755e0b13dc6d459aaaa784fec9c2dfce34cca900001800000000ff01000100000b00020100001000050003026832", + expectedOutput: "t1204h2_cca9_0000,ff01,000b,0010", + recipeConfig: [ + { + "op": "JA4Server Fingerprint", + "args": ["Hex", "JA4S Raw"] + } + ] + }, + { + name: "JA4Server Fingerprint: TLS 1.3", + input: "160303007a020000760303236d214556452c55a0754487e64b1a8b0262c50ba23004c9d504166a6de3439920d0b0099243c9296a0c84153ea4ada7d87ad017f4211c2ea1350b0b3cc5514d5f130100002e00330024001d002099e3cc43a2c9941ae75af1b2c7a629bee3ee7031973cad85c82f2f23677fb244002b00020304", + expectedOutput: "t130200_1301_234ea6891581", + recipeConfig: [ + { + "op": "JA4Server Fingerprint", + "args": ["Hex", "JA4S"] + } + ] + }, + { + name: "JA4Server Fingerprint: TLS 1.3 Raw", + input: "160303007a020000760303236d214556452c55a0754487e64b1a8b0262c50ba23004c9d504166a6de3439920d0b0099243c9296a0c84153ea4ada7d87ad017f4211c2ea1350b0b3cc5514d5f130100002e00330024001d002099e3cc43a2c9941ae75af1b2c7a629bee3ee7031973cad85c82f2f23677fb244002b00020304", + expectedOutput: "t130200_1301_0033,002b", + recipeConfig: [ + { + "op": "JA4Server Fingerprint", + "args": ["Hex", "JA4S Raw"] + } + ] + }, + { + name: "JA4Server Fingerprint: TLS 1.3 non-ascii ALPN", + input: "160303007a020000760303897c232e3ee313314f2b662307ff4f7e2cf1caeec1b27711bca77f469519168520bc58b92f865e6b9aa4a6371cadcb0afe1da1c0f705209a11d52357f56d5dd962130100002e00330024001d002076b8b7ed0f96b63a773d85ab6f3a87a151c130529785b41a4defb53184055957002b00020304", + expectedOutput: "t130200_1301_234ea6891581", + recipeConfig: [ + { + "op": "JA4Server Fingerprint", + "args": ["Hex", "JA4S"] + } + ] + }, + { + name: "JA4Server Fingerprint: TLS 1.3 non-ascii ALPN Raw", + input: "160303007a020000760303897c232e3ee313314f2b662307ff4f7e2cf1caeec1b27711bca77f469519168520bc58b92f865e6b9aa4a6371cadcb0afe1da1c0f705209a11d52357f56d5dd962130100002e00330024001d002076b8b7ed0f96b63a773d85ab6f3a87a151c130529785b41a4defb53184055957002b00020304", + expectedOutput: "t130200_1301_0033,002b", + recipeConfig: [ + { + "op": "JA4Server Fingerprint", + "args": ["Hex", "JA4S Raw"] + } + ] + }, ]);