Follow @Openwall on Twitter for new release announcements and other news
[<prev] [next>] [thread-next>] [day] [month] [year] [list]
Message-ID: <CAJZkTRfx7E6uzPn2evWoPsuN7MCqz1VCMA1jokTy6hh8buYXiw@mail.gmail.com>
Date: Sun, 8 Nov 2026 18:28:04 +0800
From: X L <idealeer521@...il.com>
To: musl@...ts.openwall.com
Subject: musl's DNS TxID is a deterministic function of the realtime clock,
 with no additional entropy

Dear Teams,

We found some bugs/vulnerabilities of your software. Please review the
technical details and mandatory mitigation steps below.
Summary

musl is the default C library of Alpine Linux and OpenWrt; when a program
resolves a name through getaddrinfo, its stub resolver builds the DNS
query. Since June 2014, musl's stub resolver computes the TxID directly
from the nanosecond field of the realtime clock, as $\mathit{id} =
(\mathit{ns} + \lfloor \mathit{ns}/65536 \rfloor) \bmod 2^{16}$; the code
comment says "Make a reasonably unpredictable id". The formula has no
hidden state and no extra entropy: 120,000 verifications (20,000 controlled
clock values and 100,000 real queries) all match exactly. The attacker only
needs to estimate the victim's clock: with an estimate within $\pm w$
nanoseconds, the TxID candidates number only $2w{+}1$ ($\pm 1\,\mu s$ gives
2,001, a 33x reduction); for a process on the same host (e.g., a sibling
container) the kernel clock is shared and $w$ is effectively zero. In
addition, 63,244 of 100,000 consecutive queries repeat the previous ID: the
A and AAAA queries of one getaddrinfo call usually share one TxID, one
socket, and one port, so observing one confines the other to about 500
candidates. Every outbound query also leaks the low 16 bits of the victim's
realtime clock, helping the attacker align its estimate.
Technical detail

src/network/res_mkquery.c (commit 8312f7f6, 2014-06-02, unchanged since):

/* Make a reasonably unpredictable id */
clock_gettime(CLOCK_REALTIME, &ts);
id = ts.tv_nsec + ts.tv_nsec/65536UL & 0xffff;


   1.

   Pure clock: the ID is a deterministic function of the CLOCK_REALTIME
   nanosecond field; no state, no entropy.
   2.

   Repeat rate: in a tight loop, 63,244/100,000 consecutive queries repeat
   the immediately preceding ID (when the clock advances by less than 65,536
   ns the formula output does not change); A/AAAA go out back to back, usually
   with the same ID, socket, and port.
   3.

   Clock leakage: $\mathit{id} \approx \mathit{ns} \bmod 2^{16}$, so every
   query discloses the low 16 bits of the realtime clock.
   4.

   Candidate windows: an estimate within $\pm w$ ns leaves $2w{+}1$
   candidates; on the same host the clock offset is zero, and $w$ is the
   uncertainty about when the victim makes the call (we did not measure it); a
   calibrated LAN peer reaches microseconds.
   5.

   Retry reuse: res_msend reuses the same query buffer, so the same (TxID,
   port) persists for the whole timeout window (about 5 s at defaults), giving
   a forger repeated attempts against a fixed target.

Experimental verification (L2 + L3, Docker-reproducible)

   -

   Formula: 120,000/120,000 (20,000 controlled clock values plus 100,000
   real queries).
   -

   L3 container (gcc:13-bookworm, aarch64, musl 1.2.6 built from the
   analyzed source): microsecond probes 30/30 ($p \approx 7.6\times10^{-79}$).
   -

   Alpine 3.20's packaged musl (dynamically linked, not built by us): 30/30
   ($p \approx 2.1\times10^{-74}$).
   -

   Real getaddrinfo captures, 50 lookups: A/AAAA share the port 50/50; the
   clock readings behind the two IDs are $\le 500$ ns apart, so observing the
   A query confines the AAAA TxID to about 500 candidates (50/50); retries
   reuse the (TxID, port) pair 50/50 (2.5 s x 2 retransmissions against a
   silent server).

Attack prerequisites and limitations

   -

   The attacker must estimate the victim's clock: on the same host it reads
   the clock itself and must estimate only the moment of the call;
   microseconds on the same LAN; remotely, the clock bits leaked by the
   queries themselves help alignment.
   -

   The source port is kernel-assigned (one socket per lookup), so the port
   factor remains; prior port side-channel work can reduce it further.
   -

   There is no cache, so poisoning holds per query; but the fixed pair
   within the retry window gives repeated attempts.
   -

   All our experiments were "lazy attacks": prediction only, never a forged
   response, and all traffic went to our own logging servers.

Impact

The numbers below come from public metadata (distribution package indexes,
registry counters, public code search). They bound what ships, not what
runs; stub components run inside end hosts and firmware and cannot be
measured remotely.

   -

   *Every version since June 2014*, including the current 1.2.x.
   -

   *Alpine Linux*: all 24,171 binary packages of the Alpine 3.20
   repositories for one architecture are built against musl; the official
   alpine image records 12.4 billion pulls on Docker Hub (November 2026),
   and every container built FROM alpine inherits the generator.
   -

   *OpenWrt*: musl is the default C library, running on hundreds of
   millions of consumer routers; opkg, ntpd, and LuCI resolve through it.
   -

   *Inheritance*: every program that resolves through getaddrinfo uses
   musl's TxID, including Node.js, Python, PHP, Ruby, Java (default), Rust
   std::net, and cgo; runtimes with their own DNS client (such as the
   pure-Go resolver) are not affected.
   -

   *Consequence*: the TxID depends only on one clock reading; an attacker
   that reads the same clock or calibrates to microseconds reduces 65,536
   candidates to a few thousand, and since the A and AAAA queries of one
   getaddrinfo call share a port, seeing the A query leaves about 500
   candidates for the AAAA query.

Suggested fix

Draw the TxID from getrandom(2). Every kernel musl supports now provides
it; the 2014 choice of the clock avoided a system call, and that constraint
no longer applies.
Reproduction

The victim programs below are those of our experiments with internal
comments removed; the check scripts and attacker models are the core of the
full experiment code, re-checked against the original records with the same
results. The output is what we recorded. All queries go only to our own
logging server on 127.0.0.1, which records them and answers REFUSED; no
forged response is ever sent. This check generates no network traffic.

*Probe* (calls the system res_mkquery between two realtime-clock reads):

/* probe.c: call the system res_mkquery between two CLOCK_REALTIME reads */
#include <stdio.h>
#include <time.h>

extern int res_mkquery(int, const char *, int, int, const unsigned char *,
                       int, const unsigned char *, unsigned char *, int);

int main(void) {
    unsigned char buf[512];
    for (int k = 0; k < 30; k++) {
        struct timespec a, b;
        clock_gettime(CLOCK_REALTIME, &a);
        int n = res_mkquery(0, "probe.test", 1, 1, NULL, 0, NULL, buf,
sizeof buf);
        clock_gettime(CLOCK_REALTIME, &b);
        printf("PROBE %ld %ld %ld %ld %u %02x %02x n=%d\n",
               a.tv_sec, a.tv_nsec, b.tv_sec, b.tv_nsec,
               (buf[0] << 8) | buf[1], buf[0], buf[1], n);
        fflush(stdout);
        struct timespec s = {0, 200000};
        nanosleep(&s, NULL);
    }
    return 0;
}

*Check script:*

# check.py
import sys
n = hit = 0
for line in open(sys.argv[1]):
    p = line.split()
    if p[0] != "PROBE":
        continue
    sec, ns0, _, ns1, wire = map(int, p[1:6])
    n += 1
    hit += any(((ns + ns // 65536) & 0xFFFF) == wire for ns in
range(ns0, ns1 + 1))
print(f"{hit}/{n} TxIDs equal (ns + ns/65536) & 0xffff for a
nanosecond inside the bracket")

*Commands:*

docker run --rm -it alpine:3.20 sh          # the distribution's own
musl, not a rebuild
apk add build-base python3
gcc -O2 -o probe probe.c && ./probe > probe.txt && python3 check.py probe.txt

*Recorded output* (the musl packaged by Alpine 3.20, dynamically linked):

PROBE 1793334795 3378215 1793334795 3380423 38138 94 fa n=28
PROBE 1793334795 3875507 1793334795 3875757 9192 23 e8 n=28
PROBE 1793334795 4235007 1793334795 4235173 40892 9f bc n=28
...
30/30 TxIDs equal (ns + ns/65536) & 0xffff for a nanosecond inside the bracket

A random ID matches the value of some nanosecond inside the bracket with
probability (bracket width) / 65,536 ns; over 30 probes the joint
probability is 2.1×10⁻⁷⁴. musl 1.2.6 built from source gives 30/30 as well
(p≈7.6×10⁻⁷⁹), and a link-time clock override checks the formula value by
value, 120,000/120,000. The complete experiment (attacker model and raw
data) is available on request.
Notes

This report comes from an academic study. It contains unreleased
vulnerability information; please do not forward it before public
disclosure. We are happy to support a CVE request.
Best,
Xiang Li, Aichun Huang, Zheli Liu (AOSP Lab @Nankai University)

Content of type "text/html" skipped

Powered by blists - more mailing lists

Confused about mailing lists and their use? Read about mailing lists on Wikipedia and check out these guidelines on proper formatting of your messages.