Malicious Conflux Endpoint Can Leave Stale Global OOO Queue Accounting After Teardown
Reported via [HackerOne](https://hackerone.com/reports/3701692)
---
## 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:
```c
total_ooo_q_bytes += cost;
cfx->ooo_q_alloc_cost += cost;
```
The normal dequeue path subtracts the same cost:
```c
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:
```c
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()`:
```c
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.sh`
- `poc-files/attacker-relay.diff`
- `poc-files/chutney-network`
Build and run the proof:
```sh
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:
```sh
cd ~/conflux-ooo-oom-check/tor-clean
git checkout a66e072d7f3752fe98d4e59129b35b0942d4399b
./autogen.sh
./configure --disable-asciidoc
make -j"$(nproc)"
```
Build the attacker-exit binary:
```sh
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:
```sh
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`:
```sh
# Measurement only. This does not change Tor behavior or the attack path.
sudo sysctl -w kernel.yama.ptrace_scope=0
```
Run the proof script:
```sh
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:
```text
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:
```text
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.
```c
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_()`:
```c
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.
issue