SmartHire turns MLflow into initial access
I break down SmartHire’s MLflow bypass, pickle RCE, and writable plugin privesc into a copyable attack path.

SmartHire shows how an old MLflow box falls to auth bypass, pickle RCE, and plugin privesc.
I’ve been on enough HTB boxes to know when a machine is trying too hard to look modern. SmartHire does that. The UI is all “AI-first hiring,” the dashboards are polished, and the app flow feels like someone stapled machine learning onto a normal web app and hoped nobody would ask questions. But once I started poking at it, the whole thing felt off in the usual annoying way: one app for the public site, another for models, a couple of CSV upload paths, and a privileged script glued together with whatever Python files happened to be writable. That’s the kind of setup that makes me sigh, because I already know where this is going.
The first irritation was the MLflow side. You hit the models subdomain and it’s clearly old, exposed, and not bothering to hide it. Then you get the usual “maybe there’s a clean auth bypass, maybe there’s a weird deserialization angle, maybe the app authors left the door open by accident” routine. And yeah, they did. The fun part here isn’t that SmartHire is flashy. It’s that every layer is a little too trusting: the web app trusts uploaded data, MLflow trusts a bad auth boundary, and a privileged management script trusts a Python plugin directory it shouldn’t. That’s the whole machine in one sentence.
I’m breaking it down the way I would if I were writing notes for myself after the box, not the way a product blog would frame it. The source writeup that kicked this off is 1337 Sheets’ SmartHire walkthrough, which is where the MLflow version, auth bypass idea, and plugin privesc path were laid out. I’m using that as the anchor, then I’m translating the moving parts into something you can actually reuse when you run into a similar mess.
Start by treating the box like two apps, not one
Get the latest AI news in your inbox
Weekly picks of model releases, tools, and deep dives — no spam, unsubscribe anytime.
No spam. Unsubscribe at any time.
“The models.smarthire.htb subdomain was identified as an instance of MLflow, a platform for the machine learning lifecycle.”
What this actually means is that the public site and the model platform are separate trust zones. That matters because I see people waste time brute-forcing the main app when the real weakness is sitting on a sibling vhost with a different auth model entirely. On SmartHire, the main site gives you the hiring workflow, while MLflow is doing the model management on models.smarthire.htb. Once you see that split, the attack surface gets much easier to reason about.

I’ve hit this pattern before on internal apps too. Product teams love to expose one polished frontend and one “temporary” admin or ML service, then they forget the second thing is still internet-facing. That’s not a side detail. That’s the box.
How to apply it: enumerate vhosts early, not after you’ve exhausted the main site. In this writeup, the author used ffuf with a Host header to find models.smarthire.htb. I’d do the same on any app that smells like it has a public UI plus a hidden service. Check for redirects, inspect cookies, and don’t assume the visible domain is the only useful one.
- Scan ports first, then check host routing behavior.
- Fuzz subdomains with a Host header, not just DNS.
- Map every discovered vhost into
/etc/hostsso you stop fighting redirects.
Old MLflow is not a detail, it’s the attack plan
The writeup says the instance was running MLflow 2.14.1, and that’s old enough to matter. The author points to MLflow as the platform and then calls out CVE-style research around authentication bypass and RCE paths. The important part is not the exact version number by itself. It’s the habit of checking whether the service is behind on patches before you spend an hour trying to invent an exploit from scratch.
What this actually means is simple: once you know the service and version, your first job is to ask, “What broke in this release line?” SmartHire’s MLflow instance was old enough that the walkthrough could use an auth bypass with default credentials, which is the sort of thing that makes every defender in the room groan because it usually means nobody touched the service after deployment.
I ran into this same mistake in a lab years ago where the team had exposed an ML service and never rotated the default admin access. It didn’t matter how pretty the frontend was. The backend was a cardboard box with a lock sticker on it.
How to apply it: identify the service, pin the version, then search for known issues in that exact range. Don’t just search “MLflow RCE.” Search the version line, the auth layer, and the model-loading behavior separately. If you’re lucky, the auth layer gives you a faster path than the payload research.
- Check the server banner and app footer for version hints.
- Look for default credentials or weak setup assumptions.
- Search by version range, not just product name.
CSV upload paths are usually where the app tells on itself
SmartHire’s public site exposes “Train Model” and “Make Predictions,” both driven by CSV uploads. That’s the kind of feature I immediately treat like a test harness that escaped into production. CSV sounds harmless until you remember it’s often the bridge between user-controlled data and Python code that expects the world to be well-behaved.

The writeup says the dashboard accepts CSV files in a specific format, and that’s exactly where I’d start looking for parser assumptions, model serialization, and weird file handling. In machine-learning apps, the dangerous part is rarely “upload a file” on its own. It’s what the app does after upload: train, deserialize, score, import, or shell out.
I’ve seen teams think they’re safe because they limit file size to 2MB. That’s cute. If the parser is the problem, 2MB is more than enough to hurt you.
How to apply it: when you see CSV upload in a Python app, ask three questions. What library parses it? What happens after parse? Does the app store, import, or re-use any part of the uploaded artifact later? Those answers usually tell you whether you’re looking at data ingestion or code execution waiting to happen.
Here’s the practical checklist I use:
- Test malformed rows, extra columns, and delimiter abuse.
- Look for downstream serialization formats like Pickle, joblib, or YAML.
- Check whether uploaded content gets reused by another service or job.
Pickle is where the clean-looking path gets ugly
The writeup lands on a Pickle deserialization attack after the MLflow bypass. That makes sense, because Python Pickle has been a footgun for years. If a service unpickles attacker-controlled data, you’re no longer “uploading a model.” You’re asking Python to reconstruct objects from bytes you influence. That’s not a safe ask.
The interesting wrinkle in SmartHire is that the first attempts didn’t work cleanly because of Python version mismatches and missing dependencies. That’s the part people skip when they summarize a box, and it’s the part I care about. Real exploitation is usually less “one magic payload” and more “the payload almost works, then the environment ruins your afternoon.”
The author dug into a legitimate python_model.pkl with unpickle.py and pickletools, then found that the model expected a local module path: utils/simplehiringmodel.py. That tells you the exploit path wasn’t just “serialize shellcode.” It was “make the target believe the object graph is valid, then smuggle in behavior through the import path.” That’s a much more realistic failure mode.
I’ve had to do this on test rigs where the payload kept dying because the target environment didn’t match my local one. The fix was almost never “try harder.” It was “recreate the target’s module layout and stop assuming the exploit can be generic.”
How to apply it: when Pickle is involved, inspect a legitimate artifact first. Don’t start from the malicious payload. Start from the expected object structure, module names, and import paths. If the target imports local helpers, mirror them. If the app expects a specific Python version, match it. The exploit usually becomes obvious once the environment stops fighting you.
Default credentials are boring, which is exactly why they work
The walkthrough mentions admin:password working against MLflow after the auth bypass research. I know, I know. It feels too dumb to be real. But that’s the point. In a lot of CTF-style targets, the path is a mix of an actual vulnerability and a setup mistake that makes the vuln easier to reach. The auth bypass gets you in the door; the default credentials keep the door from slamming shut again.
What this actually means is that you should stop treating “default creds” as a beginner-only check. I’ve seen seasoned people skip it because it feels beneath them, then spend forty minutes on a harder path that wasn’t needed. If a service is old, exposed, and looks like it was stood up by copy-pasting docs, try the obvious login first. Save your pride for later.
How to apply it: after you identify an admin panel or model service, test the documented defaults, then test the defaults tied to the product version you found. If the app has a setup wizard, assume someone may have left it in a half-configured state. And if one auth path fails, look for a sibling endpoint that hasn’t been wired into the same controls.
Privilege escalation came from a writable plugin directory, which is exactly as bad as it sounds
The second half of the box is where the annoyance really starts. The writeup says privilege escalation happened through a writable Python plugin directory used by a high-privilege management script. That’s a classic “someone thought extensibility was harmless” problem. It isn’t harmless. A plugin directory is code execution with extra steps if the permissions are sloppy.
What this actually means is that the script is probably running as a more privileged user, loading Python modules from a path the attacker can modify, and trusting those imports at runtime. If I can write to that directory, I don’t need to break crypto or guess a password. I just need to replace or add code and wait for the privileged process to import it.
I’ve seen this exact failure in internal tooling where a service account owned a script directory but not the files inside it. One bad chmod later, the “plugin” system became a root shell. The lesson was not subtle.
How to apply it: during post-exploitation, enumerate writable paths that are consumed by scheduled jobs, service wrappers, or management scripts. Look for Python import locations, cron jobs, systemd timers, and anything that dynamically loads code. If the directory is writable and the consuming process is privileged, you’ve got a privesc candidate.
- Check
sudo -l, cron, systemd timers, and app-specific maintenance scripts. - Trace every writable directory that appears in Python
sys.pathor plugin config. - Look for importable filenames, not just executable scripts.
What I’d actually do on a box like this
If I were solving SmartHire from scratch, I’d keep the workflow tight. First, confirm the main site and enumerate vhosts. Second, fingerprint the model service and check the version against known MLflow issues. Third, test the obvious auth boundary and default credentials before I build anything fancy. Fourth, inspect legitimate model artifacts to understand the serialization format. Fifth, after initial access, hunt for writable code paths before I go chasing noisy privilege escalation tricks.
This is the part where I get opinionated: boxes like this reward discipline more than creativity. People love to jump straight to payload crafting because it feels clever. Usually it just burns time. The better move is to let the application tell you where it’s weak. SmartHire basically screams it: separate services, old model stack, user-controlled CSVs, and a privileged script that imports from a writable directory. That’s enough to build a clean chain if you stay patient.
One more practical note: if you’re documenting a similar machine, keep the exploit chain in the same order you discovered it. Vhost first, auth bypass second, deserialization third, privesc last. Readers need the logic, not just the shell transcript.
The template you can copy
# SmartHire-style ML app checklist
## 1) Enumerate the split surface
- Scan ports
- Fuzz vhosts with Host headers
- Map every useful hostname into /etc/hosts
- Identify which host is the public app and which host is the model/admin service
## 2) Fingerprint the ML service
- Capture version strings, banners, and page footers
- Check the exact version against known CVEs
- Test documented defaults and common setup credentials
- Verify whether auth is enforced consistently across endpoints
## 3) Inspect upload-driven features
- Find CSV, JSON, model, or archive upload paths
- Ask what parser handles the file
- Ask what happens after upload: train, store, import, score, or deserialize
- Look for Pickle, joblib, YAML, or custom plugin loading
## 4) Recreate the target environment
- Extract a legitimate artifact if possible
- Inspect module names, import paths, and object structure
- Match Python version and dependency expectations
- Rebuild missing local modules if the payload depends on them
## 5) Hunt for privilege escalation
- List writable directories consumed by privileged jobs
- Check cron, systemd timers, and app maintenance scripts
- Look for plugin directories or import paths you can write to
- Replace or add importable code and wait for execution
## 6) Writeup order
- Recon
- Vhost discovery
- Service fingerprinting
- Auth bypass or initial access
- Payload construction
- Privilege escalation
- Lessons learned
## Reusable notes format
- Hostname:
- Port:
- Service:
- Version:
- Auth issue:
- Upload surface:
- Serialization format:
- Writable code path:
- Privilege boundary:
- Final shell path:
The template above is the part I’d keep around for the next time I run into a fake-modern ML app with a real security problem hiding underneath it. It’s not magic, but it saves me from wandering around the box like I forgot how web apps work.
Original source: https://1337sheets.com/hack-the-box-htb-smarthire-writeup-medium-weekly-may-16th-2026/. My breakdown is derivative of that walkthrough, with the framing, explanation, and copy-ready checklist rewritten from my own developer notes.
// Related Articles
- [TOOLS]
Aliyun Bailian Token Plan turns credits into agents
- [TOOLS]
One API gateway turns six AI APIs into one
- [TOOLS]
OpenAI FDEs turn broken agents into shipped systems
- [TOOLS]
Anthropic’s daily brief turns news into a workflow
- [TOOLS]
Claude Reflect turns usage into retention
- [TOOLS]
Midjourney turns prompt ideas into art