Skip to content
All writing
Technical · 7 min

My First Metasploit Module to Land: an Audiobookshelf Auth Bypass

CVE-2025-25205 is an unauthenticated auth bypass in Audiobookshelf caused by matching a whitelist regex against the full request URL — query string and all. Here's the bug, the crash-safe detection module I wrote for it, and what it took to get my first PR merged into the Metasploit Framework.


This one’s a milestone post, so let me be precise about the milestone. I did not discover this vulnerability — swiftbird07 did, and published the advisory. What I did was write the Metasploit detection module for it, and on June 22nd that module became the first thing I’ve authored to merge into the Metasploit FrameworkPR #21565, now shipping in the framework as auxiliary/scanner/http/audiobookshelf_auth_bypass. Discovery and detection are different jobs. This post is about the second one.

The bug: a whitelist that reads the whole URL

Audiobookshelf is a self-hosted audiobook and podcast server — the kind of thing that ends up on a home server or a small VPS, reachable from the internet so you can stream on the commute. CVE-2025-25205 (GHSA-pg8v-5jcv-wrvw) is an unauthenticated authentication bypass in versions 2.17.0 through 2.19.0, fixed in 2.19.1.

The root cause is the kind of thing that looks completely reasonable until you stare at it. The server has a short whitelist of paths a GET request is allowed to reach without authentication — cover art and a few other genuinely public assets. To decide whether an incoming request is on that list, it tests a regular expression against the request’s URL. Two mistakes stack:

  1. The regex is unanchored — it looks for the whitelisted substring anywhere in the string, not at the start.
  2. It’s matched against the full original URL, including the query string, not the normalized path.

Put those together and the whitelist becomes trivially forgeable. You ask for a protected endpoint and simply append a query parameter whose value contains a whitelisted substring:

GET /api/libraries?r=/api/items/1/cover

The path the server actually routes is /api/libraries — protected. But the auth-skip regex sees the whole string, finds /api/items/1/cover sitting in the query, matches, and waves the request through. An unauthenticated client now reaches authenticated API endpoints. The query parameter is inert; it’s there purely to poison a string comparison.

Why detection, and why carefully

I write these as detection modules, not exploits. The question a defender needs answered is is this specific host vulnerable — not what can I take from it. So the module confirms the flaw and stops there: it never enumerates a library, reads an item, or touches user data.

That restraint isn’t just principle here — it’s a safety requirement, because this CVE has a second half. The same bypass against certain endpoints (/api/users, for one) crashes the server process: the handler runs assuming an authenticated user, dereferences the now-undefined user object, and takes the process down. That’s a denial of service, and it is exactly the behavior a detection module must not trigger against a host it’s only meant to check. So the module deliberately probes /api/libraries, which exercises the same bypass without crashing the box, and the module is marked CRASH_SAFE. The interesting design work in a detection module is often the list of things it refuses to do.

The detection: fingerprint, then a differential

A version banner alone is a weak verdict — it tells you what the server claims, not whether it’s actually exploitable behind a reverse proxy or a backport. So the module does two things.

Fingerprint via the unauthenticated /status endpoint, which every Audiobookshelf instance exposes. If the response isn’t JSON with app equal to audiobookshelf, this isn’t our target and the module bails. Otherwise it reads serverVersion.

Confirm with a differential. This is the part I trust. The module sends two requests to the protected /api/libraries endpoint:

  • a baseline request with no tricks, which on any sane server must come back 401 Unauthorized;
  • a bypass request carrying ?r=/api/items/1/cover, the whitelisted substring in the query.

On a vulnerable server the baseline is rejected (401) and the bypass is processed — it reaches the handler and returns 200, or 500 if the handler trips over the undefined user. On a patched server both requests return 401. The verdict comes from the difference between the two, not from either one alone:

def auth_bypassed?
  baseline = send_request_cgi(
    'method' => 'GET',
    'uri' => normalize_uri(target_uri.path, 'api', 'libraries')
  )
  return false unless baseline && baseline.code == 401

  bypass = send_request_cgi(
    'method' => 'GET',
    'uri' => normalize_uri(target_uri.path, 'api', 'libraries'),
    'vars_get' => { 'r' => '/api/items/1/cover' }
  )
  return false unless bypass

  bypass.code == 200 || bypass.code == 500
end

Requiring the baseline 401 first is what keeps a misconfigured or wide-open server from reading as a false positive: if the protected endpoint isn’t protected to begin with, there’s no bypass to confirm and the module doesn’t claim one.

The version number then becomes a fallback, not the verdict. If the differential confirms, the result is Vulnerable. If it doesn’t but the reported version sits in the affected 2.17.0–2.19.0 range, the module returns Appears — “in range but I couldn’t confirm it” — rather than overstating what it actually proved. Anything else is Safe.

Proving it in the lab

I verified against the real software on both sides of the fix, using the official images: an instance in the affected range returned Vulnerable with the bypass confirmed, and a 2.19.1 instance returned Safe. Then I re-ran check repeatedly to make sure the result was stable and not a timing artifact. A detection that flips between runs is worse than no detection — it teaches you to distrust it.

What landing the first one taught me

The lesson wasn’t the bug; the bug is a tidy lesson in why you normalize a path before you make a security decision about it, and never against the query string. The lesson was about the merge.

Getting a module into a framework like Metasploit means writing for the maintainer, not just for the target. The credit line names both authors — swiftbird07 for the discovery, me for the module — because that distinction matters and the framework is rigorous about it. The References point at the CVE, the GHSA, and the fix commit so anyone can trace the claim. The CRASH_SAFE and IOC_IN_LOGS notes tell an operator exactly what running it will and won’t do to their host. None of that is the clever part of the code, and all of it is why the code is allowed to exist in a tool other people point at production systems.

It’s one module. Six more are still in review, and I expect each will teach me something the last one didn’t. But the gap between “I wrote a thing that works on my machine” and “a thing that ships in a tool defenders actually run” is the gap I’m trying to close, and this is the first time I’ve closed it.

Share LinkedInXBlueskyReddit