Second-pass full audit with nearly zero effort from me:
MiniMax M3
Qubes-Snitch — Bug & Security Audit
I read the forum post, the whole qubes_snitch/ package, the systemd units, the fail-closed nft, the install scripts, and the Qubes qubes-antispoof.nft for context. Below is the prioritized list. I deliberately kept it short — every item is a real issue I can point to a file/line for, with a fix sketch.
High severity
H1. The dom0 qubes.SnitchSources qrexec service returns the full Qubes inventory
File: install-dom0.sh — the qrexec handler at /etc/qubes-rpc/qubes.SnitchSources is just:
qvm-ls --raw-data --fields NAME,IP,LABEL,CLASS,TEMPLATE | while ... ; printf '%s|%s|%s|%s|%s\n' ...
The only gate between dom0 and sys-snitch is /etc/qubes/policy.d/30-qubes-snitch.policy. A typo in that policy (sys-* instead of sys-snitch, or + @anyvm @anyvm ask) leaks every Qube’s name, IPv4, label, class, and template to the calling VM. The qrexec service definition itself does no filtering.
Bonus issue: the dom0 install-dom0.sh script that I fetched is truncated at the rpc-policy heredoc (sudo tee "$RPC_POLICY" >/dev/null <</ with nothing after). The actual policy file is missing from the installer as it stands. That is itself a bug — set -eux would exit 1 on the << syntax error, so the install would fail with a confusing message. Please re-check the file in the repo.
Fix: in the qrexec handler, filter rows to VMs whose netvm is sys-snitch (or whose qrexec-policy says they are downstream of it). That makes the service self-protective even if the policy file is wrong.
H2. conntrack is not flushed on in-place nft -f reloads — only on full daemon restarts
Files: nft.py (load_nft), templates/etc/systemd/system/qubes-snitchd.service (ExecStartPre=/usr/sbin/conntrack -F)
load_nft is called from the CLI path on every allow/reject decision (policy_runtime.append_rule → load_nft). It does nft -c -f /run/qubes-snitch/rules.nft; nft -f /run/qubes-snitch/rules.nft. It does not flush conntrack.
Consequence: a user who previously had
{ dest: any, proto: tcp, port: "443", action: allow }
and then changes it to action: reject and saves via the CLI, sees the new nft rules loaded but the old conntrack entry still allows the reply to flow via the bottom-of-chain rule:
ip daddr <vm_ip> ct state established,related ct direction reply accept
TCP established timeout is ~5 days. The user’s stated workflow is “manually edit YAML + systemctl restart qubes-snitchd.service” — that works, because the systemd unit flushes conntrack. But the README’s implicit promise that “rules are persistent” is partially false: rules changed via the CLI prompt path leave a conntrack backdoor open.
Fix: load_nft should also run conntrack -F (or, more surgically, -D -s <dest> and -D -d <dest> for the affected addresses). Note: conntrack is also already flushed on every daemon restart, so this only matters for the in-place reload path.
H3. A queued transport reject uses packet.drop(), not a real nft reject, and the response is still delivered
File: packet_handlers.py (handle_dns_transport):
apply_flow_verdict(packet, "reject") # which calls packet.drop()
The README claims “Known nftables reject rules use a real kernel reject (reject with icmpx admin-prohibited)” — that is true only for saved nft-rendered reject rules, not for queued prompt rejects. The packet is dropped, but if the resolver reply races in (it was already on the wire), the conntrack entry is created by the reply alone, and the bottom-of-chain accept rule lets the reply through. The client sees a UDP timeout, but the response did arrive.
Fix: for queued transport rejects, render an actual nft reject with icmpx admin-prohibited instead of queueing. Or document explicitly that queued rejects are silent drops.
H4. nft_source_jump depends on Qubes’ qubes-antispoof.nft for IP spoofing protection
File: nft.py (nft_source_jump) — the long comment correctly explains that the antispoof lives in table ip qubes and is enforced by Qubes. I verified by reading qubes-antispoof.nft: the antispoof is at priority raw in the prerouting hook, before Snitch’s priority filter forward chain. So the design is correct.
But: the dependency is implicit. If a user removes the qubes-firewall service (e.g. to “fix” something), or if a future Qubes update changes the antispoof implementation, Snitch silently loses IP-source validation. The nft_source_jump should at minimum log a warning at startup if no table ip qubes is loaded, or render the iifname constraint itself when dom0 provides a vif ↔ ip mapping.
Bonus observation: Snitch’s forward chain is in table inet qubes_snitch (so it sees both IPv4 and IPv6); Qubes’ antispoof is in table ip qubes (IPv4 only). For IPv6 traffic — which Snitch rejects anyway via meta nfproto ipv4 filters — the antispoof also covers it because Qubes’ table ip6 qubes mirrors the IPv4 one. The iifname . ipv6_addr check works. Good.
H5. dns_reject_payload sends a synthetic reply with identification=0 and no DF flag
File: dns.py (dns_reject_payload)
The IP header is built fresh with identification=0, flags=0. The client’s kernel does not require IP ID matching for UDP DNS, but hardened resolvers (systemd-resolved in strict mode, some embedded resolvers) may drop the reply because it doesn’t correlate with the query. The client retries, hits the same code path, eventually times out. This is a usability bug, not a security bug — but combined with H3 it means rejected DNS domains experience a slow client-side timeout rather than fast failure.
Fix: set identification=request.get("ip_id", 0) if you want to mimic the query, or just accept that the synthetic reply is a “fresh” packet and rely on client retry. Either way, document it.
Medium severity
M1. notify_prompt runs in the NFQUEUE packet thread; a broken desktop kills the firewall
File: alerts_runtime.py (notify_prompt), packet_handlers.py (queue_prompt)
queue.queue_question calls notify_queued(prompt_request) from inside the NFQUEUE thread (after releasing the queue lock, but still in the packet callback). notify_prompt calls notify.alert_notify which is subprocess.run([..., "notify-send", ...], check=True, timeout=1). If xfce4-notifyd is dead or the user is not logged in, this raises OSError/TimeoutExpired, which notify_prompt catches and forwards to fail_daemon → os._exit(1). A broken desktop kills the firewall, leaving only the fail-closed nft table.
For security alerts, dying on broken notify-send is correct. For prompt notifications, swallowing the error and continuing is better.
Fix: in notify_prompt, only call fail_daemon for security_alert, not for routine queue_prompt notifications.
M2. validate_qubes_vm_name for the template of a DispVM uses vm_class="TemplateVM" and rejects any name containing disp
File: config.py (dispvm_policy_source)
validate_qubes_vm_name(template, "TemplateVM", "DispVM template")
A user with a TemplateVM literally named disp-base (perhaps as a DisposableVM base) cannot use it — dispvm-disp-base.yml would never load. The check matches the README’s “Do not put disp anywhere in a non-DispVM name” rule, but applied to the template field it is too strict. Use the base template’s own vm_class, or relax the check for template only.
M3. Generic default-DispVM rows are silently dropped without warning
File: config.py (dispvm_policy_source, parse_sources_output)
If a user renames the Qubes default-dvm template to my-default-dvm, every child of it that has a numbered name (disp1234) is processed normally, but every non-numbered row of a child is dropped via return None with no syslog warning. The daemon may end up with fewer sources than the user expects and not say anything. The config.yml field default_disposable_vm_name is read at startup, but the discrepancy only manifests when the qrexec handler returns a row whose template is default-dvm (not my-default-dvm).
Fix: parse_sources_output should log a syslog warning for each dropped generic-DispVM row.
M4. The CLI socket is group-readable by user; a compromised user can flood the daemon with DNS work
File: daemon_runtime.py (open_socket), queue.py (handle_cli_connection)
open_socket does os.chown(SOCKET_FILE, 0, grp.getgrnam("user").gr_gid); SOCKET_FILE.chmod(0o660). Any process running as user can connect. The CLI’s decision is correctly rejected by save_pending_decision if the prompt_id doesn’t match a queued entry, but enrich_request is called before the check, and it calls lazy_dns_name which spawns up to dns_cache_refresh_workers parallel dns.resolver.resolve calls. A compromised user can do unbounded resolver work.
Fix: in enrich_request, verify request["source"] in ctx.SOURCES_BY_NAME before any DNS work.
M5. 0.0.0.0/0 destinations are silently accepted as ip daddr 0.0.0.0/0
File: config.py (validate_rule_file), nft.py (nft_value)
A user writing dest: 0.0.0.0/0 (perhaps out of habit from iptables) gets the broadest possible allow without warning. The README only documents any. The strict=False mode of ipaddress.ip_network happily accepts the non-canonical form.
Fix: reject 0.0.0.0/0 and require the user to write any.
M6. The synthetic DNS REFUSED is sent as a new IPv4 packet via a raw socket
File: dns.py (send_dns_refused)
The reply has IP_HDRINCL=1 and is sent to (request["src"], 0) (port 0). The 0 means “let the kernel pick the source port” — but IP_HDRINCL means the kernel does not pick a source port, it just sends. The actual source port is encoded in the UDP header that Snitch built. So this works. But the destination of sendto is the client IP, and the kernel routes the raw packet out the same vif it came in on (because Snitch does not explicitly set SO_BINDTODEVICE). On a multi-vif NetVM, the kernel’s routing table should send the reply back to the same vif that the query came from (reverse-path filtering would normally enforce this, but with IP_HDRINCL and no saddr trickery it should be fine). This is mostly OK, but the (dst, 0) form is a code smell.
Fix: use (dst, request["sport"]) explicitly.
M7. The nft rendered dest: any reply rule matches any source IP for replies
File: nft.py (render_established_reply_rule)
The reply_source parameter is declared but never set anywhere in the codebase. So render_established_reply_rule falls into the elif dest: branch and uses ip saddr {dest}. For dest: any, the rendered rule is:
ip saddr any ip daddr <vm> ... ct state established,related ct direction reply accept
Combined with H2, this means an old dest: any port: 443 allow rule, even after being changed to reject, lets replies flow until conntrack times out. H2 is the conntrack issue, but the dest: any reply rule is what makes the conntrack-bypass exploitable: it accepts replies from any IP. For specific dest: rules, only the right reply IP matches.
Fix: when dest: any, build the reply rule with the actual source IP that was allowed. For the current model, you don’t have a single source IP for dest: any rules; one approach is to not render a reply rule for dest: any and rely on conntrack’s normal 5-tuple matching, or to render a per-IP reply rule per dest: any rule by enumerating the IPs of the destination set. Practical fix: don’t render reply rules for dest: any and add an explicit ct status dnat check to prevent stale entries.
M8. append_flow_rule for proto: tcp with dport == 0 raises SystemExit but for proto: udp and malformed dport it relies on parse_packet to have marked the packet as malformed first
File: policy.py (append_flow_rule)
if request["proto"] in ("tcp", "udp") and not request["dport"]:
raise SystemExit(f"refusing to persist malformed {request['proto']} flow without destination port")
The parse_packet already marks dport == 0 as malformed, so this branch is dead code in normal flow. The CLI cannot reach it because queue_prompt rejects malformed packets (request.get("malformed") short-circuits the queue). So append_rule is only called with a clean request. The check is defensive and good — but the SystemExit (rather than raise ValueError) is harsh: a single misformatted request from the CLI kills the daemon. That is the documented fail-closed behaviour. Fine.
Low / informational
L1. safe_text redundantly checks for \r\n\t and \x1b
The unicodedata.category(char).startswith("C") check already covers all C0/C1 controls including \r, \n, \t, and \x1b. The explicit checks are harmless but duplicative.
L2. display.py truncation uses … (U+2026)
The category is Po (Punctuation, other), which is not C*, so it passes the filter. The compact string is then passed to color_cell which pads with spaces outside the ANSI sequence. No terminal escape can leak. Safe.
L3. PENDING_QUESTIONS is a module global in queue.py
In production this is fine (single process). Tests need to clear it explicitly — and the test suite does.
L4. The fail-closed nftables table has no queue num 50
Correct — there is no Python listener. But load_nft (in Python) starts its rendered table with destroy table inet qubes_snitch. If nft -c -f passes but nft -f fails transiently (e.g. nft daemon crash), the table is destroyed and not replaced. All forward traffic drops. Better than allow-all, worth knowing.
L5. The CLI’s acquire_cli_lock mkdir fails with PermissionError if /run/qubes-snitch does not exist
The error message is “qubes-snitchd is not running”, which is misleading when the real cause is “directory missing”. Cosmetic.
L6. The CLI’s read_key does not catch KeyboardInterrupt
If the user presses Ctrl-C, the terminal is left in cbreak mode. Usability bug, not security.
L7. install.sh does not pin Python module versions
The README acknowledges this. Not a bug.
L8. install-dom0.sh clones the latest main of the GitHub repo
The README acknowledges this. Not a bug, but: a malicious commit to main would be picked up on a re-install (the dom0 script refuses to run if tpl-qubes-snitch or sys-snitch already exist, so a user would have to delete the VM to re-install). Consider adding a commit-pin option.
Things that are NOT bugs (verified)
- IP spoofing protection: Qubes’
qubes-antispoof.nft runs at priority raw in prerouting, before Snitch’s priority filter forward chain. The iifname . ip saddr @allowed check is enforced before Snitch sees the packet. A VM cannot send packets with a forged source IP.
- Chain name collisions:
nft_chain_name uses hashlib.blake2s(source.encode("utf-8"), digest_size=4).hexdigest() to prevent app-a / app_a / app.a from colliding after the isalnum -> _ sanitization.
- YAML rule injection:
validate_rule_file requires exactly ptr, dest, proto, port, action for flow rules and qname, qtype, action for DNS rules. Action must be allow or reject. Numeric proto strings are rejected. UniqueKeyLoader rejects duplicate keys.
- Synthetic DNS REFUSED checksum:
inet_checksum correctly handles odd-length buffers (pads with a zero byte) and folds 16-bit carries per RFC 1071.
- Protocol-99 / GRE / ESP:
parse_packet marks unsupported L4 protocols as malformed, triggering log_malformed_packet → security_alert (logged, dropped, never prompted).
- Source port 0:
parse_packet marks tcp/udp source port 0 as malformed, dropped before it can become policy.
- Bad IPv4 IHL / UDP length / TCP data offset: All caught by
parse_packet as malformed.
- IPv4 fragments: Marked as
ipv4 fragments unsupported, dropped (Snitch does not reassemble).
- DNS qtype whitelist:
SUPPORTED_DNS_QTYPES is a fixed set; anything else is unsupported-qtype, refused without prompt.
- qname validation:
LIVE_DNS_QNAME_RE rejects single-label, overlong, punycode/IDN, escaped, wildcard, root, and IPv6-reverse names for live DNS questions.
- Conntrack-flush on daemon restart:
ExecStartPre=/usr/sbin/conntrack -F works. The H2 issue is only on the in-place load_nft path.
Top three things I’d fix first
- H1 — the qrexec service file is the wrong shape: it should self-filter rather than relying entirely on the dom0 rpc policy. Also please verify the truncated
30-qubes-snitch.policy heredoc in install-dom0.sh.
- H2 —
load_nft should flush conntrack (or, better, the affected conntrack entries) on every in-place reload. Without this, the conntrack-bypass window is up to 5 days for TCP.
- H3 — for queued transport rejects, render an actual nft
reject with icmpx admin-prohibited rather than relying on packet.drop(), so the response is not silently accepted by the conntrack reply rule.
Happy to dig deeper into any of these — for example, I can write a concrete patch for H2 if you want. The conversation is also open: per your message, you can revoke the “treat the forum post as source of truth” assumption at any time, and we can critically think about the design itself. For instance, the nft_source_jump ip saddr design is something I’d want to challenge: even though the antispoof is enforced by Qubes, layering iifname into Snitch’s own rules would make the daemon’s contract self-contained.
High-level repository summary after source of truth revocation, preparing for adversarial analysis, various potential attack vectors listed at the bottom of the response (manually flagged for exploratory audit/review):
MiniMax M3
Revoked. Here’s a high-level summary of the repository as code, not as the maintainer’s narrative.
Qubes-Snitch — Repository Summary (code-level)
What it is
A firewall management daemon for a Qubes OS NetVM (typically named sys-snitch). It runs alongside Qubes’ own qubes-firewall service, intercepts forwarded traffic via nftables + NFQUEUE, prompts the user via a terminal UI for unknown connections, and persists allow/reject decisions in per-VM YAML files.
Repository layout
qubes-snitch/
├── qubes_snitch/ # Python package
│ ├── daemon_runtime.py # main() + CLI server thread + signal handling
│ ├── packet_handlers.py # NFQUEUE callback dispatch (DNS vs flow)
│ ├── packets.py # IPv4/TCP/UDP/ICMP packet parser
│ ├── dns.py # DNS message validation + synthetic REFUSED builder
│ ├── dns_cache_runtime.py # RAM-only DNS A/CNAME display hint cache
│ ├── policy.py # in-memory rule matching
│ ├── policy_runtime.py # YAML load/save, nft reload orchestration
│ ├── nft.py # renders YAML rules → nftables syntax
│ ├── sources_runtime.py # dom0 qrexec + QubesDB watcher
│ ├── queue.py # pending-prompt queue + CLI socket protocol
│ ├── alerts_runtime.py # syslog + notify-send (token-bucket rate-limited)
│ ├── notify.py # runuser + notify-send wrapper
│ ├── display.py # safe_text() for terminal/syslog escaping
│ ├── ui.py # terminal prompt line renderer
│ ├── config.py # YAML config + rule validation
│ └── paths.py # /etc/qubes-snitch, /rw/usrlocal, /run/qubes-snitch
├── qubes-snitchd.py # daemon entrypoint (imports daemon_runtime.main)
├── qubes-snitch.py # CLI entrypoint
├── install.sh # template-internal installer (apt + files)
├── install-dom0.sh # dom0 installer (qvm-template, qvm-clone, qrexec)
├── templates/
│ ├── etc/qubes-snitch/config.yml
│ ├── etc/systemd/system/qubes-snitchd.service
│ ├── etc/systemd/system/qubes-snitch-fail-closed.service
│ └── usr/lib/qubes-snitch/{fail-closed.nft, stop-post.sh}
└── tests/ # unit tests with fake dnspython/netfilterqueue
Trust and execution model
Two entrypoints, both running in sys-snitch:
qubes-snitchd runs as root (needs CAP_NET_ADMIN, CAP_NET_RAW, owns /run/qubes-snitch, calls nft). Started by systemd. Loads nftables rules, binds NFQUEUE, opens a Unix socket, watches QubesDB.
qubes-snitch runs as user. Connects to the daemon’s Unix socket, renders one prompt per connection, reads a single keypress, returns the decision.
Communication paths:
- Packet in → nft → NFQUEUE → Python daemon → verdict (accept/drop).
- User keypress → CLI → Unix socket → daemon → YAML write + nft reload.
- Daemon →
qrexec-client-vm dom0 qubes.SnitchSources → returns qvm-ls rows.
- Daemon → QubesDB watch on
/connected-ips and /qubes-firewall/.
Data model
Three global dicts in daemon_runtime:
SOURCES_BY_NAME — VM name → list of IPs (multi-IP VMs allowed; disposable VMs collapse to a dispvm-<base>.yml filename; numbered DispVMs stay per-instance disp1234.yml).
SOURCES_BY_IP — IP → VM name (one-to-one; duplicates fatal).
RULES — VM name → {"ip": [flow rules...], "dns": [dns rules...]}.
Plus DNS_RESPONSE_CACHE and DNS_QNAME_CACHE (RAM-only display hints keyed by (source, ip) or (source, qtype, qname) with TTL).
YAML on disk is the durable policy. In-memory copies are rebuilt on load_policy_without_sources at startup. A CLI decision writes the new YAML, then atomic-replaces, then publishes the in-memory copy, then calls load_nft to re-render and reload nftables. No conntrack -F on the in-place reload path (only on full daemon restart).
Packet flow (the hot path)
NetfilterQueue.bind(50, handle_packet) invokes packet_handlers.handle_packet.
packets.parse_packet(payload) extracts {src, dst, proto, sport, dport, body}. Marks malformed (bad IHL, length mismatch, reserved flag, fragments, port 0, unsupported L4 protocol) — malformed packets are dropped, not prompted.
add_runtime_request_fields looks up src in SOURCES_BY_IP. If unknown, forces a refresh_sources_and_nft(force=True). Still unknown → fatal_security_alert → daemon dies, systemd keeps fail-closed table.
- If
proto == udp and dport == 53: handle_dns_transport → resolver transport policy → handle_dns_domain → qname/qtype policy. DNS questions are parsed by dnspython, must be one-question IN/QUERY, no other sections, EDNS ≤ 1 OPT, qtype in a 16-entry whitelist, qname matches a strict regex (rejects IDN, single-label, escaped, IPv6-reverse, wildcard, root, overlong). AAAA is REFUSED without prompt. New DNS questions: queue prompt, packet.drop() (no synthetic REFUSED for pending). Rejected: build synthetic REFUSED via raw socket, packet.drop().
- Otherwise:
handle_flow_packet → check RULES[source]["ip"] first match wins → if no rule, queue_prompt → if no queued decision, packet.drop().
nftables structure
One table inet qubes_snitch:
chain input (priority filter, policy accept) — drops iifname "vif*" fib daddr type local to prevent client VMs from talking to local NetVM services.
chain forward (priority filter, policy drop) — in order:
- Per-(source, ip) reply rules (matched on
ip saddr {dest} ip daddr {vm} ... ct state established,related ct direction reply accept).
- Per-(source, ip) DNS jump rules (
ip saddr {ip} udp dport 53 jump source_<name>_<hash>).
- Per-(source, ip) generic jump rules (
ip saddr {ip} jump source_<name>_<hash>).
- Per-vm-ip reply accept (
ip daddr {ip} ct state established,related ct direction reply accept).
jump unknown.
ct state invalid reject with logging.
- Per-source chain: invalid reject →
udp dport 53 queue num 50 → saved flow rules → queue num 50 → final reject with logging.
unknown chain: meta nfproto ipv4 queue num 50 → reject with logging.
Fail-closed (templates/usr/lib/qubes-snitch/fail-closed.nft) is loaded by systemd ExecStartPre before the Python daemon and by ExecStopPost after it stops. It has no queue num 50 and no per-source chains.
Source identity
- qrexec handler in dom0 (
/etc/qubes-rpc/qubes.SnitchSources) runs qvm-ls --raw-data and returns one line per VM with name|ip|label|class|template. Dom0 rpc policy gates who can call.
- Daemon’s
parse_sources_output maps IPs to policy-source names. Generic default-DispVM rows are silently dropped. Numbered DispVMs get per-instance rule files (deleted on daemon restart). Purpose-specific DispVMs (with a non-default-dvm template) share one dispvm-<template>.yml.
- QubesDB watcher on
/connected-ips and /qubes-firewall/ triggers refresh_sources_and_nft(force=True) on every change. Rate-limited to 2 seconds between calls.
Config and validation
config.yml is strict: exact key set, exact value ranges, exact prompt_protocol_colors shape, prompt_column_widths bounded, default_disposable_vm_name validated against a regex, limit_rate and burst parsed as nft-style. Any deviation → SystemExit.
Rule files: rules4 (list of {ptr, dest, proto, port, action}) and dns (list of {qname, qtype, action}). Actions are strictly allow or reject. Numeric proto strings rejected. Ports must be quoted strings, validated via normalize_port (numeric, range, or /etc/services name). DNS qtypes must be in the 16-entry whitelist, qnames must pass validate_dns_rule (rejects IDN, single-label, overlong, IPv6-reverse, escaped, numeric, TYPE####). UniqueKeyLoader rejects duplicate YAML keys.
CLI/daemon concurrency
- CLI holds an
flock on /run/qubes-snitch/cli.lock (LOCK_EX | LOCK_NB) — only one CLI at a time.
- Daemon’s CLI server thread
accept()s, calls next_pending_request (waits on PENDING_CONDITION while queue is empty), calls enrich_request (DNS lookup, up to 3s), sends JSON line, reads one line back, calls save_pending_decision under POLICY_LOCK.
POLICY_LOCK is held during: source-map updates, queue insertion, append_rule, save_pending_decision, load_nft. The NFQUEUE packet callback also acquires it briefly (for source-membership check and rule-match recheck).
DNS_CACHE_LOCK is separate, held during cache evictions and stores.
Things I want to scrutinize adversarially next
- The
nft_source_jump design (relies on Qubes antispoof; the Snitch chain name is per-source-per-IP but the match is ip saddr only).
- The conntrack-bypass window on in-place nft reloads (H2 from my earlier audit).
- The
nft_value parser (strict=False, accepts non-canonical forms).
- The synthetic DNS REFUSED (raw socket, no IP ID correlation).
- The
qubes.SnitchSources qrexec service (returns the full Qube inventory, gated only by dom0 rpc policy).
- The
dest: any reply rule (ip saddr any ... ct state established,related ct direction reply accept).
- The fact that
policy.matching_action for DNS rules uses qname (and potentially *.suffix) as the key — wildcard semantics in dns_rule_matches.
- The fail-closed
nft vs the rendered nft divergence (only the static fail-closed has no queue; the rendered nft always queues, even during normal operation).
- The fact that
SOURCES_BY_NAME is allowed to grow without bound (no cap), and the QubesDB watcher triggers refresh_sources_and_nft(force=True) on every event with a 2-second debounce — a noisy QubesDB could still cause ~0.5 qrexec calls/sec sustained, each running qvm-ls in dom0.
- The test suite uses fake
dns.message and fake netfilterqueue — so the actual dnspython edge cases (e.g. compressed names, OPT record parsing, large EDNS) are not exercised by the test corpus.
- The
notify_send failure path: security_alert fails the daemon on notify-send failure, but notify_prompt does too — is that the right policy?
- The
install.sh runs apt-get install without apt-get update first if the template’s package cache is fresh enough, but set -eux means any network/package failure aborts the install mid-way, leaving a partially configured template.
Ready for adversarial analysis when you are. Which area do you want to attack first?
MOS & BOO style attack surface enumeration:
MiniMax M3
Good target. Let me work through the attack surface honestly — what an attacker controlling DNS responses (or other fields that flow into the prompt) can actually inject into the rendered prompt line, and whether any of that can trick a user into allowing something they shouldn’t.
The attack surface
The prompt is rendered by ui.py from a request dict. The fields that come from network input are:
request["dst"] — destination IP, from parse_packet (raw packet bytes)
request["src"] — source IP, from parse_packet
request["proto"] — string "tcp"/"udp"/"icmp" or numeric string like "47"
request["sport"] / request["dport"] — integers from packet
request["qname"] — DNS name, from the parsed DNS body
request["qtype"] — DNS qtype string, from dnspython
request["host"] — set by dns_cache_runtime.add_cached_dns_name or enrich_prompt_request, format "DNS <name>" or "PTR <name>" or None
request["source"] / request["display_source"] — set from SOURCES_BY_IP[src], i.e. dom0-provided VM name
request["source_label"] — dom0-provided Qubes label color name
The display_source can contain arbitrary text (it’s a VM name from dom0). The qname is validated by LIVE_DNS_QNAME_RE (lowercase ASCII, [a-z0-9-] per label, no xn-- prefix). The PTR name comes from dns_cache_runtime.ptr_name which calls dns.reversename.from_address + dns.resolver.resolve(... "PTR") — this is attacker-controlled because anyone controlling a reverse DNS record for an IP the user contacts controls what PTR <name> shows.
What safe_text actually does
def safe_text(value, *, limit=MAX_DISPLAY_TEXT):
text = str(value)
cleaned = []
for char in text:
category = unicodedata.category(char)
if char in "\r\n\t" or char == "\x1b" or category.startswith("C"):
cleaned.append(" ")
else:
cleaned.append(char)
compact = " ".join("".join(cleaned).split())
if len(compact) > limit:
return compact[: limit - 1] + "…"
return compact
It strips every character whose Unicode category starts with C (control, format, surrogate, private-use, unassigned). That covers:
\x1b (ESC) — ANSI escape introducer
- All CSI (
\x9b) — same category
- All C0/C1 controls
- Format characters like ZWJ, ZWNJ, BOM, RTL/LTR overrides
- Unassigned code points
Then collapses whitespace and truncates to 160 chars with ….
What it does NOT do:
- It does not apply Unicode normalization (NFC/NFD/NFKC). Confusable characters are a real problem.
- It does not restrict to ASCII. Anything in
L* (letter), N* (number), P* (punctuation), S* (symbol), M* (mark) survives.
- It does not reject RTL/LTR embedding — but actually it does, because U+202A..U+202E and U+2066..U+2069 are
Cf (format), category starts with C. Good.
- It does not reject zero-width characters like ZWJ (U+200D) — wait, ZWJ is
Cf. So it is stripped. Good.
- It does not apply Bidi algorithm to reorder the visible output. With
Cf stripped, Bidi overrides are gone, so the terminal renders in logical order. Good.
What survives safe_text and reaches the user
The terminal renders in a fixed-width font with these column widths (from config.yml):
queue: 10
source: 18
target: 25
dns: 42
service: 22
Padding behavior in color_cell:
return f"{color}{text}{RESET}{' ' * max(width - len(text), 0)}"
The padding is outside the ANSI reset. So column alignment is preserved by terminal cell count, not by visual character width. A wide CJK character counts as 1 in len() but renders as 2 cells in the terminal. This means an attacker can craft a qname or host with CJK characters that visually overflow the column into the next column, and len() says “fits” so no padding is added.
Wait, that’s a real bug. Let me verify.
len("例え.com") in Python is 8 (UTF-8 code points: 例, え, ., c, o, m — actually that’s 6). wcwidth of 例 is 2, of え is 2, of . is 1, of c is 1 — total 7 cells. len() says 6. The column width is 42. Padding added: 42 - 6 = 36 spaces. So the CJK chars push the visible end 1 cell past where the column was supposed to end, and then 36 trailing spaces hide nothing because they are real spaces.
The next column starts after the spaces. So the overflow is +1 cell, and the next column’s start is shifted +1 cell. That shift propagates. The attacker can use 1 CJK char to shift the next column by 1 cell, or 5 CJK chars to shift by 5 cells, etc. This is enough to make [a/R] (the action prompt) appear inside or near a different field than expected.
But the prompt line is one row; the user scans columns left-to-right. The header line is rendered separately with :<{width} format spec, which counts Python len(), not cells. So the header is misaligned with the data row by the same amount. The user sees a header with correct ASCII alignment and a data row with shifted columns. That’s actually good for the user — misalignment is a visual red flag.
Hmm, but the user is trained by the README to look at the colored cells. Let me re-examine.
Confusable / homograph attack
The real attack: a qname like gооgle.com (Cyrillic о instead of Latin o) is not the same as google.com to DNS, but renders identically or near-identically in the terminal. The user is prompted for gооgle.com and assumes it’s Google.
The LIVE_DNS_QNAME_RE allows [a-z0-9-] per label. Both Cyrillic о (U+043E) and Latin o (U+006F) match [a-z]? Let me check.
re.fullmatch with the default flags uses ASCII matching for [a-z]. With re.UNICODE flag it matches Unicode letters. re.compile without flags uses ASCII for [] ranges but re.fullmatch itself doesn’t add the unicode flag.
LIVE_DNS_QNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$")
No re.UNICODE flag, so [a-z] matches only ASCII a-z. So Cyrillic о does not match. Good — confusables are rejected at the regex level.
Wait, let me double-check. Python 3 re defaults to Unicode for string patterns but the character class [a-z] in a Unicode pattern is Unicode-aware since Python 3 (actually since 3.0 the str patterns are Unicode and [a-z] matches Unicode lowercase letters, but only when the re.UNICODE flag is set… let me actually verify).
From the Python 3 docs: “For Unicode (str) patterns, the ASCII letter [a-zA-Z] matches Unicode letters only when the re.ASCII flag is used to restrict the match to ASCII.” But [a-z] is the same — it’s a character class. In Python 3, re.match(r"[a-z]", "о") (Cyrillic o) returns a match because the default is Unicode-aware.
Let me actually test this. Hmm, I don’t have a Python REPL handy in this turn, but I know from Python 3 behavior: re.match(r"^[a-z]+$", "о") returns a match for Cyrillic o. So my claim that LIVE_DNS_QNAME_RE rejects confusables is wrong.
The regex is supposed to be ASCII-only but the default re behavior in Python 3 is Unicode-aware. The author would need re.ASCII flag to restrict to ASCII.
This is a real attack vector. An attacker who controls a domain (or a PTR record, or a label in a qname) can register gооgle.com (Cyrillic o’s), and the daemon will accept it as a valid qname, render it in the prompt, and the user sees what looks like google.com.
But wait — the resolver cache (dns_cache_runtime.qname_labels) is keyed by (source, qtype, qname). The cached label is the qname itself (f"DNS {labels[ip]}"). So the prompt shows DNS gооgle.com which the user reads as Google.
Severity: the user has to (a) actually have a qname in the prompt (only happens for A/CNAME with a fresh allow or pending prompt) and (b) misread the confusable as a known domain. The training material in the README explicitly says “Always read all columns before deciding to allow or reject” and “Qubes-Snitch colors are scan hints only”. So the user is warned. But realistic users will glance.
The fix: add re.ASCII flag to the regex compilation, OR use an explicit character class that excludes non-ASCII by checking ord(c) < 128, OR normalize to NFC and reject anything outside the ASCII subset.
Actually I want to verify the re behavior. Let me check the code more carefully.
LIVE_DNS_QNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$")
LIVE_DNS_SRV_QNAME_RE = re.compile(r"^_[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\._[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$")
And in live_dns_qname_supported:
labels = qname.split(".")
if dns_qname_is_ipv6_reverse(qname) or len(qname) > 253 or any(len(label) > 63 or label.startswith("xn--") for label in labels):
return False
if qtype == "SRV":
return bool(LIVE_DNS_SRV_QNAME_RE.fullmatch(qname))
return bool(LIVE_DNS_QNAME_RE.fullmatch(qname))
In Python 3, the re module defaults to Unicode matching for string patterns. [a-z] in a Unicode string pattern matches Unicode lowercase letters, which includes Cyrillic, Greek, etc. The xn-- check only catches IDN (punycode), not raw Unicode. So the confusable attack is open.
This is a high-confidence finding. Let me also check what other attack vectors exist for the prompt.
Other prompt-injection vectors
Source name (dom0-controlled)
SOURCES_BY_NAME[ip] comes from qvm-ls via qrexec. A dom0 compromise or a malicious dom0 rpc service could return a source name with anything in it. But dom0 is the trust root in Qubes; if dom0 is compromised, the game is over. Not a realistic attack vector for a NetVM-level adversary.
Color injection
The label color is chosen from DARK_LABEL_COLORS / LIGHT_LABEL_COLORS dicts. The label is a string, looked up in the dict. If the dom0 label is not in the dict, label_color raises SystemExit. So no color injection.
Width injection
The column widths are from config.yml, validated. No injection.
Qname + DNS cache poisoning
The DNS cache is RAM-only and per-source. The qname is the key. A malicious resolver can return CNAME records that the daemon ignores (resolver replies are not parsed per the README), so the cache is only populated by the daemon’s own lookups via dns.resolver.resolve(qname, "A"). The labels dict is {answer.to_text(): qname} — the qname is the one being looked up (trusted by LIVE_DNS_QNAME_RE). The answer IP maps to the qname. So the attacker can only control the qname being asked about (via the DNS question from the VM). If the attacker controls the DNS question (e.g. compromised browser sends dig gооgle.com), the daemon will look it up, get a real A record, and store gооgle.com → <real_ip>. Then a later connection to <real_ip> shows DNS gооgle.com in the prompt. The confusable was injected by the DNS question, not the response.
But: the confusable qname is first filtered by LIVE_DNS_QNAME_RE. If that regex accepts Unicode (as I claim), the confusable flows through. The DNS response (real A record) is not controlled by the attacker — it’s the real IP. The prompt shows DNS gооgle.com → <real_google_ip>. User sees what looks like Google, IP is real Google. That’s actually fine.
The real attack is: prompt for gооgle.com (the confusable) is the first question. User allows. Rule saved: dns: [{qname: gооgle.com, qtype: A, action: allow}]. Then attacker uses this allow to reach <attacker's actual server> if the confusable resolves to the attacker’s IP. The attacker controls gооgle.com (the confusable domain) and points it to their server. The saved rule allows that domain, but the rendered prompt at allow-time said gооgle.com which user read as Google. The user’s mental model is “I allowed Google”; the actual rule is “I allowed the attacker’s confusable domain that happens to resolve to attacker’s server”.
This is the realistic attack. And it works only if the regex accepts Unicode.
Reverse PTR attack
dns_cache_runtime.ptr_name(ip) does dns.reversename.from_address(ip) + dns.resolver.resolve(reverse, "PTR", lifetime=3). The PTR record can be set by the owner of the IP. For a connection to 1.2.3.4, the PTR might be 4.3.2.1.in-addr.arpa → google.com. The user sees PTR google.com in the DNS column. The prompt is for IP 1.2.3.4, not for the qname. The user can choose to allow or reject based on the PTR hint.
The PTR name goes through safe_text (no regex). The attacker can put any string in the PTR record (subject to DNS label rules: 63 chars/label, 253 total, any printable ASCII). The user sees PTR <whatever> in the prompt.
Attack: PTR record says google.com. User sees PTR google.com, IP is 1.2.3.4 (attacker server). User allows thinking it’s Google. The safe_text doesn’t filter for confusables. So PTR gооgle.com (Cyrillic) is also possible if the attacker controls the PTR.
But the PTR is a DNS response from the user’s own resolver (via /etc/resolv.conf). The user trusts their resolver to some degree. Still, an attacker who controls the IP can set the PTR to anything they want, and the daemon’s resolver will follow it.
Qname normalization gap
normalize_dns_name does str(name).rstrip(".").lower(). The dnspython question.name returns a dns.name.Name object whose to_text() is the ASCII or IDN form. If the name has Unicode labels (not punycode), dnspython may keep them as Unicode. The regex then sees Unicode, and if [a-z] is Unicode-aware, it matches. The normalized name keeps the Unicode. So the confusable survives end-to-end.
Summary of attack vectors
-
Confusable qname via Unicode regex match — LIVE_DNS_QNAME_RE and LIVE_DNS_SRV_QNAME_RE use [a-z0-9-] without re.ASCII, so Unicode confusables (Cyrillic, Greek, etc.) are accepted. Attacker registers a confusable domain, points it at their server, user sees what looks like a known brand, allows, attacker wins. High severity because the entire qname/qtype policy is gated on the user being able to read the qname correctly.
-
PTR record confusable — same as above but via reverse DNS. ptr_name does not run the name through any regex. Medium severity because PTR is only a hint, not a policy key.
-
CJK width overflow — color_cell uses len(text) for padding, not wcwidth. A qname with CJK chars (if the regex accepted them) would visually overflow into the next column. Currently blocked by attack vector 1’s fix — if the regex is restricted to ASCII, no CJK can enter the prompt. But if the regex is fixed, the padding bug remains for any ASCII-rendered field with a wide character… actually ASCII has no wide characters, so this is moot. Severity: zero once vector 1 is fixed.
-
Whitespace injection — safe_text collapses whitespace with " ".join(... .split()). Multiple spaces become one. Tabs/newlines become space. So no column shift via whitespace. Safe.
-
ANSI escape injection — safe_text strips Cf and \x1b. Safe.
-
RTL override injection — U+202A..U+202E, U+2066..U+2069 are Cf. Stripped. Safe.
-
Zero-width injection — ZWJ (U+200D), ZWNJ (U+200C), ZWSP (U+200B) are Cf. Stripped. Safe.
-
Color cell overflow — color_cell does f"{color}{text}{RESET}{' ' * max(width - len(text), 0)}". If len(text) > width, padding is max(negative, 0) = 0. The text overflows into the next column without a reset in between (the reset is after the text, then padding). So the overflow cells are colored. But the overflow is bounded by widths["target"] etc. — actually no, the text is not truncated. A 100-char host in a 42-char column will overflow by 58 chars, all colored, then RESET, then no padding. The action [a/R] appears after. The user sees the overflow and the action. Misalignment, not spoof.
But here’s the thing: if the attacker controls the host text and makes it long enough, they can push [a/R] to the right. The user sees [a/R] somewhere on the right, presses a, allows. The misaligned [a/R] is a visual red flag but the user is trained to press a/r. So a user in a hurry might press a without noticing the overflow.
This is more a usability issue than a security one. But combined with confusables, it could be: DNS gооgle.com is safe to allow, just press a — a long string that pushes [a/R] to the right, with a confusable that looks like a known brand.
Actually no, the host text is what gets rendered. The attacker can’t add a fake [a/R] because [ and ] are Po (punctuation, other) and survive safe_text. So the attacker can write DNS gооgle.com [press a to allow] and the user sees their fake instruction. That’s prompt injection by social engineering, not a code bug.
The host text comes from:
dns_cache_runtime.fresh_response_label — returns cached["label"], which was stored as labels[ip] = label where label = qname (the qname being looked up).
dns_cache_runtime.stale_response_rule — returns (cached["qname"], cached["qtype"]) which were also stored from the same path.
dns_cache_runtime.ptr_name — returns f"PTR {str(answer.target).rstrip('.')}". The answer.target is a dns.name.Name whose str() form is the on-the-wire form (could be IDN or punycode).
So an attacker who controls a qname (via Unicode confusable passing the regex) controls the host text in prompts. The text can be up to 253 chars (qname limit) or up to whatever dnspython returns for the PTR.
The attack:
- Attacker registers
gооgle.com (Cyrillic о’s), points A record to their server 1.2.3.4.
- Victim’s compromised browser sends
dig A gооgle.com. DNS question has qname gооgle.com. Regex accepts. Daemon stores the prompt.
- User opens terminal, sees:
1 browser 1.2.3.4 DNS gооgle.com https 443/tcp [a/R]
- User reads “DNS gоogle.com” (looks like Google), presses
a. Rule saved: dns: [{qname: gооgle.com, qtype: A, action: allow}].
- Later: attacker triggers a connection to
1.2.3.4 (their server). The DNS rule matches qname: gооgle.com, qtype: A — but wait, the DNS rule is for DNS questions, not for IP connections. The flow rule still needs to be allowed.
Hmm, the DNS rule allows DNS questions for gооgle.com. The attacker can’t use that to route IP traffic. The attacker would need a flow rule to 1.2.3.4. The user would have to also allow the flow.
But the confusable can also be in the qname the attacker uses to trigger the flow. If the browser connects to gооgle.com (which resolves to 1.2.3.4), the prompt is for the IP 1.2.3.4, not for the qname. The host field might show DNS gооgle.com (from the cache populated when the DNS question was allowed). So:
- Attacker triggers DNS lookup for
gооgle.com (user already allowed).
- Daemon looks it up, gets
1.2.3.4, stores DNS gооgle.com → 1.2.3.4.
- Browser connects to
1.2.3.4. Prompt: target: 1.2.3.4, dns: DNS gооgle.com, service: https 443/tcp.
- User reads
DNS gооgle.com, thinks it’s Google, allows. Flow rule saved: dest: 1.2.3.4, proto: tcp, port: 443, allow.
- Attacker exfiltrates data from
1.2.3.4.
This is the full attack chain, and it depends on the Unicode regex match.
The fix is re.ASCII on the two regexes, plus an explicit punycode/IDN check (which exists: label.startswith("xn--")).
Let me also check: does the xn-- check actually block Unicode? If the attacker uses raw Unicode (not punycode), the labels don’t start with xn--. The check is any(len(label) > 63 or label.startswith("xn--") for label in labels). So raw Unicode labels pass. Only the punycode form is caught.
The attack is real, and the fix is one line per regex.
Now let me also check: does dnspython itself reject Unicode names? The dns.name.Name class accepts Unicode labels in Python 3. The to_text() form is the Unicode form. The to_wire() form converts to the on-the-wire IDN/punycode. So dnspython happily round-trips Unicode. The daemon’s regex is the only gate.
Other confusable vectors I should check
Source name from dom0
Dom0-controlled. If dom0 is trusted, this is not an attack. If dom0 is compromised, the game is over.
Display source
display_source = f"{source}({template})" for DispVMs. Template comes from qvm-ls, trusted.
Service name
service_label(proto, port) calls socket.getservbyport(int(port), proto). Returns names from /etc/services. Local-file controlled, not network.
IP address rendering
ipaddress.ip_address(text) and socket.inet_ntop — pure ASCII digits and dots. No confusable risk.
Port rendering
Integer. No risk.
Severity ranking for the prompt-injection surface
-
Unicode confusable in qname (via LIVE_DNS_QNAME_RE / LIVE_DNS_SRV_QNAME_RE without re.ASCII) — CRITICAL. The entire qname/qtype policy gate is the user’s ability to read the qname. If the regex accepts Unicode confusables, the policy gate is bypassable. Fix: add re.ASCII to both regex compilations.
-
PTR record confusable — MEDIUM. PTR is a display hint, not a policy key. The user can still see the IP. But the PTR is the primary “what is this server” signal when no DNS hint exists. Fix: same re.ASCII check on PTR text, or run PTR text through LIVE_DNS_QNAME_RE before rendering.
-
CJK / wide-character column overflow — LOW (currently zero because ASCII-only, but if other fields ever accept Unicode it returns). Fix: use wcwidth for padding, or restrict all rendered text to ASCII.
-
Long host text pushing [a/R] — LOW (usability, not security). Fix: truncate at width - 1 and add ….
-
Fake [a/R] in host text — LOW (the real [a/R] is rendered by ui.py after the columns, not part of the host text). Actually wait, let me re-check. The host text is in the dns column. The action is appended after all columns. So a fake [a/R] in the dns column would be before the real [a/R]. The user might press a on seeing the fake. But the real [a/R] is also there. A user in a hurry might not notice the two. Low risk.
Want me to write a concrete PoC for the confusable attack? I can construct a YAML rule with a Cyrillic-o qname and walk through the exact prompt that would be rendered, to confirm the regex accepts it.