diff --git a/etc/schema.json b/etc/schema.json index 9794c439a8..c08ee10011 100644 --- a/etc/schema.json +++ b/etc/schema.json @@ -3358,6 +3358,413 @@ } } }, + "llmnr": { + "description": "LLMNR requests and responses", + "type": "object", + "additionalProperties": false, + "properties": { + "additionals": { + "description": "LLMNR additional records", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "a": { + "description": "IPv4 address", + "type": "string" + }, + "aaaa": { + "description": "IPv6 address", + "type": "string" + }, + "cname": { + "description": "Canonical name", + "type": "string" + }, + "mx": { + "description": "Mail exchange", + "type": "string" + }, + "ns": { + "description": "Name server", + "type": "string" + }, + "null": { + "description": "NULL record data", + "type": "string" + }, + "opt": { + "description": "EDNS OPT records", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "description": "EDNS option code", + "type": "integer" + }, + "data": { + "description": "EDNS option data", + "type": "string" + } + } + } + }, + "ptr": { + "description": "Pointer record", + "type": "string" + }, + "rrname": { + "description": "Resource name of the record", + "type": "string" + }, + "rrname_truncated": { + "description": "Name was truncated by Suricata due to length", + "type": "boolean" + }, + "rrtype": { + "description": "Resource record type", + "type": "string" + }, + "ttl": { + "description": "Time to live in seconds", + "type": "integer" + }, + "txt": { + "description": "TXT record values", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "unknown": { + "description": "Unknown record type data", + "type": "string" + } + } + } + }, + "answers": { + "description": "LLMNR answer records", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "a": { + "description": "IPv4 address", + "type": "string" + }, + "aaaa": { + "description": "IPv6 address", + "type": "string" + }, + "cname": { + "description": "Canonical name", + "type": "string" + }, + "mx": { + "description": "Mail exchange", + "type": "string" + }, + "ns": { + "description": "Name server", + "type": "string" + }, + "null": { + "description": "NULL record data", + "type": "string" + }, + "ptr": { + "description": "Pointer record", + "type": "string" + }, + "rrname": { + "description": "Resource name of the record", + "type": "string" + }, + "rrname_truncated": { + "description": "Name was truncated by Suricata due to length", + "type": "boolean" + }, + "rrtype": { + "description": "Resource record type", + "type": "string" + }, + "soa": { + "description": "SOA record data", + "$ref": "#/$defs/dns.soa" + }, + "srv": { + "description": "SRV record data", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "Target hostname", + "type": "string" + }, + "port": { + "description": "Target port", + "type": "integer" + }, + "priority": { + "description": "Priority value", + "type": "integer" + }, + "weight": { + "description": "Weight value", + "type": "integer" + } + } + }, + "sshfp": { + "description": "SSH fingerprint record", + "type": "object", + "additionalProperties": false, + "properties": { + "algo": { + "description": "Algorithm number", + "type": "integer" + }, + "fingerprint": { + "description": "Fingerprint value", + "type": "string" + }, + "type": { + "description": "Fingerprint type", + "type": "integer" + } + } + }, + "ttl": { + "description": "Time to live in seconds", + "type": "integer" + }, + "txt": { + "description": "TXT record values", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "unknown": { + "description": "Unknown record type data", + "type": "string" + } + } + } + }, + "authorities": { + "description": "LLMNR authority records", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "a": { + "description": "IPv4 address", + "type": "string" + }, + "aaaa": { + "description": "IPv6 address", + "type": "string" + }, + "cname": { + "description": "Canonical name", + "type": "string" + }, + "mx": { + "description": "Mail exchange", + "type": "string" + }, + "ns": { + "description": "Name server", + "type": "string" + }, + "null": { + "description": "NULL record data", + "type": "string" + }, + "ptr": { + "description": "Pointer record", + "type": "string" + }, + "rrname": { + "description": "Resource name of the record", + "type": "string" + }, + "rrname_truncated": { + "description": "Name was truncated by Suricata due to length", + "type": "boolean" + }, + "rrtype": { + "description": "Resource record type", + "type": "string" + }, + "soa": { + "description": "SOA record data", + "$ref": "#/$defs/dns.soa" + }, + "ttl": { + "description": "Time to live in seconds", + "type": "integer" + }, + "txt": { + "description": "TXT record values", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "unknown": { + "description": "Unknown record type data", + "type": "string" + } + } + } + }, + "flags": { + "description": "LLMNR message flags", + "type": "array", + "items": { + "type": "string", + "enum": ["c", "tc", "t"] + } + }, + "grouped": { + "description": "Grouped answer records by type", + "type": "object", + "additionalProperties": false, + "properties": { + "A": { + "description": "IPv4 address records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "AAAA": { + "description": "IPv6 address records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "CNAME": { + "description": "Canonical name records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "MX": { + "description": "Mail exchange records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "NS": { + "description": "Name server records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "NULL": { + "description": "NULL record data", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "PTR": { + "description": "Pointer records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "SOA": { + "description": "Start of authority records", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/dns.soa" + } + }, + "TXT": { + "description": "Text records", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "id": { + "description": "LLMNR transaction ID", + "type": "integer" + }, + "opcode": { + "description": "LLMNR opcode value", + "type": "integer" + }, + "queries": { + "description": "LLMNR query records", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "rrname": { + "description": "Resource name being requested", + "type": "string" + }, + "rrname_truncated": { + "description": "Name was truncated by Suricata due to length", + "type": "boolean" + }, + "rrtype": { + "type": "string", + "description": "Type of resource being requested" + } + } + } + }, + "tx_id": { + "description": "Internal transaction ID", + "type": "integer" + }, + "type": { + "description": "Type of message, either a request or response", + "type": "string", + "enum": [ + "request", + "response" + ] + } + } + }, "log_level": { "type": "string" }, @@ -5886,6 +6293,14 @@ "description": "Errors encountered parsing LDAP/UDP protocol", "$ref": "#/$defs/stats_applayer_error" }, + "llmnr_tcp": { + "description": "Errors encountered parsing LLMNR/TCP protocol", + "$ref": "#/$defs/stats_applayer_error" + }, + "llmnr_udp": { + "description": "Errors encountered parsing LLMNR/UDP protocol", + "$ref": "#/$defs/stats_applayer_error" + }, "mdns": { "description": "Errors encountered parsing mDNS", "$ref": "#/$defs/stats_applayer_error" @@ -6069,6 +6484,14 @@ "type": "integer", "description": "Number of flows LDAP/UDP protocol" }, + "llmnr_tcp": { + "type": "integer", + "description": "Number of flows for LLMNR/TCP protocol" + }, + "llmnr_udp": { + "type": "integer", + "description": "Number of flows for LLMNR/UDP protocol" + }, "mdns": { "description": "Number of flows for mDNS", "type": "integer" @@ -6243,6 +6666,14 @@ "type": "integer", "description": "Number of transactions for LDAP/UDP protocol" }, + "llmnr_tcp": { + "type": "integer", + "description": "Number of transactions for LLMNR/TCP protocol" + }, + "llmnr_udp": { + "type": "integer", + "description": "Number of transactions for LLMNR/UDP protocol" + }, "mdns": { "description": "Number of transactions for mDNS", "type": "integer" diff --git a/rust/src/llmnr/log.rs b/rust/src/llmnr/log.rs new file mode 100644 index 0000000000..b8367f5f6b --- /dev/null +++ b/rust/src/llmnr/log.rs @@ -0,0 +1,168 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// written by Giuseppe Longo + +use crate::dns::dns::*; +use crate::dns::log::{ + dns_log_opt, dns_log_soa, dns_log_srv, dns_log_sshfp, dns_print_addr, dns_rrtype_string, +}; +use crate::jsonbuilder::{JsonBuilder, JsonError}; + +fn llmnr_log_json_answer_detail(answer: &DNSAnswerEntry) -> Result { + let mut jsa = JsonBuilder::try_new_object()?; + + jsa.set_string_from_bytes("rrname", &answer.name.value)?; + if answer.name.flags.contains(DNSNameFlags::TRUNCATED) { + jsa.set_bool("rrname_truncated", true)?; + } + let rrtype = dns_rrtype_string(answer.rrtype).to_lowercase(); + + match &answer.data { + DNSRData::A(addr) | DNSRData::AAAA(addr) => { + jsa.set_string(&rrtype, &dns_print_addr(addr))?; + } + DNSRData::CNAME(name) | DNSRData::MX(name) | DNSRData::NS(name) | DNSRData::PTR(name) => { + jsa.set_string_from_bytes(&rrtype, &name.value)?; + if name.flags.contains(DNSNameFlags::TRUNCATED) { + jsa.set_bool("rdata_truncated", true)?; + } + } + DNSRData::TXT(txt) => { + jsa.open_array(&rrtype)?; + for txt in txt { + jsa.append_string_from_bytes(txt)?; + } + jsa.close()?; + } + DNSRData::NULL(bytes) | DNSRData::Unknown(bytes) => { + jsa.set_string_from_bytes(&rrtype, bytes)?; + } + DNSRData::SOA(soa) => { + jsa.set_object(&rrtype, &dns_log_soa(soa)?)?; + } + DNSRData::SSHFP(sshfp) => { + jsa.set_object(&rrtype, &dns_log_sshfp(sshfp)?)?; + } + DNSRData::SRV(srv) => { + jsa.set_object(&rrtype, &dns_log_srv(srv)?)?; + } + DNSRData::OPT(opt) => { + jsa.open_array(&rrtype)?; + for val in opt { + jsa.append_object(&dns_log_opt(val)?)?; + } + jsa.close()?; + } + } + + jsa.close()?; + return Ok(jsa); +} + +fn log_json(tx: &DNSTransaction, jb: &mut JsonBuilder) -> Result<(), JsonError> { + jb.open_object("llmnr")?; + + let message = if let Some(request) = &tx.request { + jb.set_string("type", "request")?; + request + } else if let Some(response) = &tx.response { + jb.set_string("type", "response")?; + response + } else { + debug_validate_fail!("unreachable"); + return Ok(()); + }; + + // The on the wire LLMNR transaction ID. + jb.set_uint("id", tx.tx_id() as u64)?; + + let header = &message.header; + if header.flags & (0x0400 | 0x0200 | 0x0100) != 0 { + jb.open_array("flags")?; + if header.flags & 0x0400 != 0 { + jb.append_string("c")?; + } + if header.flags & 0x0200 != 0 { + jb.append_string("tc")?; + } + if header.flags & 0x0100 != 0 { + jb.append_string("t")?; + } + jb.close()?; + } + + let opcode = ((header.flags >> 11) & 0xf) as u8; + jb.set_uint("opcode", opcode as u64)?; + + if !message.queries.is_empty() { + jb.open_array("queries")?; + for query in &message.queries { + jb.start_object()? + .set_string_from_bytes("rrname", &query.name.value)? + .set_string("rrtype", &dns_rrtype_string(query.rrtype).to_lowercase())?; + if query.name.flags.contains(DNSNameFlags::TRUNCATED) { + jb.set_bool("rrname_truncated", true)?; + } + jb.close()?; + } + jb.close()?; + } + + if !message.answers.is_empty() { + jb.open_array("answers")?; + for entry in &message.answers { + jb.append_object(&llmnr_log_json_answer_detail(entry)?)?; + } + jb.close()?; + } + + if !message.authorities.is_empty() { + jb.open_array("authorities")?; + for entry in &message.authorities { + jb.append_object(&llmnr_log_json_answer_detail(entry)?)?; + } + jb.close()?; + } + + if !message.additionals.is_empty() { + let mut is_jb_open = false; + for entry in &message.additionals { + if let DNSRData::OPT(rdata) = &entry.data { + if rdata.is_empty() { + continue; + } + } + if !is_jb_open { + jb.open_array("additionals")?; + is_jb_open = true; + } + jb.append_object(&llmnr_log_json_answer_detail(entry)?)?; + } + if is_jb_open { + jb.close()?; + } + } + + jb.close()?; + Ok(()) +} + +#[no_mangle] +pub extern "C" fn SCLLMNRLogJson(tx: &mut DNSTransaction, jb: &mut JsonBuilder) -> bool { + log_json(tx, jb).is_ok() +} diff --git a/rust/src/llmnr/mod.rs b/rust/src/llmnr/mod.rs index c5ff69eabd..d8edcb7cfe 100644 --- a/rust/src/llmnr/mod.rs +++ b/rust/src/llmnr/mod.rs @@ -20,3 +20,4 @@ // written by Giuseppe Longo pub mod llmnr; +pub mod log; diff --git a/src/Makefile.am b/src/Makefile.am index 43f0ad1a10..6b0039a2f6 100755 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -369,6 +369,7 @@ noinst_HEADERS = \ output-json-ftp.h \ output-json-http.h \ output-json-ike.h \ + output-json-llmnr.h \ output-json-mdns.h \ output-json-metadata.h \ output-json-mqtt.h \ @@ -943,6 +944,7 @@ libsuricata_c_a_SOURCES = \ output-json-ftp.c \ output-json-http.c \ output-json-ike.c \ + output-json-llmnr.c \ output-json-mdns.c \ output-json-metadata.c \ output-json-mqtt.c \ diff --git a/src/output-json-llmnr.c b/src/output-json-llmnr.c new file mode 100644 index 0000000000..b7542d8a69 --- /dev/null +++ b/src/output-json-llmnr.c @@ -0,0 +1,161 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// written by Giuseppe Longo + +#include "suricata-common.h" +#include "conf.h" + +#include "threadvars.h" + +#include "util-byte.h" +#include "util-debug.h" +#include "util-mem.h" +#include "app-layer-parser.h" +#include "output.h" + +#include "output-json.h" +#include "output-json-llmnr.h" +#include "rust.h" + +typedef struct SCLLMNRLogFileCtx_ { + uint64_t flags; /** Store mode */ + OutputJsonCtx *eve_ctx; +} SCLLMNRLogFileCtx; + +typedef struct LogLLMNRLogThread_ { + SCLLMNRLogFileCtx *llmnrlog_ctx; + OutputJsonThreadCtx *ctx; +} SCLLMNRLogThread; + +bool AlertJsonLLMNR(void *txptr, SCJsonBuilder *js) +{ + return SCLLMNRLogJson(txptr, js); +} + +static int JsonLLMNRLogger(ThreadVars *tv, void *thread_data, const Packet *p, Flow *f, + void *alstate, void *txptr, uint64_t tx_id) +{ + SCLLMNRLogThread *td = (SCLLMNRLogThread *)thread_data; + SCLLMNRLogFileCtx *llmnrlog_ctx = td->llmnrlog_ctx; + + SCJsonBuilder *jb = CreateEveHeader(p, LOG_DIR_FLOW, "llmnr", NULL, llmnrlog_ctx->eve_ctx); + if (unlikely(jb == NULL)) { + return TM_ECODE_OK; + } + + if (SCLLMNRLogJson(txptr, jb)) { + OutputJsonBuilderBuffer(tv, p, p->flow, jb, td->ctx); + } + SCJbFree(jb); + + return TM_ECODE_OK; +} + +static TmEcode SCLLMNRLogThreadInit(ThreadVars *t, const void *initdata, void **data) +{ + SCLLMNRLogThread *aft = SCCalloc(1, sizeof(SCLLMNRLogThread)); + if (unlikely(aft == NULL)) + return TM_ECODE_FAILED; + + if (initdata == NULL) { + SCLogDebug("Error getting context for EveLogLLMNR. \"initdata\" argument NULL"); + goto error_exit; + } + + /* Use the Output Context (file pointer and mutex) */ + aft->llmnrlog_ctx = ((OutputCtx *)initdata)->data; + aft->ctx = CreateEveThreadCtx(t, aft->llmnrlog_ctx->eve_ctx); + if (!aft->ctx) { + goto error_exit; + } + + *data = (void *)aft; + return TM_ECODE_OK; + +error_exit: + SCFree(aft); + return TM_ECODE_FAILED; +} + +static TmEcode SCLLMNRLogThreadDeinit(ThreadVars *t, void *data) +{ + SCLLMNRLogThread *aft = (SCLLMNRLogThread *)data; + if (aft == NULL) { + return TM_ECODE_OK; + } + FreeEveThreadCtx(aft->ctx); + + /* clear memory */ + memset(aft, 0, sizeof(SCLLMNRLogThread)); + + SCFree(aft); + return TM_ECODE_OK; +} + +static void SCLLMNRLogDeInitCtxSub(OutputCtx *output_ctx) +{ + SCLogDebug("cleaning up sub output_ctx %p", output_ctx); + SCLLMNRLogFileCtx *llmnrlog_ctx = (SCLLMNRLogFileCtx *)output_ctx->data; + SCFree(llmnrlog_ctx); + SCFree(output_ctx); +} + +static OutputInitResult JsonLLMNRLogInitCtxSub(SCConfNode *conf, OutputCtx *parent_ctx) +{ + OutputInitResult result = { NULL, false }; + const char *enabled = SCConfNodeLookupChildValue(conf, "enabled"); + if (enabled != NULL && !SCConfValIsTrue(enabled)) { + result.ok = true; + return result; + } + + OutputJsonCtx *ojc = parent_ctx->data; + + SCLLMNRLogFileCtx *llmnrlog_ctx = SCCalloc(1, sizeof(SCLLMNRLogFileCtx)); + if (unlikely(llmnrlog_ctx == NULL)) { + return result; + } + + llmnrlog_ctx->eve_ctx = ojc; + llmnrlog_ctx->flags = ~0ULL; + + OutputCtx *output_ctx = SCCalloc(1, sizeof(OutputCtx)); + if (unlikely(output_ctx == NULL)) { + SCFree(llmnrlog_ctx); + return result; + } + + output_ctx->data = llmnrlog_ctx; + output_ctx->DeInit = SCLLMNRLogDeInitCtxSub; + + SCLogDebug("LLMNR log sub-module initialized"); + + SCAppLayerParserRegisterLogger(IPPROTO_UDP, ALPROTO_LLMNR); + SCAppLayerParserRegisterLogger(IPPROTO_TCP, ALPROTO_LLMNR); + + result.ctx = output_ctx; + result.ok = true; + return result; +} + +void JsonLLMNRLogRegister(void) +{ + OutputRegisterTxSubModule(LOGGER_JSON_TX, "eve-log", "JsonLLMNRLog", "eve-log.llmnr", + JsonLLMNRLogInitCtxSub, ALPROTO_LLMNR, JsonLLMNRLogger, SCLLMNRLogThreadInit, + SCLLMNRLogThreadDeinit); +} diff --git a/src/output-json-llmnr.h b/src/output-json-llmnr.h new file mode 100644 index 0000000000..fbb0c99533 --- /dev/null +++ b/src/output-json-llmnr.h @@ -0,0 +1,26 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// written by Giuseppe Longo + +#ifndef SURICATA_OUTPUT_JSON_LLMNR_H +#define SURICATA_OUTPUT_JSON_LLMNR_H + +void JsonLLMNRLogRegister(void); +bool AlertJsonLLMNR(void *txptr, SCJsonBuilder *js); + +#endif /* SURICATA_OUTPUT_JSON_LLMNR_H */ diff --git a/src/output.c b/src/output.c index 86821cfb23..f512de031b 100644 --- a/src/output.c +++ b/src/output.c @@ -83,6 +83,7 @@ #include "app-layer-parser.h" #include "output-filestore.h" #include "output-json-arp.h" +#include "output-json-llmnr.h" typedef struct RootLogger_ { OutputLogFunc LogFunc; @@ -948,6 +949,7 @@ void OutputRegisterRootLoggers(void) // ALPROTO_DCERPC special: uses state RegisterSimpleJsonApplayerLogger(ALPROTO_DNS, (EveJsonSimpleTxLogFunc)AlertJsonDns, NULL); RegisterSimpleJsonApplayerLogger(ALPROTO_MDNS, (EveJsonSimpleTxLogFunc)AlertJsonMdns, NULL); + RegisterSimpleJsonApplayerLogger(ALPROTO_LLMNR, (EveJsonSimpleTxLogFunc)AlertJsonLLMNR, NULL); // either need a cast here or in rust for ModbusTransaction, done here RegisterSimpleJsonApplayerLogger(ALPROTO_MODBUS, (EveJsonSimpleTxLogFunc)SCModbusToJson, NULL); RegisterSimpleJsonApplayerLogger(ALPROTO_ENIP, (EveJsonSimpleTxLogFunc)SCEnipLoggerLog, NULL); @@ -1225,6 +1227,8 @@ void OutputRegisterLoggers(void) } /* ARP JSON logger */ JsonArpLogRegister(); + /* LLMNR JSON logger */ + JsonLLMNRLogRegister(); for (size_t i = 0; i < preregistered_loggers_nb; i++) { OutputRegisterTxSubModule(LOGGER_JSON_TX, "eve-log", preregistered_loggers[i].logname, diff --git a/suricata.yaml.in b/suricata.yaml.in index ec1a4afc80..b8d242f8ff 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -345,6 +345,7 @@ outputs: - quic - ldap - pop3 + - llmnr #- modbus - arp: enabled: no # Many events can be logged. Disabled by default