Security Azure

Pwndoc on Ubuntu 24.04 on Azure User Guide

| Product: Pwndoc on Ubuntu 24.04 LTS on Azure

Overview

This image runs Pwndoc 1.6.0, the open source penetration test reporting platform, on Ubuntu 24.04 LTS. Pwndoc replaces the copy-and-paste report workflow with a shared workspace: a reusable vulnerability knowledge base holds your standard findings with their descriptions, remediation advice and CVSS scoring, and each engagement draws from that base instead of starting from a blank document. Testers write findings collaboratively with live updates between everyone in the same audit, attach evidence images, record scope and network hosts, and export the finished engagement to a Word document generated from your own DOCX template.

Upstream Pwndoc ships only as a Docker Compose stack. This image is a native systemd install with no container runtime: the Node.js backend runs from /opt/pwndoc/backend as an unprivileged pwndoc system account, the Vue/Quasar single page app is pre-built and served as static files, and a local MongoDB 8.0 Community database holds every audit, finding, template and evidence image.

nginx terminates TLS on port 443 and is the only listener exposed to the network. It serves the single page app and reverse-proxies /api and /socket.io to the backend on 127.0.0.1:4242, which in turn talks to MongoDB on 127.0.0.1:27017. The backend and the database are bound to loopback and are never directly reachable. Port 80 issues a permanent redirect to HTTPS: Pwndoc sets its session cookies secure and sameSite=strict, so authentication cannot complete over plain HTTP.

What is included:

  • Pwndoc 1.6.0 built from a pinned upstream release commit, run by systemd as the unprivileged pwndoc user
  • The Pwndoc web UI on :443 over HTTPS, with :80 redirecting to it
  • A per-VM admin account whose password is generated on first boot and recorded in a root-only file
  • A per-VM 4096-bit TLS certificate, also generated on first boot, replacing the private keys that upstream commits to its public repository
  • Per-VM JWT signing secrets and a per-VM MongoDB credential, none of them baked into the image
  • MongoDB 8.0 Community, bound to loopback with authentication enabled, its data directory on a dedicated Azure data disk at /var/lib/pwndoc
  • A usable starter dataset so you can create an audit immediately: the English language, the default DOCX report template, a Security Audit audit type and four vulnerability categories
  • mongod.service, pwndoc.service and nginx.service as systemd units, enabled and active
  • An unauthenticated /healthz endpoint for Azure Load Balancer health probes
  • 24/7 cloudimg support

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point; size up for larger teams and audits with many findings and evidence images. NSG inbound: allow 22/tcp from your management network and 443/tcp for the web interface. Optionally allow 80/tcp so the HTTP-to-HTTPS redirect works for people who type the bare address. Because Pwndoc holds client engagement data, restrict 443/tcp to your own networks or a VPN rather than opening it to the internet.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Pwndoc by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTPS (443). Review the dedicated data disk on the Disks tab, then Review + create then Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name pwndoc \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_ed25519.pub \
  --vnet-name <your-vnet> --subnet <your-subnet> \
  --public-ip-sku Standard

az vm open-port --resource-group <your-rg> --name pwndoc --port 443 --priority 1010

Step 3 - Connect to your VM

ssh azureuser@<vm-public-ip>

Step 4 - Confirm the services are running

nginx on port 443 is the only public listener. It serves the single page app and proxies the API to the backend on 127.0.0.1:4242, which talks to MongoDB on 127.0.0.1:27017. Confirm the services are active and check which sockets are actually exposed:

systemctl is-active mongod pwndoc nginx pwndoc-firstboot
sudo ss -tlnp | grep -E ':80 |:443 |:4242 |:27017 '

Expected output:

active
active
active
active
LISTEN 0  4096  127.0.0.1:27017   0.0.0.0:*  users:(("mongod",pid=...))
LISTEN 0  511   127.0.0.1:4242    0.0.0.0:*  users:(("node",pid=...))
LISTEN 0  511     0.0.0.0:443     0.0.0.0:*  users:(("nginx",pid=...))
LISTEN 0  511     0.0.0.0:80      0.0.0.0:*  users:(("nginx",pid=...))

Note that 4242 and 27017 are bound to 127.0.0.1, not 0.0.0.0. The Pwndoc API and the database are reachable only from the VM itself, so nginx is the single point of ingress.

mongod, pwndoc, nginx and pwndoc-firstboot all active, with the API on 127.0.0.1:4242 and MongoDB on 127.0.0.1:27017 bound to loopback and only nginx listening publicly

Step 5 - Retrieve your admin password

On the first boot of every VM, pwndoc-firstboot.service generates a unique administrator password, a unique MongoDB credential and unique JWT signing secrets, and writes the credentials into a root-only file. Confirm the file exists and is root-only:

sudo ls -l /root/pwndoc-credentials.txt

Expected output:

-rw------- 1 root root 649 ... /root/pwndoc-credentials.txt

Read the file to get your admin password with sudo cat /root/pwndoc-credentials.txt. It contains PWNDOC_URL, PWNDOC_ADMIN_USER, PWNDOC_ADMIN_PASSWORD and MONGO_PASSWORD, all unique to this VM.

The same first-boot service mints the TLS certificate nginx serves, so the private key on your VM was generated on your VM. This matters here: the upstream Pwndoc repository commits working TLS private keys into its source tree, which means every Docker Compose deployment of Pwndoc shares them. This image deletes those keys during the build and generates a fresh 4096-bit keypair per VM instead.

sudo openssl x509 -in /etc/pwndoc/ssl/server.cert -noout -subject -dates

Expected output (the common name is the address your VM resolved for itself, and the dates are one year apart):

subject=O = cloudimg, CN = 10.0.0.21
notBefore=Jul 19 15:25:02 2026 GMT
notAfter=Jul 19 15:25:02 2027 GMT

The per-VM credentials file is 0600 root-only and holds the admin and MongoDB credentials, alongside the 4096-bit TLS certificate generated by pwndoc-firstboot.service on this VM

Step 6 - Confirm the stack, the health endpoint and that the API is closed

nginx serves an unauthenticated /healthz endpoint for load balancer probes. Everything else in the API requires a session. Confirm the component versions, that the app answers, and that an anonymous caller is refused even when it sends a complete, well-formed request body:

node -v
mongod --version | head -1
curl -sk -o /dev/null -w 'healthz            -> %{http_code}\n' https://127.0.0.1/healthz
curl -s  -o /dev/null -w 'http redirect      -> %{http_code}\n' http://127.0.0.1/
curl -sk -o /dev/null -w 'GET  /api/audits   -> %{http_code}\n' https://127.0.0.1/api/audits
curl -sk -o /dev/null -w 'POST /api/clients  -> %{http_code}\n' -X POST https://127.0.0.1/api/clients \
  -H 'Content-Type: application/json' \
  -d '{"company":"Acme","lastname":"Doe","firstname":"Jane","email":"jane@example.com"}'

Expected output:

v22.23.1
db version v8.0.26
healthz            -> 200
http redirect      -> 301
GET  /api/audits   -> 401
POST /api/clients  -> 401

The POST is deliberately sent with a complete body. A request missing required fields could be rejected by input validation before authentication is ever consulted, which would make an open endpoint look protected; sending a valid body proves the 401 came from the authentication layer.

Node.js 22 and MongoDB 8.0 versions, the healthz endpoint returning 200, port 80 redirecting with 301, and unauthenticated API calls refused with 401 and 403

Step 7 - Confirm the administrator bootstrap is closed

Pwndoc creates its first administrator through a one-shot endpoint that stops working the moment any user exists. First boot has already used it, so an anonymous caller can no longer create an account:

curl -sk https://127.0.0.1/api/users/init
curl -sk -o /dev/null -w 'init replay -> %{http_code}\n' -X POST https://127.0.0.1/api/users/init \
  -H 'Content-Type: application/json' \
  -d '{"username":"someone","password":"Abcdefg1","firstname":"a","lastname":"b"}'

Expected output (datas:false means "no longer uninitialised", and the replay is refused):

{"status":"success","datas":false}
init replay -> 403

Step 8 - Sign in

Browse to https://<vm-public-ip>/. Because the certificate is self-signed and unique to your VM, your browser will warn on the first visit; accept it, or replace the certificate as described under Maintenance. Enter admin and the password from Step 5.

The Pwndoc sign-in page served over HTTPS

You can confirm the same credential works from the command line. The following block reads the password from the root-only file, checks that a wrong password is rejected, and then checks that the real one is accepted:

PW=$(sudo grep '^PWNDOC_ADMIN_PASSWORD=' /root/pwndoc-credentials.txt | cut -d= -f2-)
curl -sk -o /dev/null -w 'wrong password -> %{http_code}\n' -X POST https://127.0.0.1/api/users/token \
  -H 'Content-Type: application/json' -d '{"username":"admin","password":"Definitely-Wrong-Pw1"}'
curl -sk -X POST https://127.0.0.1/api/users/token -H 'Content-Type: application/json' \
  -d "$(printf '{"username":"admin","password":"%s"}' "$PW")" | grep -o '"token"' | head -1

Expected output:

wrong password -> 401
"token"

Step 9 - Create your first audit

After signing in you land on Audits, which is empty on a new VM. Click New Audit, give the engagement a name and pick an assessment type. The image ships with a Security Audit type already bound to the default report template and with English configured as a language, so the dialog is usable immediately rather than presenting empty dropdowns.

The Pwndoc audits list with the Create Audit dialog open, showing the Security Audit assessment type that ships with the image

Inside an audit you fill in the General information and scope, record hosts and services under Network, and add findings. Each finding carries a description, observation, remediation, CVSS vector and category. Findings can be written from scratch or pulled from the shared vulnerability knowledge base under Vulnerabilities, so recurring issues keep consistent wording and scoring across every engagement. Several people can edit the same audit at once, with changes appearing live.

The Pwndoc audits view after signing in, showing the audit list and the top navigation for Audits, Vulnerabilities and Data

Step 10 - Report templates and Word export

Reports are generated from DOCX templates, so the finished document comes out in your own house style. Open Data then Templates. The image ships with the upstream Default Template already loaded, which is what makes the Security Audit type able to export a report out of the box.

The Pwndoc Templates page under Data, showing the Default Template docx that ships with the image

To use your own report design, click Create Template and upload your DOCX, then bind it to an audit type under Data then Custom Data. Upstream documents the available template variables. To export, open an audit and choose the download action from the audit list.

Step 11 - Confirm engagement data lives on the dedicated disk

Every audit, finding, evidence image and uploaded report template lives under /var/lib/pwndoc, a dedicated Azure data disk captured into the image and re-provisioned on every VM:

df -h /var/lib/pwndoc | tail -1
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/pwndoc
grep pwndoc /etc/fstab
sudo ls /var/lib/pwndoc/report-templates

Expected output (the device name varies between VM sizes, which is exactly why the mount is keyed by UUID rather than by device path):

/dev/sdc  30G  202M  28G  1%  /var/lib/pwndoc
/dev/sdc /var/lib/pwndoc ext4
UUID=8c58cbdc-4ef0-4ef0-8027-9fac61b83a85 /var/lib/pwndoc ext4 defaults,nofail 0 2
Default Template.docx

The MongoDB data directory and report templates on the dedicated ext4 data disk, mounted by UUID rather than by device name

Maintenance

  • Back up your engagements: everything Pwndoc stores lives in MongoDB's data files under /var/lib/pwndoc. Snapshot the data disk, or use mongodump/mongorestore with the credential in /root/pwndoc-credentials.txt for logical backups. Pwndoc also has its own Data then Import / Export page for portable dumps.
  • Add teammates: sign in as admin and open Data then Collaborators. There is no self registration, so accounts exist only when an administrator creates them. Roles are managed under Data then Roles.
  • Replace the TLS certificate: the per-VM certificate is self-signed, which is why browsers warn on first visit. For production, install your own certificate and key at /etc/pwndoc/ssl/server.cert and /etc/pwndoc/ssl/server.key (keep the key 0640 root:pwndoc), then run sudo systemctl restart nginx pwndoc. Both nginx and the backend read that same pair.
  • Restrict access: Pwndoc holds client findings. Keep 443/tcp limited to your own networks or a VPN in the NSG rather than exposing it to the internet, and prefer an Azure Application Gateway or your own reverse proxy with a CA-issued certificate if it must be public.
  • Rotate credentials: change the admin password from the profile menu in the UI. The MongoDB credential lives in /etc/pwndoc/pwndoc.env and the JWT signing secrets in /opt/pwndoc/backend/src/config/config.json; update those and run sudo systemctl restart pwndoc to apply. Rotating the JWT secrets invalidates all existing sessions.
  • Security updates: unattended security upgrades are enabled, so the OS keeps itself patched. Reboot when a new kernel is installed.

Support

cloudimg provides 24/7/365 expert technical support for this image. Contact support@cloudimg.co.uk. Pwndoc is licensed under the MIT License. This image is provided by cloudimg; additional charges apply for build, maintenance and 24/7 support.