We uploaded a package. We got root.

The client gave us a one-sentence scope: test the web application reachable from the internet. Mail had DMARC set to reject, traffic ran over TLS 1.3, login locked after a run of failed attempts, and in the application logic we found not a single privilege escalation.
And yet one “upload package” button handed us the whole server, with root rights, without a single advanced trick. The account it took did not even have to be an admin account.
Here is how an ordinary feature for uploading a package of files turned into remote code execution, with a mapping to MITRE ATT&CK.
Context
We came across an application that did a lot of things right. All traffic ran over HTTPS, with HSTS and a forced redirect from HTTP. The certificate and cipher were current. Passwords followed a strict policy, and an account locked after a few missed logins, so brute force was out right away. The session cookie carried the __Host- prefix, requests carried anti-CSRF tokens. We found no SQL injection, no IDOR, and no way to jump horizontal or vertical authorization in the application logic itself.
We had not run into a fence full of holes, but a wall someone had built with care. And the more evenly the bricks are laid, the more tempting it is to find the one that wobbles.
One of the application's features lets you upload packages of files, plain ZIP archives: a manifest and a set of assets, including web content (HTML, JavaScript, CSS). Whoever imports the package controls what is inside the archive. And that is the whole secret of this story.
Challenge
The catch in a system like this is sneaky, because the package upload is not a bug. It is a deliberate feature, not a defect. You cannot “switch it off,” the way you cannot remove the mailbox at a company that lives on correspondence. The difficulty is not that a user can upload a file. It is where that file lands and what the server takes it for.
The contents of such a package are untrusted. They should sit beside the application like a photo in an album, a static asset the browser will at most display. In this application they landed in the live application directory, the same place an Apache Tomcat server compiles and runs JSP files from on demand. The package was supposed to be a picture in the album, and it became a program the server happily ran.
A second thing, easy to miss, sat on top of that. The import needed content-management rights, not admin rights. It sounds harmless. In practice it means the person whose job is to drop in content files got a path to running operating-system commands. The distance between “I can upload a package” and “I am root” should be infinite. Here it was one upload.
What we did
We worked in a grey box model, with sample accounts and basic knowledge of the infrastructure, following the OWASP methodology. First, recon: we mapped the stack (a Java application on a Tomcat-based server, a Postgres database) and every place where a user enters data. Then we turned to the package import feature.
We built a valid package. Nothing exotic, a correct manifest and two extra files:
paczka.zip ├── manifest.xml ├── index.html └── proof.jsp
The import mechanism did not block the JSP file in the archive. The package unpacked in full, and every file became available as a package asset. The upload alone ran nothing yet. Execution came when we opened the contents of the uploaded package and the server reached for the JSP file. Instead of returning it as text, it passed it through the JSP engine and ran it on its side.
The output of the id command settled the matter:
$ id uid=0(root) gid=0(root) grupy=0(root)
The JSP file was not served as a static package asset. It was interpreted and run by the application server, with root rights. To be sure, we also set up an outbound connection to a host we controlled, and the server called back to us without resistance. We had command execution and outbound traffic off the machine. Because the server process ran as root and the environment was not properly locked down, there was no need to fuss with any escalation. A plain reverse shell was enough to take over the server, with access to the data and the code.
While we were at it, we checked the same entry points from the other side. Instead of a JSP file, we dropped our own JavaScript into index.html. The package content ran in the same origin as the application, with no isolation at all. Our script read the application's localStorage, reached into the parent document, and could send authenticated requests in the context of the victim's session. If an administrator opened such a package, the script could use their hands to create an account with elevated privileges. One package, two different uses, depending on what the attacker happened to need.
The whole chain, in the language the blue team speaks:
Beyond the main story, we left the client a few smaller things to clean up. A login that asked for selected characters of the credential instead of the whole thing could be bypassed by stripping the parameters that said which positions were being checked. Three query parameters reflected our data into the response without encoding, which gave a reflected XSS. The front end pulled outdated library versions (jQuery, jQuery UI, TinyMCE) with known vulnerabilities. And one more nuance worth thinking through: since an account locks after a run of failed logins, deliberately locking the admin accounts can take the service down. A hard lockout can be a double-edged sword.
Result
A critical vulnerability, remote code execution, full access to the application server with root rights, and with it access to the data and the code. All the solid defense around it, mail, TLS, passwords, sessions, stopped mattering the moment one upload gave code execution on the machine.
We left the recommendations in a form you can tick off, because they are meant to be actions, not wishes:
- Keep uploaded content outside the application directory and outside the server runtime, only as static assets served from a separate origin.
- Block executable files, JSP in particular, but treat this as an extra layer, not the main safeguard.
- Unpack the archive first into a random temporary directory outside the site root, and validate every entry, whether or not it appears in the manifest.
- Check extensions regardless of letter case, accounting for double extensions and differences in path normalization.
- Run uploaded content in a restricted, sandboxed iframe, and route communication with the application through a controlled
postMessagewith origin validation. - Set a CSP as another layer of protection, without
unsafe-inline. - Take root rights away from the server process. A content-management permission has no business ending in command execution with server privileges.
- Encode the data reflected in the three parameters and update the outdated front-end libraries.
If you run a platform that lets users upload “content,” a document, a package, a template, anything, two questions are worth asking. Where does that content really land, and what does your server take it for when someone opens it? We would be glad to check that for you.
Book a call with a consultant. Together we will go over the scope of a web application test, the risks, and what such a vector could look like for you.
All case studies are anonymized by sector, without names, dates or any data that could identify the client, in line with confidentiality. We never publish real vulnerabilities or client technical data.