Wazuh 5.0 does threat intelligence in two places, and they are not interchangeable. Indicator matching against the Wazuh CTI feed happens automatically in the manager’s enrichment stage, writing results to wazuh.threat before an event ever reaches the indexer. Everything you bring yourself, a MISP export, an internal blocklist, an allowlist of scanners, goes into a KVDB and is matched by a decoder, not by a rule. That second half is the part that surprises people migrating from 4.x, because CDB list lookups used to live in rules.

What you need

  • A 5.0 deployment with internet access to Wazuh CTI, or the offline settings below if it has none.
  • Enough decoder fluency to add a check stage. Writing a custom Wazuh decoder in YAML covers the syntax.
  • Indexer API access on port 9200 for the Content Manager endpoints.
  • The framework mappings from Wazuh compliance if your indicator work has to produce evidence as well as detections.
  • Somewhere to export indicators from. This lesson uses a MISP export as the worked example, but any source that produces a list of atomic indicators works the same way.

How CTI enrichment works

The Wazuh CTI service supplies three categories of content, each synchronised independently by the Content Manager plugin on the indexer:

Category What it contains Where it lands
Detection content Rules, decoders, integrations, KVDBs, filters, routing policies wazuh-threatintel-rules-a, -decoders-a, -integrations-a, -kvdbs-a, -filters-a, -policies-a
IoCs IP addresses, file hashes, URLs wazuh-threatintel-enrichments-a
Vulnerability intelligence CVE data for the Vulnerability Scanner .wazuh-threatintel-vulnerabilities (hidden)

Note the -a suffix on every one of those index names. It is easy to miss and it breaks any hand-written query or index pattern that leaves it off. Listing them on a 5.0.0-beta5 install returns seven: wazuh-threatintel-decoders-a, -enrichments-a, -filters-a, -integrations-a, -kvdbs-a, -policies-a and -rules-a. They are ordinary indices rather than data streams, so they appear in _cat/indices and not in _data_stream.

The third row is the feed behind Wazuh vulnerability detection and syncs on the same schedule as the rest, which is why a stalled Content Manager shows up as stale CVE data before anyone notices stale indicators.

The IoC half is what this lesson is about. During the manager’s enrichment stage, which runs after decoding and before post-filtering, the engine checks declared event fields against the synchronised indicators. For network indicators the engine builds a connection key, for example source.ip:source.port, and matches that rather than restricting itself to a bare address.

The enrichment types present on the beta5 lab are Geolocation, Connection, URL full, URL domain, and hashes by MD5, SHA1 and SHA256. That is the whole list, and it is worth reading as a constraint rather than a feature list: there is no registry key, no mutex, no JA3, and no free-form indicator type. Anything outside those types has to become a KVDB and a decoder check, which is the second half of this lesson.

On a match, the event gains a wazuh.threat object. The documented example shape:

{
  "wazuh": {
    "threat": {
      "indicator": {
        "type": "ipv4-addr",
        "ip": "203.0.113.10"
      },
      "enrichments": [
        { "matched": { "field": "destination.ip" } }
      ]
    }
  }
}

The object can also carry indicator metadata such as feed or provider and first and last seen, software or campaign information, and provider tags such as “Botnet” or “C2”. Enrichment is additive: an event that matches nothing carries on unchanged.

Geo and ASN enrichment runs in the same stage, populating source.geo and source.as. Both enrichments are declared in the policy as an ordered array, and the enrichment data source is chosen in the settings of the Space.

Check that CTI is actually synchronised

Silent staleness is the failure mode here: nothing errors, the feed just stops moving. The Content Manager keeps its state in the internal .wazuh-cti-consumers index, one document per content category, and the managing Wazuh CTI page documents how to read it.

curl -k -u <USER>:<PASSWORD> "https://<INDEXER_IP>:9200/.wazuh-cti-consumers/_search?pretty"

Each document carries a status of ready, running or failed, plus local_offset and remote_offset. Read them this way:

  • local_offset equals remote_offset: synchronised.
  • local_offset lower than remote_offset: updates are available and pending.
  • local_offset is 0: initial snapshot has not completed.

A category stuck in running, or reporting failed, sends you to /var/log/wazuh-indexer/wazuh-indexer.log for Content Manager errors. Make the offset comparison a monitored check rather than something you run when a detection looks stale.

The manager log is the other half of the picture, and it is more legible. Indicator synchronisation announces itself per type, and the vulnerability feed consumer reports its own progress:

grep -E "IOC::Sync|content-updater" /var/wazuh-manager/logs/wazuh-manager.log

On the beta5 lab stack, a first start produced:

2026/09/23 02:37:56 wazuh-manager-modulesd:content-updater: INFO: IndexerDownloader: Consumer 'cti:catalog:consumer:vulnerabilities' is still running in '.wazuh-cti-consumers'. Waiting 60s before retrying.
2026/09/23 02:38:23 wazuh-manager-analysisd: INFO: [CMSync] Event processing is now active
2026/09/23 02:39:55 wazuh-manager-analysisd: INFO: [IOC::Sync] Changes detected for IOC type 'connection', updating...
2026/09/23 02:39:56 wazuh-manager-modulesd:content-updater: INFO: IndexerDownloader: Consumer 'cti:catalog:consumer:vulnerabilities' in index '.wazuh-cti-consumers' is ready. Starting feed download.
2026/09/23 02:40:04 wazuh-manager-analysisd: INFO: [IOC::Sync] Synchronized IOC type 'connection'

Two things to take from that. [CMSync] Event processing is now active is the line that says the engine has content and is normalising; before it, events are not being processed at all. And indicator synchronisation is reported per type, so a type that never appears in the log is a type you are not matching on.

To force a synchronisation:

curl -k -u <USER>:<PASSWORD> -X POST "https://<INDEXER_IP>:9200/_plugins/_content_manager/update"

A 202 means accepted for asynchronous processing, 409 means one is already running, 403 means on-demand synchronisation is disabled by plugins.content_manager.catalog.update_on_demand.

The synchronisation settings live in /etc/wazuh-indexer/opensearch.yml under the plugins.content_manager prefix and are not present by default. The interval is plugins.content_manager.catalog.sync_interval, default 60 minutes, range 1 to 1440. For an air-gapped deployment, the documented pattern is to run on the snapshot packaged with the indexer and turn the network attempts off:

plugins.content_manager.catalog.update_on_start: false
plugins.content_manager.catalog.update_on_schedule: false
plugins.content_manager.telemetry.enabled: false

Restart the indexer after changing any of these. The packaged snapshot reflects the CTI content shipped with the installed version, so an offline deployment’s indicators age with the release.

KVDBs: what replaced CDB lists

A KVDB is a named dictionary where each key is a string and each value is any JSON value, including nested objects and arrays. It exists so that lookup data can change independently of parsing logic. Structurally:

kvdbs:
  - id: b162e527-c155-43a3-8780-854350880f54
    metadata:
      title: example_kvdb
      description: description of the kvdb
    enabled: true
    content:
      key_1: value_1
      key_2: 123
      key_3: true
      key_4:
        nested_field: nested_value
      key_5:
        - item_1
        - item_2

The KVDB’s name is metadata.title, and that is what helper functions reference. Keys inside content are the lookup keys.

KVDBs are not registered on their own. They belong to an integration, which is the unit the engine pulls from the indexer, and an integration’s kvdbs field lists the KVDB identifiers its decoders use. The engine’s content synchronisation picks the change up on its next cycle.

The documented helper functions:

Helper Use
kvdb_match('<name>') Check stage: does the field value exist as a key
kvdb_not_match('<name>') Check stage: allowlist, does the value not exist as a key
kvdb_get('<name>', $field) Map stage: retrieve the value stored for a key
kvdb_get_array('<name>', ...) Map stage: array lookup
kvdb_get_merge('<name>', $field) Map stage: merge the stored object into the target
kvdb_get_merge_recursive('<name>', ...) Map stage: recursive merge
kvdb_decode_bitmask(...) Map stage: decode a bitmask against a reference table

Load your own indicators

Export the indicators you want from MISP, or from any other platform, as a flat list of atomic values. Then build the KVDB payload and post it to the Content Manager. The API accepts both JSON and YAML with the same envelope: an integration identifier and a resource object.

The companion files for this lesson include a complete example as kvdb-blocked-ips.json, with the matching decoder as decoder-ip-blocklist-misp.yml. The shape:

{
  "integration": "9e301671-382d-4c1a-9abf-3d9d9544789c",
  "resource": {
    "metadata": {
      "title": "known_malicious_ips",
      "author": "Security Operations",
      "description": "Atomic IPv4 indicators exported from the internal MISP instance",
      "references": ["https://www.misp-project.org/"]
    },
    "enabled": true,
    "content": {
      "203.0.113.10": { "source": "misp", "event_id": "1042", "confidence": "high" },
      "198.51.100.7": { "source": "misp", "event_id": "1051", "confidence": "medium" }
    }
  }
}
curl -k -u <USER>:<PASSWORD> -X POST "https://<INDEXER_IP>:9200/_plugins/_content_manager/kvdbs" -H 'Content-Type: application/json' -d @kvdb-blocked-ips.json

The KVDB is created in the draft space and the server generates its UUID. Update it later with PUT /_plugins/_content_manager/kvdbs/{id}, carrying a resource object without the integration field. Note that an update replaces content, so your export job regenerates the whole map rather than appending to it.

Because values are arbitrary JSON, put the provenance in the value: which feed, which event, what confidence, when it was added. That is what makes an indicator removable six months later, and it is what a kvdb_get retrieves when you want the context on the event rather than just the match.

Promote the content through the spaces exactly as you would a rule: draft, then test, then custom. Only custom content is active against live events.

Match on your indicators in a decoder

In 4.x this lookup was a <list> element in a rule. In 5.0 it is a check stage in a decoder, and the pattern comes straight from the CDB to KVDB migration guide:

name: decoder/ip-blocklist-misp/0
id: <replace-with-a-new-uuidv4>
enabled: true
parents:
  - decoder/json/0
metadata:
  title: Source IP present in the MISP blocklist
  description: Matches source IPs found in the known_malicious_ips KVDB.
  author: Security Operations
  date: "2026-09-22"
check:
  - source.ip: kvdb_match('known_malicious_ips')
normalize:
  - map:
      - wazuh.threat.groups: array_append(misp-blocklist)

Three behaviours to hold in mind, all documented in the migration guide:

Siblings are an OR, and only the first match fires. Child decoders under a parent are evaluated in order and the engine follows the first that accepts the event. If an address appears in two sibling blocklist decoders, only one fires. When an entry can legitimately appear in several lists and you need every label, chain the decoders as parent and child instead, which also gives you AND semantics.

A failed normalize block is skipped, not fatal. If a kvdb_get finds no key, the decoder carries on. A failed check, by contrast, rejects the event from that branch.

Allowlists use kvdb_not_match. That is how you express “alert when the user is not in the approved list”, which was not_match_key in 4.x.

Once the decoder writes a field, a Sigma rule can detect on it. Remember that every field named in a rule’s detection block is validated against the Wazuh Common Schema at creation time and a rule referencing an unknown field is rejected with a structured error, so verify the field exists on a real event with the Log test tool before you write the rule against it. Writing custom Wazuh detection rules covers the rule side.

Where to look on the dashboard

Threat intelligence holds Threat Hunting, Vulnerability detection and the MITRE ATT&CK dashboard. Threat Hunting is where you investigate findings; detection engineering with Sigma and ATT&CK covers turning that into coverage measurement.

Endpoint security > Malware detection shows malware events, threat intelligence matches and indicators of compromise detected on endpoints.

Indexer management > Indexes, filtered on wazuh-threatintel, lists the seven CTI content indices so you can confirm content actually arrived. Indexer management > Settings has the Update CTI content button, and Dev tools runs the same API calls without a terminal.

Security Analytics > Normalization > KVDBs lists KVDBs across all four spaces, and creates them through the interface if you would rather not use the API: select the integration, set the title and author, add key and value pairs, create, then promote. On beta5 the Security Analytics application lives at /app/sa-integrations, with Normalization holding Decoders and KVDBs, Detection holding Detectors and Rules, and Log test below them. The space selector carries Draft, Test, Custom and Standard.

Operating an indicator feed

The mechanics above are a day’s work. Keeping the feed useful is the ongoing part, and three rules carry most of the value.

Indicators expire; make that automatic. An IP-based indicator is worth much less three months on than on the day it was published, and a KVDB has no expiry mechanism of its own. Put a date in the value, regenerate the whole content map from your source on a schedule, and drop anything past your chosen age. Because a PUT replaces content entirely, regeneration is the natural operation.

Keep hash and address indicators in separate KVDBs. Different half-lives, different false-positive profiles, different owners. One KVDB per source and type also means you can disable one without touching the rest, since enabled is a per-KVDB field.

Run an allowlist KVDB alongside the blocklists. Your own scanners, your egress addresses, partner ranges. A kvdb_not_match on the allowlist in the same decoder check block removes the largest single source of indicator noise before it becomes a finding, and it documents which exclusions exist. Cutting Wazuh false positives has the wider tuning method.

Common errors

The rule is rejected for an unknown field

Cause: every field referenced in a rule’s detection block is validated against the Wazuh Common Schema at creation, and the engine rejects rules naming fields the schema does not define. The error names the offending field.

Fix: confirm the field is present on a real processed event using Security Analytics > Log test before writing the rule.

Indicators load but never match

Cause: usually one of three things. The content is still in the draft or test space rather than custom. The KVDB name used in the helper does not match metadata.title. Or the key format differs from the field value, for example an indicator stored with a port suffix while the decoder matches a bare source.ip.

Fix: check the space first, then the title, then run a sample event through Log test and read the normalised document.

Some blocklist entries never fire

Cause: CIDR entries in a KVDB. Keys are exact strings, so a /24 is a key that no address will ever equal.

Fix: split the list. Exact addresses stay in the KVDB; subnets become ip_cidr_match entries in the same decoder check block, in the form ip_cidr_match/192.168.0.0/16/$source.ip.

CTI content is out of date and nothing reported an error

Cause: a failed or stalled synchronisation, which does not surface in the interface.

Fix: compare local_offset and remote_offset in .wazuh-cti-consumers, then read /var/log/wazuh-indexer/wazuh-indexer.log. A failed scheduled synchronisation does not block later attempts, so a single failure can pass unnoticed while a persistent one looks identical.

403 from the content update endpoint

Cause: plugins.content_manager.catalog.update_on_demand is set to false, usually as part of an offline configuration.

Fix: intended behaviour in an air-gapped deployment. If the deployment does have connectivity, remove the setting and restart the indexer.

Next: Detection Engineering in Wazuh: Sigma Rules and ATT&CK Coverage.

Files for this lesson

Every file this lesson refers to, exactly as used. Open a file to read it in the browser, or download the set as a zip. Download wazuh-threat-intelligence.zip. The full set for all lessons is at wazuh-tutorial-files.zip.

Frequently asked questions

What replaced CDB lists in Wazuh 5.0?

Key-Value Databases, or KVDBs. A KVDB is a named dictionary where each key is a string and each value is any JSON value. It is bundled inside an integration, stored in the Wazuh indexer and synchronised to the engine automatically. There is no import tool, so each CDB list is converted by hand, and the lookup logic moves from the rule into a decoder.

Can Wazuh 5.0 integrate with MISP?

There is no documented MISP module in the 5.0 beta. The supported pattern is to export indicators from MISP and load them into a KVDB through the Content Manager API, then match on them in a decoder with kvdb_match. The bi-directional lookup integrations from 4.x, such as VirusTotal and Maltiverse, have no 5.x equivalent.

Where do Wazuh CTI threat intelligence matches appear?

Under wazuh.threat on the event, added during the enrichment stage on the manager before the event reaches the indexer. The object can carry indicator metadata such as type and value, campaign or malware information, provider tags, and an enrichments array naming which event field matched.

How often does Wazuh CTI content update?

Every 60 minutes by default, set by plugins.content_manager.catalog.sync_interval, which accepts 1 to 1440 minutes. You can trigger an immediate synchronisation with POST /_plugins/_content_manager/update, which returns 202 when accepted, 409 when a synchronisation is already running and 403 when on-demand synchronisation is disabled.

Can a KVDB match an IP subnet?

No. KVDB keys are exact string matches, so kvdb_match works for individual addresses only. The Wazuh migration guide is explicit that CIDR entries have no KVDB equivalent and must become explicit ip_cidr_match checks in the decoder's check stage. A list mixing exact addresses and subnets has to be split into both forms.