llmnr: implement logger

This adds an LLMNR protocol logger that reuses existing DNS functions,
following the same approach as the mDNS logger:

- No grouped logging
- Rdata is logged in a field that is named after the rdata type
- Types are logged in lower case
- Flags are logged as an array

Ticket #8366
pull/15509/head
Giuseppe Longo 2 years ago committed by Victor Julien
parent 13df0f7a06
commit 8f205bb34f

@ -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"

@ -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 <giuseppe@glongo.it>
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<JsonBuilder, JsonError> {
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()
}

@ -20,3 +20,4 @@
// written by Giuseppe Longo <giuseppe@glongo.it>
pub mod llmnr;
pub mod log;

@ -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 \

@ -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 <giuseppe@glongo.it>
#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);
}

@ -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 <giuseppe@glongo.it>
#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 */

@ -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,

@ -345,6 +345,7 @@ outputs:
- quic
- ldap
- pop3
- llmnr
#- modbus
- arp:
enabled: no # Many events can be logged. Disabled by default

Loading…
Cancel
Save