Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/authhound-probe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
4 changes: 2 additions & 2 deletions docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
108 changes: 108 additions & 0 deletions internal/check/blastradius.go
Original file line number Diff line number Diff line change
@@ -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 <host>

Background: https://blastradius.fail / CVE-2024-3596`
69 changes: 69 additions & 0 deletions internal/check/blastradius_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 20 additions & 1 deletion internal/check/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -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])
Expand Down
72 changes: 72 additions & 0 deletions internal/radius/msgauth.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading