plugins/ja3_fingerprint/ja3_fingerprint.cc (340 lines of code) (raw):
/** @file ja3_fingerprint.cc
*
Plugin JA3 Fingerprint calculates JA3 signatures for incoming SSL traffic.
@section license License
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
#include "ja3_utils.h"
#include <getopt.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string_view>
#include "ts/apidefs.h"
#include "ts/ts.h"
#include "ts/remap.h"
#ifdef OPENSSL_NO_SSL_INTERN
#undef OPENSSL_NO_SSL_INTERN
#endif
#include <openssl/ssl.h>
#include <openssl/md5.h>
#include <openssl/opensslv.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include <utility>
namespace
{
constexpr std::string_view JA3_VIA_HEADER{"x-ja3-via"};
constexpr int ja3_hash_included_byte_count{16};
static_assert(ja3_hash_included_byte_count <= MD5_DIGEST_LENGTH);
constexpr int ja3_hash_hex_string_with_null_terminator_length{2 * ja3_hash_included_byte_count + 1};
} // end anonymous namespace
const char *PLUGIN_NAME = "ja3_fingerprint";
static DbgCtl dbg_ctl{PLUGIN_NAME};
static TSTextLogObject pluginlog = nullptr;
static int ja3_idx = -1;
static int global_raw_enabled = 0;
static int global_log_enabled = 0;
static int global_modify_incoming_enabled = 0;
static int global_preserve_enabled = 0;
struct ja3_data {
std::string ja3_string;
char md5_string[ja3_hash_hex_string_with_null_terminator_length];
char ip_addr[INET6_ADDRSTRLEN];
char const *
update_fingerprint()
{
// Validate that the buffer is the same size as we will be writing into.
static_assert(ja3_hash_hex_string_with_null_terminator_length == sizeof(this->md5_string));
unsigned char digest[MD5_DIGEST_LENGTH];
MD5(reinterpret_cast<unsigned char const *>(this->ja3_string.c_str()), this->ja3_string.length(), digest);
for (int i{0}; i < ja3_hash_included_byte_count; ++i) {
std::snprintf(&(this->md5_string[i * 2]), sizeof(this->md5_string) - (i * 2), "%02x", static_cast<unsigned int>(digest[i]));
}
return this->md5_string;
}
};
struct ja3_remap_info {
int raw_enabled = false;
int log_enabled = false;
int preserve_enabled = false;
TSCont handler = nullptr;
~ja3_remap_info()
{
if (handler) {
TSContDestroy(handler);
handler = nullptr;
}
}
};
char *
getIP(sockaddr const *s_sockaddr, char res[INET6_ADDRSTRLEN])
{
res[0] = '\0';
if (s_sockaddr == nullptr) {
return nullptr;
}
switch (s_sockaddr->sa_family) {
case AF_INET: {
const struct sockaddr_in *s_sockaddr_in = reinterpret_cast<const struct sockaddr_in *>(s_sockaddr);
inet_ntop(AF_INET, &s_sockaddr_in->sin_addr, res, INET_ADDRSTRLEN);
} break;
case AF_INET6: {
const struct sockaddr_in6 *s_sockaddr_in6 = reinterpret_cast<const struct sockaddr_in6 *>(s_sockaddr);
inet_ntop(AF_INET6, &s_sockaddr_in6->sin6_addr, res, INET6_ADDRSTRLEN);
} break;
default:
return nullptr;
}
return res[0] ? res : nullptr;
}
static std::string
custom_get_ja3(SSL *ssl)
{
std::string result;
std::size_t len{};
const unsigned char *buf{};
// Get version
unsigned int version = SSL_client_hello_get0_legacy_version(ssl);
result.append(std::to_string(version));
result.push_back(',');
// Get cipher suites
len = SSL_client_hello_get0_ciphers(ssl, &buf);
result.append(ja3::encode_word_buffer(buf, len));
result.push_back(',');
// Get extensions
int *extension_ids{};
if (SSL_client_hello_get1_extensions_present(ssl, &extension_ids, &len) == 1) {
result.append(ja3::encode_integer_buffer(extension_ids, len));
OPENSSL_free(extension_ids);
}
result.push_back(',');
// Get elliptic curves
if (SSL_client_hello_get0_ext(ssl, 0x0a, &buf, &len) == 1) {
// Skip first 2 bytes since we already have length
result.append(ja3::encode_word_buffer(buf + 2, len - 2));
}
result.push_back(',');
// Get elliptic curve point formats
if (SSL_client_hello_get0_ext(ssl, 0x0b, &buf, &len) == 1) {
// Skip first byte since we already have length
result.append(ja3::encode_byte_buffer(buf + 1, len - 1));
}
return result;
}
// This function will append value to the last occurrence of field. If none exists, it will
// create a field and append to the headers
static void
append_to_field(TSMBuffer bufp, TSMLoc hdr_loc, const char *field, int field_len, const char *value, int value_len, bool preserve)
{
if (!bufp || !hdr_loc || !field || field_len <= 0) {
return;
}
TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, field, field_len);
if (target == TS_NULL_MLOC) {
TSMimeHdrFieldCreateNamed(bufp, hdr_loc, field, field_len, &target);
TSMimeHdrFieldAppend(bufp, hdr_loc, target);
TSMimeHdrFieldValueStringInsert(bufp, hdr_loc, target, -1, value, value_len);
} else if (!preserve) {
TSMLoc next = target;
while (next) {
target = next;
next = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
}
TSMimeHdrFieldValueStringInsert(bufp, hdr_loc, target, -1, value, value_len);
}
TSHandleMLocRelease(bufp, hdr_loc, target);
}
static ja3_data *
create_ja3_data(TSVConn const ssl_vc)
{
ja3_data *result = new ja3_data;
std::string const raw_ja3_string{custom_get_ja3(reinterpret_cast<SSL *>(TSVConnSslConnectionGet(ssl_vc)))};
result->ja3_string = std::move(raw_ja3_string);
getIP(TSNetVConnRemoteAddrGet(ssl_vc), result->ip_addr);
return result;
}
static int
tls_client_hello_handler(TSCont /* contp ATS_UNUSED */, TSEvent event, void *edata)
{
if (TS_EVENT_SSL_CLIENT_HELLO != event) {
Dbg(dbg_ctl, "Unexpected event %d.", event);
// We ignore the event, but we don't want to reject the connection.
return TS_SUCCESS;
}
TSVConn const ssl_vc{static_cast<TSVConn>(edata)};
ja3_data *ja3_vconn_data{create_ja3_data(ssl_vc)};
TSUserArgSet(ssl_vc, ja3_idx, static_cast<void *>(ja3_vconn_data));
Dbg(dbg_ctl, "JA3 raw: %s", ja3_vconn_data->ja3_string.c_str());
char const *fingerprint{ja3_vconn_data->update_fingerprint()};
Dbg(dbg_ctl, "JA3 fingerprint: %s", fingerprint);
TSVConnReenable(ssl_vc);
return TS_SUCCESS;
}
static int
vconn_close_handler(TSCont /* contp ATS_UNUSED */, TSEvent event, void *edata)
{
if (TS_EVENT_VCONN_CLOSE != event) {
Dbg(dbg_ctl, "Unexpected event %d.", event);
// We ignore the event, but we don't want to reject the connection.
return TS_SUCCESS;
}
TSVConn const ssl_vc{static_cast<TSVConn>(edata)};
delete static_cast<ja3_data *>(TSUserArgGet(ssl_vc, ja3_idx));
TSUserArgSet(ssl_vc, ja3_idx, nullptr);
TSVConnReenable(ssl_vc);
return TS_SUCCESS;
}
static void
modify_ja3_headers(TSCont contp, TSHttpTxn txnp, ja3_data const *ja3_vconn_data)
{
// Decide global or remap
ja3_remap_info *remap_info = static_cast<ja3_remap_info *>(TSContDataGet(contp));
bool raw_flag = remap_info ? remap_info->raw_enabled : global_raw_enabled;
bool log_flag = remap_info ? remap_info->log_enabled : global_log_enabled;
bool preserve_flag = remap_info ? remap_info->preserve_enabled : global_preserve_enabled;
Dbg(dbg_ctl, "Found ja3 string.");
// Get handle to headers
TSMBuffer bufp;
TSMLoc hdr_loc;
if (global_modify_incoming_enabled) {
TSAssert(TS_SUCCESS == TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc));
} else {
TSAssert(TS_SUCCESS == TSHttpTxnServerReqGet(txnp, &bufp, &hdr_loc));
}
TSMgmtString proxy_name = nullptr;
if (TS_SUCCESS != TSMgmtStringGet("proxy.config.proxy_name", &proxy_name)) {
TSError("[%s] Failed to get proxy name for %s, set 'proxy.config.proxy_name' in records.config", PLUGIN_NAME,
JA3_VIA_HEADER.data());
proxy_name = TSstrdup("unknown");
}
append_to_field(bufp, hdr_loc, JA3_VIA_HEADER.data(), static_cast<int>(JA3_VIA_HEADER.length()), proxy_name,
static_cast<int>(std::strlen(proxy_name)), preserve_flag);
TSfree(proxy_name);
// Add JA3 md5 fingerprints
append_to_field(bufp, hdr_loc, "x-ja3-sig", 9, ja3_vconn_data->md5_string, 32, preserve_flag);
// If raw string is configured, added JA3 raw string to header as well
if (raw_flag) {
append_to_field(bufp, hdr_loc, "x-ja3-raw", 9, ja3_vconn_data->ja3_string.data(), ja3_vconn_data->ja3_string.size(),
preserve_flag);
}
TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
// Write to logfile
if (log_flag) {
TSTextLogObjectWrite(pluginlog, "Client IP: %s\tJA3: %.*s\tMD5: %.*s", ja3_vconn_data->ip_addr,
static_cast<int>(ja3_vconn_data->ja3_string.size()), ja3_vconn_data->ja3_string.data(), 32,
ja3_vconn_data->md5_string);
}
}
static int
req_hdr_ja3_handler(TSCont contp, TSEvent event, void *edata)
{
TSEvent expected_event = global_modify_incoming_enabled ? TS_EVENT_HTTP_READ_REQUEST_HDR : TS_EVENT_HTTP_SEND_REQUEST_HDR;
if (event != expected_event) {
TSError("[%s] Unexpected event, got %d, expected %d", PLUGIN_NAME, event, expected_event);
TSAssert(event == expected_event);
}
TSHttpTxn txnp{};
TSHttpSsn ssnp{};
TSVConn vconn{};
if ((txnp = static_cast<TSHttpTxn>(edata)) == nullptr || (ssnp = TSHttpTxnSsnGet(txnp)) == nullptr ||
(vconn = TSHttpSsnClientVConnGet(ssnp)) == nullptr) {
Dbg(dbg_ctl, "Failure to retrieve txn/ssn/vconn object.");
TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
return TS_SUCCESS;
}
// Retrieve ja3_data from vconn args
ja3_data const *ja3_vconn_data = static_cast<ja3_data *>(TSUserArgGet(vconn, ja3_idx));
if (ja3_vconn_data) {
modify_ja3_headers(contp, txnp, ja3_vconn_data);
} else {
Dbg(dbg_ctl, "ja3 data not set. Not SSL vconn. Abort.");
}
TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
return TS_SUCCESS;
}
static bool
read_config_option(int argc, const char *argv[], int &raw, int &log, int &modify_incoming, int &preserve)
{
const struct option longopts[] = {
{"ja3raw", no_argument, &raw, 1},
{"ja3log", no_argument, &log, 1},
{"modify-incoming", no_argument, &modify_incoming, 1},
{"preserve", no_argument, &preserve, 1},
{nullptr, 0, nullptr, 0}
};
int opt = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "", longopts, nullptr)) >= 0) {
switch (opt) {
case '?':
Dbg(dbg_ctl, "Unrecognized command arguments.");
case 0:
case -1:
break;
default:
Dbg(dbg_ctl, "Unexpected options error.");
return false;
}
}
Dbg(dbg_ctl, "ja3 raw is %s", (raw == 1) ? "enabled" : "disabled");
Dbg(dbg_ctl, "ja3 logging is %s", (log == 1) ? "enabled" : "disabled");
Dbg(dbg_ctl, "ja3 modify-incoming is %s", (modify_incoming == 1) ? "enabled" : "disabled");
Dbg(dbg_ctl, "ja3 preserve is %s", (preserve == 1) ? "enabled" : "disabled");
return true;
}
void
TSPluginInit(int argc, const char *argv[])
{
Dbg(dbg_ctl, "Initializing plugin");
TSPluginRegistrationInfo info;
info.plugin_name = PLUGIN_NAME;
info.vendor_name = "Apache Software Foundation";
info.support_email = "dev@trafficserver.apache.org";
// Options
if (!read_config_option(argc, argv, global_raw_enabled, global_log_enabled, global_modify_incoming_enabled,
global_preserve_enabled)) {
return;
}
if (TSPluginRegister(&info) != TS_SUCCESS) {
TSError("[%s] Unable to initialize plugin. Failed to register.", PLUGIN_NAME);
} else {
if (global_log_enabled && !pluginlog) {
TSAssert(TS_SUCCESS == TSTextLogObjectCreate(PLUGIN_NAME, TS_LOG_MODE_ADD_TIMESTAMP, &pluginlog));
Dbg(dbg_ctl, "log object created successfully");
}
// SNI handler
TSUserArgIndexReserve(TS_USER_ARGS_VCONN, PLUGIN_NAME, "used to pass ja3", &ja3_idx);
TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, TSContCreate(tls_client_hello_handler, nullptr));
TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, TSContCreate(vconn_close_handler, nullptr));
TSHttpHookID const hook = global_modify_incoming_enabled ? TS_HTTP_READ_REQUEST_HDR_HOOK : TS_HTTP_SEND_REQUEST_HDR_HOOK;
TSHttpHookAdd(hook, TSContCreate(req_hdr_ja3_handler, nullptr));
}
return;
}
// Remap Part
TSReturnCode
TSRemapInit(TSRemapInterface * /* api_info ATS_UNUSED */, char * /* errbuf ATS_UNUSED */, int /* errbuf_size ATS_UNUSED */)
{
Dbg(dbg_ctl, "JA3 Remap Plugin initializing..");
// Check if there is config conflict as both global and remap plugin
if (ja3_idx >= 0) {
TSError("[%s] JA3 configured as both global and remap. Check plugin.config.", PLUGIN_NAME);
return TS_ERROR;
}
// Set up SNI handler for all TLS connections
TSUserArgIndexReserve(TS_USER_ARGS_VCONN, PLUGIN_NAME, "Used to pass ja3", &ja3_idx);
TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, TSContCreate(tls_client_hello_handler, nullptr));
TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, TSContCreate(vconn_close_handler, nullptr));
return TS_SUCCESS;
}
TSReturnCode
TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSED */, int /* errbuf_size ATS_UNUSED */)
{
Dbg(dbg_ctl, "New instance for client matching %s to %s", argv[0], argv[1]);
std::unique_ptr<ja3_remap_info> remap_info{new ja3_remap_info};
// Parse parameters
int discard_modify_incoming = -1; // Not used for remap.
if (!read_config_option(argc - 1, const_cast<const char **>(argv + 1), remap_info->raw_enabled, remap_info->log_enabled,
discard_modify_incoming, remap_info->preserve_enabled)) {
Dbg(dbg_ctl, "Bad arguments");
return TS_ERROR;
}
if (remap_info->log_enabled && !pluginlog) {
TSAssert(TS_SUCCESS == TSTextLogObjectCreate(PLUGIN_NAME, TS_LOG_MODE_ADD_TIMESTAMP, &pluginlog));
Dbg(dbg_ctl, "log object created successfully");
}
// Create continuation
remap_info->handler = TSContCreate(req_hdr_ja3_handler, nullptr);
TSContDataSet(remap_info->handler, remap_info.get());
// Pass to other remap plugin functions
*ih = static_cast<void *>(remap_info.release());
return TS_SUCCESS;
}
TSRemapStatus
TSRemapDoRemap(void *ih, TSHttpTxn rh, TSRemapRequestInfo *rri)
{
auto remap_info = static_cast<ja3_remap_info *>(ih);
// On remap, set up handler at send req hook to send JA3 data as header
if (!remap_info || !rri || !(remap_info->handler)) {
TSError("[%s] Invalid private data or RRI or handler.", PLUGIN_NAME);
} else {
TSHttpTxnHookAdd(rh, TS_HTTP_SEND_REQUEST_HDR_HOOK, remap_info->handler);
}
return TSREMAP_NO_REMAP;
}
void
TSRemapDeleteInstance(void *ih)
{
auto remap_info = static_cast<ja3_remap_info *>(ih);
delete remap_info;
ih = nullptr;
}