JoinMarket Makers Are Under Active DoS Attack — Here's the FixJoinMarket Makers Are Under Active DoS Attack — Here's the Fix
Someone is systematically knocking JoinMarket makers offline. The attack floods your onion messaging daemon with junk connections until RAM spirals, log files eat your disk, and the yield generator crashes. Your offers disappear from the orderbook. Every maker that goes dark improves the attacker's selection odds as a taker — this is a direct economic attack on the coinjoin liquidity pool.
A community patch is working. It's not merged yet (PR #1840) but makers running it have stayed stable for 12+ hours under active attack. This guide covers every common setup — bare metal, Docker, node packages, directory nodes.
Don't trust, verify. The patch is short. Read it before running anything: b391a29
The Attack and Why It WorksThe Attack and Why It Works
JoinMarket makers listen for connections over the onion messaging layer (onionmc.py). Before this patch, every inbound connection — legitimate or not — triggered thread spawning, memory allocation, and cryptographic handshake work. The attacker's cost to send connections is effectively zero. Your cost to handle them was unbounded.
The patch fixes the asymmetry by adding a rolling-window rate limiter that fires before any expensive work starts. 5 connections from the same IP within 10 seconds and you're dropped. No threads, no memory, no crypto. The attack still hits — you'll see the rate limit log lines firing — but it no longer accumulates damage.
Two things are required together. The patch controls RAM and CPU. Log rotation controls disk. Miss either one and you'll still crash, just slower.
Step 0 — Know Your SetupStep 0 — Know Your Setup
| Setup | Examples |
| Bare metal / virtualenv | Manual install, RaspiBlitz native, dedicated server |
| Docker-based node | Umbrel, MyNode, RaspiBlitz docker mode, Start9 |
| Directory Node | Running directory_node.py — higher-value target, apply urgently |
Step 1 — Find onionmc.pyStep 1 — Find onionmc.py
Bare metal:
find / -type f -name onionmc.py 2>/dev/null
Common paths:
~/joinmarket-clientserver/src/jmdaemon/onionmc.py/opt/joinmarket/src/jmdaemon/onionmc.py
Docker:
# Find your container name first
sudo docker ps | grep -E "jam|joinmarket"
# Then locate the file inside it
sudo docker exec -it <container_name> find / -type f -name onionmc.py 2>/dev/null
Common container paths:
/src/src/jmdaemon/onionmc.py— JAM on Umbrel/src/jmdaemon/onionmc.py— other Docker builds
Note the full path. You'll need it in Step 2.
Step 2 — Apply the PatchStep 2 — Apply the Patch
Bare metal / virtualenvBare metal / virtualenv
# Download the patch
curl -sL https://github.com/m0wer/joinmarket-clientserver/commit/b391a29e5f3c28e93fc8e80bb261830adbb7ed86.patch \
-o /tmp/onionmc.patch
# Auto-locate repo root from onionmc.py and patch in place
FILE=$(find / -type f -name onionmc.py 2>/dev/null | head -n 1) \
&& DIR=$(dirname $(dirname $(dirname "$FILE"))) \
&& echo "Patching in: $DIR" \
&& cd "$DIR" \
&& patch -p1 -i /tmp/onionmc.patch \
&& echo "Success"
Docker-based nodes (Umbrel, MyNode, etc.)Docker-based nodes (Umbrel, MyNode, etc.)
Shell into the container and run the same command from inside:
# Enter the container
sudo docker exec -it <container_name> bash
# Run inside the container
curl -sL https://github.com/m0wer/joinmarket-clientserver/commit/b391a29e5f3c28e93fc8e80bb261830adbb7ed86.patch \
-o /tmp/onionmc.patch \
&& FILE=$(find / -type f -name onionmc.py 2>/dev/null | head -n 1) \
&& DIR=$(dirname $(dirname $(dirname "$FILE"))) \
&& echo "Patching in: $DIR" \
&& cd "$DIR" \
&& patch -p1 -i /tmp/onionmc.patch \
&& echo "Success"
# Exit the container when done
exit
If auto-find fails inside Docker, do it manually on the host:
# Pull the file out of the container
sudo docker cp <container_name>:/src/src/jmdaemon/onionmc.py ~/onionmc.py
# Patch it on the host
cd ~ \
&& curl -sL https://github.com/m0wer/joinmarket-clientserver/commit/b391a29e5f3c28e93fc8e80bb261830adbb7ed86.patch \
-o /tmp/onionmc.patch \
&& patch -p1 --strip=3 -i /tmp/onionmc.patch onionmc.py \
&& echo "Success"
# Push it back
sudo docker cp ~/onionmc.py <container_name>:/src/src/jmdaemon/onionmc.py
Adjust /src/src/jmdaemon/... to the path find returned for your container in Step 1.
Directory NodesDirectory Nodes
Same procedure as bare metal. Directory nodes receive more connections than regular makers — they're a better target for this attack. Patch first, ask questions later.
Step 3 — What the Patch Does (Read This)Step 3 — What the Patch Does (Read This)
The diff adds a rolling-window rate limiter to the inbound connection handler in onionmc.py. Here's the logic with commentary:
# Two constants added at class level:
RATE_LIMIT_WINDOW = 10 # rolling window in seconds
RATE_LIMIT_MAX = 5 # max connections from one IP within that window
# A dict added to __init__ to track per-IP connection timestamps:
# self.connection_attempts = {}
# Structure: { "ip_address": [t1, t2, t3, ...] }
# New method called at the top of the inbound connection handler,
# before any threads are spawned or crypto work starts:
# def _check_rate_limit(self, peer_address):
# now = time.time()
# ip = peer_address.split(":")[0] # isolate IP, drop port
#
# attempts = self.connection_attempts.get(ip, [])
#
# # Expire entries outside the rolling window — only last 10s matter
# attempts = [t for t in attempts if now - t < RATE_LIMIT_WINDOW]
#
# if len(attempts) >= RATE_LIMIT_MAX:
# # 5+ connections in 10s from this IP:
# # log it, return False — caller drops the connection immediately.
# # No thread, no memory allocation, no handshake.
# log.info(f"Rate limit exceeded by peer {peer_address}, dropping connection.")
# return False
#
# # Under limit: record timestamp and allow connection
# attempts.append(now)
# self.connection_attempts[ip] = attempts
# return True
The attack relies on the fact that handling a connection is expensive and you had no mechanism to refuse bulk callers early. The rate limiter inverts the economics: the attacker keeps paying to send, you stop paying to receive. The copytruncate-safe log entry lets you monitor it without generating expensive output per-packet.
Step 4 — Set Up Log RotationStep 4 — Set Up Log Rotation
Not optional. The patch controls RAM and CPU. It does nothing about log output volume. Under active attack, logs still grow fast enough to fill your disk and kill the process. This is what took down setups that had the patch but not log rotation.
Bare metal:Bare metal:
sudo nano /etc/logrotate.d/joinmarket
/home/<youruser>/joinmarket-clientserver/logs/*.log {
daily # rotate once per day
rotate 7 # keep 7 days, delete older
compress # gzip rotated files
missingok # no error if log doesn't exist yet
notifempty # skip if log is empty
copytruncate # truncate in place — safe for running processes
}
# Test it
sudo logrotate --debug /etc/logrotate.d/joinmarket
Docker / Umbrel:Docker / Umbrel:
Logs live on the host, mounted into the container. Find the mount point:
sudo docker inspect <container_name> | grep -A5 '"Mounts"'
On Umbrel the logs are typically at ~/umbrel/app-data/jam/data/joinmarket/logs/. Create the config:
sudo nano /etc/logrotate.d/jam
/home/umbrel/umbrel/app-data/jam/data/joinmarket/logs/*.log {
daily
rotate 7
compress
missingok
notifempty
copytruncate
}
# Test it
sudo logrotate --debug /etc/logrotate.d/jam
Step 5 — Restart the Yield GeneratorStep 5 — Restart the Yield Generator
⚠️ Docker users: do NOT restart from your node dashboard⚠️ Docker users: do NOT restart from your node dashboard
Restarting JAM from the Umbrel (or MyNode, Start9) dashboard rebuilds the container from its original image and wipes your patched onionmc.py. If you need a full restart for any reason, re-apply the patch afterward. Start Earn from the JAM web UI, not from the dashboard.
Bare metal:
# If running in screen or tmux, reattach and restart normally
screen -r joinmarket
# Stop the running instance with Ctrl+C, then restart:
python yield-generator-basic.py ~/.joinmarket/wallets/your-wallet.jmdat
Docker:
Stop the yield generator from within the JAM Earn tab. Start it again from the same UI after patching — don't touch the dashboard.
Step 6 — Verify It's WorkingStep 6 — Verify It's Working
Rate limit lines in the logRate limit lines in the log
# Bare metal
tail -f ~/joinmarket-clientserver/logs/joinmarket*.log | grep -i "rate limit"
# Docker
sudo docker logs -f <container_name> 2>&1 | grep -i "rate limit"
Under active attack you'll see these firing in bursts:
[INFO ] Rate limit exceeded by peer 127.0.0.1:49586, dropping connection.
That's the patch working. The attack is still incoming — you're just not paying for it anymore.
RAM is stableRAM is stable
# Bare metal — watch the JM process every 2 seconds
watch -n 2 'ps aux | grep -E "yield-generator|joinmarket" | grep -v grep'
# Docker
watch -n 2 'sudo docker stats <container_name> --no-stream'
Before patch: RAM climbs continuously under attack. After: stable at roughly 300–320 MB even with rate limit lines firing constantly.
Log files are no longer runawayLog files are no longer runaway
# Bare metal
watch -n 30 'du -sh ~/joinmarket-clientserver/logs/*'
# Umbrel / Docker
watch -n 30 'du -sh ~/umbrel/app-data/jam/data/joinmarket/logs/*'
Growth should be slow and bounded. Logrotate handles the rest daily.
CPU is not peggedCPU is not pegged
htop
Before patch: Python pegged under attack as it processes every flooded connection. After: near-idle between legitimate order activity.
Your offers are still on the orderbookYour offers are still on the orderbook
Check a JoinMarket orderbook explorer and confirm your nick is listed. If you were crashing every few hours before and you're still up after 6–12 hours, the fix is holding.
CaveatsCaveats
- Temporary. Community patch, not yet merged. Watch PR #1840 and review it if you can.
- Wiped on container rebuild. Any full node dashboard restart will reset the patched file. Re-apply the patch before restarting Earn.
- Logrotate is required alongside the patch. One without the other is not enough.
- Directory node operators are higher-value targets. Apply this immediately and consider whether your connection limits upstream need tuning as well.
Every maker offline makes the coinjoin market worse for everyone — less liquidity, less privacy, less competition on fees. The attacker is winning sats while your offers sit dark. Get the patch running, signal-boost this to other makers, and let's keep the orderbook full while the maintainers get the PR merged.
Wow this is great work. I tried Jam for a few transactions and the fees were ridiculous.
Hopefully this attack stops soon
no one said privacy is free, nor cheap ;)
great work
It seems like python code need to electrum
This is a direct hit on the privacy ecosystem, but it’s great to see the community move this fast on a patch
This is awesome! Thanks.
I want to make money
this is the kind of attack where cheap failure beats expensive work. rate-limit early, rotate logs, and keep the maker box boring. same lesson on small infra too, especially when links are flaky and disks are tiny.