From 73d04804148cca98f518f6d32c2f97632ebf282d Mon Sep 17 00:00:00 2001 From: utarafdar Date: Wed, 15 Jul 2026 14:37:43 +0530 Subject: [PATCH] Add BlastRADIUS (Message-Authenticator) posture check to radius test --- README.md | 15 +++- cmd/authhound-probe/main.go | 1 + docs/json-schema.md | 4 +- internal/check/blastradius.go | 108 +++++++++++++++++++++++++ internal/check/blastradius_test.go | 69 ++++++++++++++++ internal/check/check_test.go | 21 ++++- internal/radius/msgauth.go | 72 +++++++++++++++++ internal/radius/msgauth_test.go | 124 +++++++++++++++++++++++++++++ test/README.md | 11 +++ test/freeradius-smoke.sh | 56 ++++++++++++- test/lab/docker-compose.yml | 24 ++++++ 11 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 internal/check/blastradius.go create mode 100644 internal/check/blastradius_test.go create mode 100644 internal/radius/msgauth.go create mode 100644 internal/radius/msgauth_test.go diff --git a/README.md b/README.md index 90cccd4..757bf85 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ Testing RADIUS server radius.corp.com:1812 (as NAS "authhound-probe") PASS RADIUS server answered in 23ms PASS Shared secret is correct (reply signature verified) +PASS Server signs its replies with Message-Authenticator (BlastRADIUS-hardened) PASS PEAP-MSCHAPv2 authentication succeeded for alice PASS Server certificate valid for 214 more days, chain looks complete (TLS 1.2) -Verdict: 4 passed, 0 failed, 0 warnings +Verdict: 5 passed, 0 failed, 0 warnings ``` Like `eapol_test` or `radtest`, but the output is readable — one command, no `wpa_supplicant` config file. Add `--json` for scripting, `--nas-port-type ethernet|wireless|virtual` to match how your real NAS presents itself, and `--server-name` to set the expected certificate name. @@ -58,6 +59,7 @@ Skipped this step? The probe notices: on a first-run timeout it prints this exac |---|---| | **Reachability** | The server answers on UDP/1812 — and how fast. A timeout means unreachable, not listening, **or the probe isn't whitelisted / the secret is wrong** (servers silently drop unverifiable requests). | | **Shared secret** | Cryptographically verifies the server's reply signature. A pass *proves* the secret matches — no more guessing whether "everyone's getting rejected" is a secret problem or something else. | +| **BlastRADIUS posture** | Observes whether the server signs its replies with a **Message-Authenticator** — the mitigation for the RADIUS/UDP reply-forgery flaw [CVE-2024-3596](https://blastradius.fail) ("BlastRADIUS"). PASS if it does; WARN, with config pointers, if it accepts the probe's (signed) request but replies unsigned. Observation only — see below. | | **PAP authentication** | A real login with credentials you supply → Accept or Reject, decoded. Also detects an **MFA/second-factor challenge** and reports it (the probe does not complete push/OTP — see below). | | **PEAP-MSCHAPv2** | The method most enterprise 802.1X networks actually run: a real inner authentication inside the PEAP TLS tunnel. Reports success — and verifies the server's own MSCHAPv2 proof (mutual auth) — or the decoded reason on rejection. The "can my users actually log in?" test. | | **EAP-TTLS (PAP)** | A real login inside the TTLS tunnel using inner PAP. Because the password is checked in cleartext (safe inside the tunnel), TTLS-PAP works against *any* backend — including hashed stores that MSCHAPv2 can't use. If PEAP-MSCHAPv2 fails but this passes, the directory can't produce an NT hash. | @@ -71,6 +73,17 @@ Skipped this step? The probe notices: on a first-run timeout it prints this exac If the server issues an MFA challenge after valid primary credentials, the probe reports that boundary — *"primary auth healthy, second factor required"* — but does **not** approve a push or submit an OTP. Completing a second factor from an unattended probe would mean storing a live MFA secret, which this tool refuses to do. For monitoring, point it at a **test account exempt from MFA** so the primary RADIUS path is validated cleanly. +### BlastRADIUS / Message-Authenticator posture + +[BlastRADIUS (CVE-2024-3596)](https://blastradius.fail), disclosed in 2024, lets an on-path attacker forge RADIUS/UDP responses by exploiting the protocol's MD5-based Response Authenticator. The mitigation is to enforce a **Message-Authenticator** attribute in **both** directions (and, longer term, to move to RadSec). Most deployments still haven't hardened. + +The probe already includes a Message-Authenticator in every request it sends. This check then **observes** the reply: does the server sign it back? + +- **PASS** — the server returned a valid Message-Authenticator. That path is hardened. +- **WARN** — the server accepted our signed request but replied **without** a Message-Authenticator, so responses to this probe's client entry are forgeable. The output includes paste-ready pointers: `require_message_authenticator` / `limit_proxy_state` for FreeRADIUS, the Microsoft NPS July-2024 hardening update, and `radsec test` as the durable fix. + +This is **observation only** — a single, normal authentication exchange, never any attack technique. And it is honest about scope: it can only speak to how the server treats **this probe's client entry** (a server may sign for one client and not another). In `--json`, the result carries `blastradius_posture: "signed" | "unsigned"`. + ### Machine auth (NPS) Windows NPS shops authenticate **computers** as often as users — a domain PC does diff --git a/cmd/authhound-probe/main.go b/cmd/authhound-probe/main.go index 35a97ff..a5e53b6 100644 --- a/cmd/authhound-probe/main.go +++ b/cmd/authhound-probe/main.go @@ -305,6 +305,7 @@ func cmdRadiusTest(args []string) int { Checks: []check.Check{ check.Reachability{}, check.SharedSecret{}, + check.BlastRADIUS{}, check.PAP{User: papUser, Pass: papPass}, check.PEAPMSCHAPv2{User: peapUser, Pass: peapPass, ServerName: *serverName}, check.EAPTTLS{User: ttlsUser, Pass: ttlsPass, ServerName: *serverName}, diff --git a/docs/json-schema.md b/docs/json-schema.md index 4f66fdc..319f6d9 100644 --- a/docs/json-schema.md +++ b/docs/json-schema.md @@ -62,12 +62,12 @@ Each entry in `results`: | Field | Type | Presence | Notes | |---|---|---|---| -| `check` | string | always | Stable identifier for the check, e.g. `reachability`, `shared-secret`, `pap`, `peap-mschapv2`, `eap-ttls`, `eap-tls`, `server-cert`, `mtu`, `radsec-connect`, `radsec-tls`, `radsec-cert`, `radsec-radius`. | +| `check` | string | always | Stable identifier for the check, e.g. `reachability`, `shared-secret`, `blastradius-posture`, `pap`, `peap-mschapv2`, `eap-ttls`, `eap-tls`, `server-cert`, `mtu`, `radsec-connect`, `radsec-tls`, `radsec-cert`, `radsec-radius`. | | `status` | string | always | One of `pass`, `fail`, `warn`, `info`, `skip` (see below). | | `summary` | string | always | One plain-English line describing the outcome. | | `detail` | string | when present | Extra context. Omitted when empty. | | `hint` | string | when present | Multi-line, paste-ready remediation. Newline formatting is significant. Omitted when empty. Never contains secrets. | -| `fields` | object (string→string) | when present | Structured extras such as `rtt_ms`, `tls_version`, `not_after`, `subject`, `san`, `chain_len`, `source_ip`. `timeout: "true"` marks a request that got no reply at all (a *lost* request, as opposed to a processed rejection). Aggregate verdicts under `--count` add `success_rate`, `attempts`, `successes`, `timeouts`, and `latency_{min,median,p95,max}_ms`. Keys vary by check; values are always strings. Omitted when there are none. | +| `fields` | object (string→string) | when present | Structured extras such as `rtt_ms`, `tls_version`, `not_after`, `subject`, `san`, `chain_len`, `source_ip`. `blastradius_posture` (on the `blastradius-posture` check) is `"signed"` or `"unsigned"` — whether the server signed its reply with a Message-Authenticator (see [BlastRADIUS posture](../README.md#blastradius--message-authenticator-posture)). `timeout: "true"` marks a request that got no reply at all (a *lost* request, as opposed to a processed rejection). Aggregate verdicts under `--count` add `success_rate`, `attempts`, `successes`, `timeouts`, and `latency_{min,median,p95,max}_ms`. Keys vary by check; values are always strings. Omitted when there are none. | | `duration_ns` | integer | when present | How long the check took, in nanoseconds. Omitted when zero. | | `authorization` | object | when present | On an auth check that reached an Access-Accept, the authorization attributes the server returned (VLAN/Filter-Id/…) and the outcome of any `--expect-vlan`/`--expect-attr` assertions. See below. Omitted otherwise. | diff --git a/internal/check/blastradius.go b/internal/check/blastradius.go new file mode 100644 index 0000000..6ae4705 --- /dev/null +++ b/internal/check/blastradius.go @@ -0,0 +1,108 @@ +package check + +import ( + "context" + "errors" + + "github.com/authhound/probe/internal/radius" +) + +// BlastRADIUSField carries the observed reply-signing posture in --json: +// "signed" when the server returned a valid Message-Authenticator, "unsigned" +// when it did not. Additive within schema major "1". +const BlastRADIUSField = "blastradius_posture" + +// BlastRADIUS observes whether the server signs its replies with a +// Message-Authenticator (RFC 3579) — the mitigation for the RADIUS/UDP +// reply-forgery class known as BlastRADIUS (CVE-2024-3596, 2024). +// +// This is observation only: the probe always includes a Message-Authenticator +// in its own requests (see radius.Exchange), sends one normal Access-Request, +// and reports whether the reply came back signed. It never attempts any attack +// technique — presence/absence on a single exchange, nothing more. And it can +// only speak to behaviour toward THIS probe's client entry: a server may sign +// for one client and not another, so the result is scoped to what we can see. +type BlastRADIUS struct{} + +func (BlastRADIUS) Name() string { return "blastradius-posture" } + +func (BlastRADIUS) Run(ctx context.Context, t Target) Result { + p, err := radius.NewAccessRequest(6) + if err != nil { + return Result{Check: "blastradius-posture", Status: StatusFail, Summary: "internal error: " + err.Error()} + } + p.AddString(radius.AttrUserName, "authhound-probe") + // A throwaway password: we only inspect whether the reply is signed, not + // whether auth succeeds — Access-Accept and Access-Reject are both fine. + p.SetUserPassword("authhound-probe-blastradius-check", t.Secret) + addCommon(p, t) + + reqAuth := p.Authenticator + _, raw, _, err := radius.Exchange(t.Address, t.Secret, p, t.Timeout) + if err != nil { + if errors.Is(err, radius.ErrTimeout) { + return markTimeout(Result{ + Check: "blastradius-posture", Status: StatusSkip, + Summary: "Could not check reply signing — no reply; resolve reachability first", + }) + } + return Result{Check: "blastradius-posture", Status: StatusSkip, Summary: "Could not check reply signing: " + err.Error()} + } + + present, valid := radius.VerifyMessageAuthenticator(raw, reqAuth, t.Secret) + switch { + case present && valid: + return Result{ + Check: "blastradius-posture", Status: StatusPass, + Summary: "Server signs its replies with Message-Authenticator (BlastRADIUS-hardened)", + Detail: "The server returned a valid Message-Authenticator, so it is hardened " + + "against the RADIUS/UDP reply-forgery class (CVE-2024-3596) on this client " + + "entry. This only reflects behaviour toward this probe's client entry — a " + + "server can be configured per-client.", + Fields: map[string]string{BlastRADIUSField: "signed"}, + } + case present && !valid: + // A signature that doesn't verify almost always means the shared secret + // differs; the shared-secret check diagnoses that. Flag it, don't claim + // exposure we can't attribute. + return Result{ + Check: "blastradius-posture", Status: StatusWarn, + Summary: "Server sent a Message-Authenticator that did not validate — check the shared secret", + Detail: "The reply carried a Message-Authenticator, but it did not verify with the " + + "secret given to this probe. That is almost always a shared-secret mismatch " + + "(see the shared-secret check), not a signing gap. Fix the secret and re-run.", + Fields: map[string]string{BlastRADIUSField: "unsigned"}, + } + default: + return Result{ + Check: "blastradius-posture", Status: StatusWarn, + Summary: "Server accepted our request but replied WITHOUT Message-Authenticator (BlastRADIUS-exposed)", + Detail: "The server processed a signed Access-Request but did not sign its reply, so " + + "RADIUS/UDP responses to this probe's client entry are forgeable by an on-path " + + "attacker (CVE-2024-3596, \"BlastRADIUS\"). This reflects only what we can see " + + "toward this probe's client entry; a server may sign for some clients and not others.", + Hint: blastRADIUSHint, + Fields: map[string]string{BlastRADIUSField: "unsigned"}, + } + } +} + +// blastRADIUSHint is the paste-ready remediation for an unsigned reply. It names +// the concrete config for the two servers this audience runs, then points at +// RadSec as the durable fix. No secrets appear here. +const blastRADIUSHint = `Require Message-Authenticator on both directions: + +FreeRADIUS — in each client{} block (clients.conf), then restart: + client ... { + require_message_authenticator = yes + limit_proxy_state = yes + } + +Windows NPS — install the July 2024 security update (KB5040268 / your +build's equivalent) and enable Message-Authenticator enforcement; see +Microsoft's guidance for CVE-2024-3596. + +RadSec (RADIUS/TLS) removes this class entirely — test it with: + authhound-probe radsec test --server + +Background: https://blastradius.fail / CVE-2024-3596` diff --git a/internal/check/blastradius_test.go b/internal/check/blastradius_test.go new file mode 100644 index 0000000..c7f4274 --- /dev/null +++ b/internal/check/blastradius_test.go @@ -0,0 +1,69 @@ +package check + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestBlastRADIUSSignedReplyPasses(t *testing.T) { + addr := startFakeServer(t, &fakeServer{secret: "s3cret", signMsgAuth: true}) + r := (BlastRADIUS{}).Run(context.Background(), target(addr, "s3cret")) + if r.Status != StatusPass { + t.Fatalf("signed reply: got %s (%s), want pass", r.Status, r.Summary) + } + if r.Fields[BlastRADIUSField] != "signed" { + t.Errorf("field: got %q, want signed", r.Fields[BlastRADIUSField]) + } +} + +func TestBlastRADIUSUnsignedReplyWarns(t *testing.T) { + // Default fakeServer does not sign replies — the unhardened case. + addr := startFakeServer(t, &fakeServer{secret: "s3cret"}) + r := (BlastRADIUS{}).Run(context.Background(), target(addr, "s3cret")) + if r.Status != StatusWarn { + t.Fatalf("unsigned reply: got %s (%s), want warn", r.Status, r.Summary) + } + if r.Fields[BlastRADIUSField] != "unsigned" { + t.Errorf("field: got %q, want unsigned", r.Fields[BlastRADIUSField]) + } + // The exposed case must carry the paste-ready remediation. + for _, want := range []string{"require_message_authenticator", "radsec test", "CVE-2024-3596"} { + if !strings.Contains(r.Hint, want) { + t.Errorf("hint missing %q; hint:\n%s", want, r.Hint) + } + } + // Never leak the secret. + for _, out := range []string{r.Summary, r.Detail, r.Hint} { + if strings.Contains(out, "s3cret") { + t.Errorf("secret leaked into result: %q", out) + } + } +} + +func TestBlastRADIUSTimeoutSkips(t *testing.T) { + addr := startFakeServer(t, &fakeServer{secret: "s3cret", silent: true}) + tgt := target(addr, "s3cret") + tgt.Timeout = 300 * time.Millisecond + r := (BlastRADIUS{}).Run(context.Background(), tgt) + if r.Status != StatusSkip { + t.Fatalf("timeout: got %s (%s), want skip", r.Status, r.Summary) + } + if r.Fields[TimeoutField] != "true" { + t.Errorf("timeout result not marked as a timeout") + } +} + +func TestBlastRADIUSSecretMismatchWarns(t *testing.T) { + // Server signs a Message-Authenticator, but with a different secret than the + // probe uses: present but does not validate -> WARN pointing at the secret. + addr := startFakeServer(t, &fakeServer{secret: "s3cret", wrongSecret: "other", signMsgAuth: true}) + r := (BlastRADIUS{}).Run(context.Background(), target(addr, "s3cret")) + if r.Status != StatusWarn { + t.Fatalf("mismatch: got %s (%s), want warn", r.Status, r.Summary) + } + if !strings.Contains(strings.ToLower(r.Summary), "shared secret") { + t.Errorf("summary should point at the shared secret, got %q", r.Summary) + } +} diff --git a/internal/check/check_test.go b/internal/check/check_test.go index fa7bb80..99b434c 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -20,6 +20,7 @@ type fakeServer struct { goodUser string goodPass string silent bool // if true, never replies (simulates unwhitelisted client) + signMsgAuth bool // if true, sign replies with a Message-Authenticator (BlastRADIUS-hardened) acceptAttrs []byte // raw attribute bytes appended to an Access-Accept conn *net.UDPConn } @@ -68,7 +69,16 @@ func (fs *fakeServer) buildReply(req []byte) []byte { // Access-Accept may carry authorization attributes (VLAN/Filter-Id/…). var attrs []byte if code == 2 { - attrs = fs.acceptAttrs + attrs = append(attrs, fs.acceptAttrs...) + } + // A BlastRADIUS-hardened server signs its replies with a + // Message-Authenticator (RFC 3579); reserve its 16-octet slot here and fill + // it once the packet is laid out. + maOff := -1 + if fs.signMsgAuth { + maOff = 20 + len(attrs) + 2 + attrs = append(attrs, 80, 18) // Message-Authenticator, length 18 + attrs = append(attrs, make([]byte, 16)...) } // Build reply: Code, ID, Length, ResponseAuthenticator, Attrs. @@ -82,6 +92,15 @@ func (fs *fakeServer) buildReply(req []byte) []byte { if fs.wrongSecret != "" { signSecret = fs.wrongSecret } + // Message-Authenticator is computed first (its value must be final before + // the Response Authenticator covers the attributes): HMAC-MD5 over the packet + // with the request authenticator in the auth field and the MA field zeroed. + if maOff >= 0 { + copy(reply[4:20], req[4:20]) + mac := hmac.New(md5.New, []byte(signSecret)) + mac.Write(reply) + copy(reply[maOff:maOff+16], mac.Sum(nil)) + } // ResponseAuth = MD5(Code+ID+Len+RequestAuth+Attrs+Secret) h := md5.New() h.Write(reply[0:4]) diff --git a/internal/radius/msgauth.go b/internal/radius/msgauth.go new file mode 100644 index 0000000..4215977 --- /dev/null +++ b/internal/radius/msgauth.go @@ -0,0 +1,72 @@ +package radius + +import ( + "crypto/hmac" + "crypto/md5" + "encoding/binary" +) + +// VerifyMessageAuthenticator reports whether a reply we received carries a +// Message-Authenticator attribute (RFC 3579 §3.2) and, if so, whether it is +// valid for the request we sent. This is the observation behind the +// BlastRADIUS (CVE-2024-3596) posture check: a server that signs its replies +// closes off the RADIUS/UDP reply-forgery class for this client entry. +// +// - present=false: the reply has no Message-Authenticator at all — the +// server accepted our (signed) request but did not sign its answer. +// - present=true, valid=false: a Message-Authenticator is there but does not +// verify with this shared secret (a wrong secret, or a malformed/tampered +// attribute). +// - present=true, valid=true: the server signed its reply correctly. +// +// raw is the exact bytes received; reqAuth is the Request Authenticator we sent +// (RFC 3579 keys the reply HMAC off the request's authenticator, not the +// reply's). This never mutates raw and never surfaces the secret. +func VerifyMessageAuthenticator(raw []byte, reqAuth [16]byte, secret string) (present, valid bool) { + if len(raw) < 20 { + return false, false + } + length := int(binary.BigEndian.Uint16(raw[2:4])) + if length < 20 || length > len(raw) { + return false, false + } + + // Find the first Message-Authenticator attribute and note where its 16-octet + // value sits, so we can zero it for the HMAC and compare against the original. + off := -1 + pos := 20 + for pos+2 <= length { + atype := AttrType(raw[pos]) + alen := int(raw[pos+1]) + if alen < 2 || pos+alen > length { + return present, false // truncated attribute: can't validate + } + if atype == AttrMessageAuthenticator { + if alen != 18 { + // Wrong length for a Message-Authenticator: present but not valid. + return true, false + } + off = pos + 2 + present = true + break + } + pos += alen + } + if !present { + return false, false + } + + // RFC 3579 §3.2: HMAC-MD5 over (Type, Identifier, Length, Request + // Authenticator, Attributes) with the Message-Authenticator value taken as + // sixteen octets of zero. Work on a copy so raw is untouched. + buf := make([]byte, length) + copy(buf, raw[:length]) + copy(buf[4:20], reqAuth[:]) // reply carries the Response Auth; the HMAC uses the Request Auth + for i := off; i < off+16; i++ { // zero the signature field + buf[i] = 0 + } + mac := hmac.New(md5.New, []byte(secret)) + mac.Write(buf) + want := mac.Sum(nil) + return true, hmac.Equal(raw[off:off+16], want) +} diff --git a/internal/radius/msgauth_test.go b/internal/radius/msgauth_test.go new file mode 100644 index 0000000..1705b2a --- /dev/null +++ b/internal/radius/msgauth_test.go @@ -0,0 +1,124 @@ +package radius + +import ( + "crypto/hmac" + "crypto/md5" + "encoding/binary" + "testing" +) + +// signReply builds a reply packet (code, echoed id) carrying the given extra +// attributes and, when signMsgAuth is set, a correct Message-Authenticator +// computed the way a compliant server does (RFC 3579 §3.2: keyed off the +// request authenticator, signature field zeroed during the HMAC). +func signReply(code Code, reqAuth [16]byte, secret string, signMsgAuth bool, extra []byte) []byte { + attrs := append([]byte(nil), extra...) + var maOff int + if signMsgAuth { + maOff = 20 + len(attrs) + 2 + attrs = append(attrs, byte(AttrMessageAuthenticator), 18) + attrs = append(attrs, make([]byte, 16)...) + } + pkt := make([]byte, 20+len(attrs)) + pkt[0] = byte(code) + pkt[1] = 7 // identifier + binary.BigEndian.PutUint16(pkt[2:4], uint16(len(pkt))) + // Response Authenticator field: any value — the reply MA HMAC ignores it and + // uses the request authenticator instead. + copy(pkt[4:20], reqAuth[:]) + copy(pkt[20:], attrs) + + if signMsgAuth { + mac := hmac.New(md5.New, []byte(secret)) + mac.Write(pkt) + copy(pkt[maOff:maOff+16], mac.Sum(nil)) + } + return pkt +} + +func TestVerifyMessageAuthenticator(t *testing.T) { + var reqAuth [16]byte + for i := range reqAuth { + reqAuth[i] = byte(i + 1) + } + const secret = "s3cret" + + t.Run("valid signed reply", func(t *testing.T) { + raw := signReply(AccessAccept, reqAuth, secret, true, nil) + present, valid := VerifyMessageAuthenticator(raw, reqAuth, secret) + if !present || !valid { + t.Fatalf("got present=%v valid=%v, want true/true", present, valid) + } + }) + + t.Run("valid signed reject with other attributes", func(t *testing.T) { + // A Reply-Message (type 18) before the Message-Authenticator, to prove the + // offset math holds when the MA is not the first attribute. + extra := append([]byte{byte(AttrReplyMessage), 5}, []byte("bye")...) + raw := signReply(AccessReject, reqAuth, secret, true, extra) + present, valid := VerifyMessageAuthenticator(raw, reqAuth, secret) + if !present || !valid { + t.Fatalf("got present=%v valid=%v, want true/true", present, valid) + } + }) + + t.Run("no message-authenticator", func(t *testing.T) { + raw := signReply(AccessAccept, reqAuth, secret, false, nil) + present, valid := VerifyMessageAuthenticator(raw, reqAuth, secret) + if present || valid { + t.Fatalf("got present=%v valid=%v, want false/false", present, valid) + } + }) + + t.Run("tampered signature", func(t *testing.T) { + raw := signReply(AccessAccept, reqAuth, secret, true, nil) + raw[len(raw)-1] ^= 0xff // flip a byte of the MA value + present, valid := VerifyMessageAuthenticator(raw, reqAuth, secret) + if !present || valid { + t.Fatalf("got present=%v valid=%v, want true/false", present, valid) + } + }) + + t.Run("wrong secret", func(t *testing.T) { + raw := signReply(AccessAccept, reqAuth, secret, true, nil) + present, valid := VerifyMessageAuthenticator(raw, reqAuth, "different") + if !present || valid { + t.Fatalf("got present=%v valid=%v, want true/false", present, valid) + } + }) + + t.Run("short packet", func(t *testing.T) { + if present, valid := VerifyMessageAuthenticator([]byte{1, 2, 3}, reqAuth, secret); present || valid { + t.Fatalf("short packet: got present=%v valid=%v, want false/false", present, valid) + } + }) +} + +// TestRequestAlwaysCarriesMessageAuthenticator pins constraint #1: every +// Access-Request the probe encodes carries a valid Message-Authenticator, so a +// hardened (BlastRADIUS-mitigated) server accepts it and the posture check can +// observe the reply. A regression here would silently break both. +func TestRequestAlwaysCarriesMessageAuthenticator(t *testing.T) { + const secret = "s3cret" + p, err := NewAccessRequest(1) + if err != nil { + t.Fatal(err) + } + p.AddString(AttrUserName, "authhound-probe") + wire, err := p.encode(secret) + if err != nil { + t.Fatal(err) + } + + // The request's own Message-Authenticator is keyed off its own authenticator + // (which sits in bytes 4:20 of the request), so verify with that. + var reqAuth [16]byte + copy(reqAuth[:], wire[4:20]) + present, valid := VerifyMessageAuthenticator(wire, reqAuth, secret) + if !present { + t.Fatal("encoded Access-Request has no Message-Authenticator") + } + if !valid { + t.Fatal("encoded Access-Request Message-Authenticator does not validate") + } +} diff --git a/test/README.md b/test/README.md index 10f340a..b859365 100644 --- a/test/README.md +++ b/test/README.md @@ -55,6 +55,17 @@ $ go run ./cmd/authhound-probe radius test \ --server 127.0.0.1:11812 --secret testing123 --pap alice:pw --count 10 ``` +The `unhardened` profile starts an **older FreeRADIUS (3.2.3)** that predates the +CVE-2024-3596 ("BlastRADIUS") reply-signing fix, published on `127.0.0.1:11813` — +the fixture for the BlastRADIUS posture check's **WARN** (unsigned reply). The +default `latest` server signs its replies and reports **PASS**: + +```console +$ docker compose -f test/lab/docker-compose.yml --profile unhardened up +$ go run ./cmd/authhound-probe radius test \ + --server 127.0.0.1:11813 --secret testing123 # -> BlastRADIUS posture WARN +``` + ### Run the UDP checks ```console diff --git a/test/freeradius-smoke.sh b/test/freeradius-smoke.sh index 28863ca..bed1554 100755 --- a/test/freeradius-smoke.sh +++ b/test/freeradius-smoke.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")/.." SECRET="testing123" work="$(mktemp -d)" -trap 'rm -rf "$work"; docker rm -f ah-freeradius ah-freeradius-nc ah-freeradius-flaky ah-netem >/dev/null 2>&1 || true' EXIT +trap 'rm -rf "$work"; docker rm -f ah-freeradius ah-freeradius-nc ah-freeradius-flaky ah-netem ah-unhardened >/dev/null 2>&1 || true' EXIT # One test user for the PAP check, plus a machine identity for the NPS-style # machine-auth check. This file replaces the default authorize file; a single @@ -132,6 +132,60 @@ echo "== correct secret + PAP + PEAP-MSCHAPv2 + EAP-TTLS + EAP-TLS + MTU (expect --pap 'alice:pw' --peap 'alice:pw' --ttls 'alice:pw' \ --client-cert "$work/cert.pem" --client-key "$work/key.pem" --mtu --no-color || true +echo +echo "== BlastRADIUS posture: hardened FreeRADIUS signs replies (expect PASS) ==" +# The probe always includes a Message-Authenticator in its request; a patched +# (post-CVE-2024-3596) FreeRADIUS echoes one in its reply. The posture check +# observes that and PASSes. This is observation only — a normal exchange. +set +e +bp_hard="$("$work/authhound-probe" radius test --server 127.0.0.1 --secret "$SECRET" --no-color)" +bp_hard_json="$("$work/authhound-probe" radius test --server 127.0.0.1 --secret "$SECRET" --json)" +set -e +echo "$bp_hard" | grep -qi "signs its replies with Message-Authenticator" || { echo "FAIL: hardened server should PASS the BlastRADIUS posture check"; exit 1; } +echo "$bp_hard_json" | grep -q '"blastradius_posture": "signed"' || { echo "FAIL: --json should report blastradius_posture=signed"; exit 1; } +if echo "$bp_hard$bp_hard_json" | grep -q "$SECRET"; then echo "FAIL: secret leaked into BlastRADIUS output"; exit 1; fi +echo "OK: hardened FreeRADIUS signs replies -> BlastRADIUS posture PASS (signed)" + +echo +echo "== BlastRADIUS posture: unhardened FreeRADIUS does NOT sign (expect WARN) ==" +# FreeRADIUS 3.2.3 predates the CVE-2024-3596 reply-signing fix: it accepts our +# signed request but replies WITHOUT a Message-Authenticator — the exposed state +# most un-upgraded servers are still in, and the one config a knob can't undo on +# a patched build. Same lab server, older (unhardened) build, on a published +# bridge port so it doesn't clash with the host-net one. Permissive client entry +# (any source IP) because requests arrive via the Docker bridge gateway. +# Throwaway lab secret, never production. +cat > "$work/clients-unhardened" <<'EOF' +client lab { + ipaddr = 0.0.0.0/0 + secret = testing123 +} +EOF +docker run -d --rm --name ah-unhardened -p 127.0.0.1:11813:1812/udp \ + -v "$work/authorize:/etc/freeradius/mods-config/files/authorize:ro" \ + -v "$work/clients-unhardened:/etc/freeradius/clients.conf:ro" \ + freeradius/freeradius-server:3.2.3 -fxx -l stdout >/dev/null +for i in $(seq 1 30); do + if docker logs ah-unhardened 2>&1 | grep -q "Ready to process requests"; then break; fi + sleep 0.5 +done +# The BlastRADIUS check must WARN. (The old 3.2.3 image also ships an expired +# bootstrap server cert, so the run as a whole FAILs on server-cert — unrelated +# to this posture; the WARN-doesn't-flip-exit semantics are covered by the RadSec +# cert-expiry --strict test below, so we only assert the posture result here.) +set +e +bp_warn="$("$work/authhound-probe" radius test --server 127.0.0.1:11813 --secret "$SECRET" --no-color)" +bp_warn_json="$("$work/authhound-probe" radius test --server 127.0.0.1:11813 --secret "$SECRET" --json)" +set -e +echo "$bp_warn" | grep -A6 -i "BlastRADIUS-exposed" +echo "$bp_warn" | grep -qi "replied WITHOUT Message-Authenticator" || { echo "FAIL: unhardened server should WARN on the BlastRADIUS posture check"; exit 1; } +echo "$bp_warn" | grep -q "require_message_authenticator" || { echo "FAIL: WARN hint should point at require_message_authenticator"; exit 1; } +echo "$bp_warn" | grep -q "radsec test" || { echo "FAIL: WARN hint should point at radsec as the durable fix"; exit 1; } +echo "$bp_warn_json" | grep -q '"blastradius_posture": "unsigned"' || { echo "FAIL: --json should report blastradius_posture=unsigned"; exit 1; } +if echo "$bp_warn$bp_warn_json" | grep -q "$SECRET"; then echo "FAIL: secret leaked into BlastRADIUS output"; exit 1; fi +docker rm -f ah-unhardened >/dev/null 2>&1 || true +echo "OK: unhardened FreeRADIUS omits reply Message-Authenticator -> BlastRADIUS posture WARN (unsigned)" + echo echo "== machine auth: host/ identity via PEAP-MSCHAPv2 (NPS-style, expect PASS) ==" # A computer account authenticates with a "host/NAME.domain" identity and the diff --git a/test/lab/docker-compose.yml b/test/lab/docker-compose.yml index 47e5aa2..ac7f418 100644 --- a/test/lab/docker-compose.yml +++ b/test/lab/docker-compose.yml @@ -48,6 +48,30 @@ services: - ./config/authorize:/etc/raddb/mods-config/files/authorize:ro - ./config/clients-flaky:/etc/raddb/clients.conf:ro + # Unhardened profile: an OLDER FreeRADIUS (3.2.3) that predates the + # CVE-2024-3596 ("BlastRADIUS") reply-signing fix. It accepts the probe's + # signed Access-Request but replies WITHOUT a Message-Authenticator, so + # `radius test` reports the BlastRADIUS posture check as WARN (unsigned) — + # the state most un-upgraded servers are still in. The current `latest` + # server above signs its replies and reports PASS. + # + # docker compose -f test/lab/docker-compose.yml --profile unhardened up + # authhound-probe radius test --server 127.0.0.1:11813 --secret testing123 + # + # Bridge network with a published port (so it doesn't clash with the host-net + # server); requests arrive from the bridge gateway, so it accepts any source IP + # (throwaway lab secret, never production). Note the 3.2.x image's config dir is + # /etc/freeradius, not /etc/raddb. + freeradius-unhardened: + image: freeradius/freeradius-server:3.2.3 + profiles: [unhardened] + command: ["-fxx", "-l", "stdout"] + ports: + - "127.0.0.1:11813:1812/udp" + volumes: + - ./config/authorize:/etc/freeradius/mods-config/files/authorize:ro + - ./config/clients-flaky:/etc/freeradius/clients.conf:ro + # netem sidecar: joins the flaky server's network namespace and drops ~25% of # its replies with 30ms±20ms delay — lost requests and latency jitter, the two # things --count exists to expose. NET_ADMIN is scoped to the sidecar and that