// Bug-Bounty-Automatisierung in Ruby · Teil 1
Das Labor und dein erster Probe
Stelle eine absichtlich verwundbare API auf deinem eigenen Rechner auf und schreibe dann deinen ersten Ruby-Client, der sich einloggt und ein Profil zurückliest. Keine Ruby-Vorkenntnisse nötig — am Ende hast du ein Ziel zum Jagen und ein Skript, das mit ihm spricht.
Diese Reihe vermittelt Ruby so, wie es ein Bug-Bounty-Jäger tatsächlich nutzt: indem du deinen eigenen Scanner baust und ihn auf eine echte API richtest. Über acht Teile schreibst du ein Toolkit, das die Oberfläche einer API kartiert, ihre Autorisierung bricht, ihre Eingaben fuzzt, ein Rennen gegen sie gewinnt, ihre Antworten über mehrere Läufe diffrt und am Ende alles in einen geplanten Scanner packt, der einen Bericht zur koordinierten Offenlegung ausgibt.
Das Ziel ist eine kleine API, die du selbst betreibst: ein absichtlich verwundbarer “Paper Markets”-Dienst, in Ruby geschrieben, damit du jeden Bug lesen kannst, bevor du ihn ausnutzt. Keine Ruby-Vorkenntnisse nötig — wir bauen die Sprache auf, während wir das Werkzeug bauen.
Geltungsbereich. Alles hier richtet sich auf ein Labor auf deinem eigenen Rechner, gebunden an
127.0.0.1. Genau das ist der Sinn eines Labors: Du darfst etwas angreifen, das komplett dir gehört. Richte diese Werkzeuge niemals gegen einen Dienst, den zu testen du keine ausdrückliche Erlaubnis hast — das ist die Grenze zwischen Bug-Bounty und einer Straftat.
Ruby 4 besorgen
Diese Seite läuft auf Ruby 4, also ziehen wir gleich. Prüfe, was du hast:
$ ruby -v
ruby 4.0.6 (2025-12-25 revision ...) [x86_64-linux]
Wenn das eine 4.x-Version ausgibt, bist du bereit; wenn nicht, installiere sie
mit einem Versionsmanager (rbenv install 4.0.6, mise use ruby@4.0.6 oder dem
Paket deiner Distribution). Für das Labor brauchen wir ein Gem — Sinatra, ein
winziges Web-Framework:
$ gem install sinatra
Das Ziel bauen
Ein Bug-Jäger liest das Ziel, bevor er es anfasst. Da unseres quelloffen ist —
wir schreiben es ja — siehst du jeden Fehler in klarem Ruby. Lege eine Datei
namens labapi.rb an. Wir bauen sie in Stücken; das erste Stück ist das
Framework und die Daten, die es ausliefert:
require 'sinatra'
require 'json'
set :port, 4567
set :bind, '127.0.0.1'
USERS = {
1 => { 'id' => 1, 'name' => 'alice', 'token' => 'tok_alice', 'role' => 'user', 'balance' => 1_000 },
2 => { 'id' => 2, 'name' => 'bob', 'token' => 'tok_bob', 'role' => 'user', 'balance' => 1_500 },
99 => { 'id' => 99, 'name' => 'admin', 'token' => 'tok_admin', 'role' => 'admin', 'balance' => 0 }
}
MARKETS = {
1 => { 'id' => 1, 'title' => 'BTC above 70k at noon', 'price' => 0.62 },
2 => { 'id' => 2, 'title' => 'ETH above 4k at noon', 'price' => 0.41 },
3 => { 'id' => 3, 'title' => 'Rain in NYC tomorrow', 'price' => 0.55 }
}
ORDERS = []
COUPONS = { 'FREE100' => { 'code' => 'FREE100', 'value' => 100, 'redeemed' => false } }
USERS, MARKETS, ORDERS und COUPONS sind die ganze Datenbank — im
Speicher gehalten, sodass ein Neustart des Servers die Welt zurücksetzt. Ein
{ ... } ist ein Hash: Schlüssel, die auf Werte zeigen. Ein [ ] ist ein
Array: eine geordnete Liste. Diese beiden Strukturen tragen fast alles in
Ruby, und du wirst sie ständig verwenden.
Jetzt die Verrohrung. Füge ein paar Helfer hinzu — kleine Methoden, die jede Route aufrufen kann:
helpers do
def json(obj)
content_type :json
obj.to_json
end
def current_user
auth = request.env['HTTP_AUTHORIZATION'].to_s
token = auth.sub(/\ABearer\s+/, '')
USERS.values.find { |u| u['token'] == token }
end
end
json setzt den Antworttyp und serialisiert jedes Objekt zu JSON.
current_user liest den Authorization-Header, streift das Bearer -Präfix ab
und schlägt den passenden Benutzer nach — gibt nil zurück, wenn das Token
unbekannt ist. find läuft die Liste ab und liefert das erste Element zurück,
für das der Block wahr ist.
Jetzt die Routen. Jeder get/post-Block ist ein Endpunkt. Lies die
Kommentare — jeder mit BUG markierte ist etwas, das du in einem späteren Teil
ausnutzt:
post '/login' do
body = JSON.parse(request.body.read) rescue {}
user = USERS.values.find { |u| u['name'] == body['name'] }
halt 401, json('error' => 'no such user') unless user
# BUG: the password is never checked.
json('token' => user['token'])
end
get '/api/me' do
u = current_user or halt 401, json('error' => 'unauthenticated')
json(u)
end
# BUG: mass assignment — the whole body is merged into your own record, so a
# client can set fields it should never control (role, balance...).
patch '/api/me' do
u = current_user or halt 401, json('error' => 'unauthenticated')
body = JSON.parse(request.body.read) rescue {}
u.merge!(body)
json(u)
end
# BUG: returns any user by id with no ownership check (BOLA / IDOR).
get '/api/users/:id' do
current_user or halt 401, json('error' => 'unauthenticated')
u = USERS[params['id'].to_i] or halt 404, json('error' => 'not found')
json(u)
end
get '/api/markets' do
json('markets' => MARKETS.values)
end
get '/api/markets/:id' do
m = MARKETS[params['id'].to_i] or halt 404, json('error' => 'not found')
json(m)
end
# BUG: mass assignment — the whole body is merged into the order, so a
# client can set fields it should never control (user_id, balance...).
post '/api/orders' do
u = current_user or halt 401, json('error' => 'unauthenticated')
body = JSON.parse(request.body.read) rescue {}
order = { 'id' => ORDERS.size + 1, 'user_id' => u['id'] }.merge(body)
ORDERS << order
json(order)
end
# BUG: no role check — any authenticated user reaches admin-only stats.
get '/api/admin/stats' do
current_user or halt 401, json('error' => 'unauthenticated')
json('users' => USERS.size, 'orders' => ORDERS.size,
'balances' => USERS.values.sum { |u| u['balance'] })
end
get '/api/search' do
q = params['q'].to_s
json('query' => q, 'results' => MARKETS.values.select { |m| m['title'].include?(q) })
end
# BUG: check-then-act with no lock — two requests can both pass the
# `redeemed` check before either flips it, so a coupon pays out twice.
post '/api/redeem' do
u = current_user or halt 401, json('error' => 'unauthenticated')
body = JSON.parse(request.body.read) rescue {}
c = COUPONS[body['code'].to_s] or halt 404, json('error' => 'no such coupon')
halt 409, json('error' => 'already redeemed') if c['redeemed']
sleep 0.05 # widen the race window so the bug is easy to see
u['balance'] += c['value']
c['redeemed'] = true
json('balance' => u['balance'])
end
# BUG: a debug endpoint left in prod and left out of the spec — it dumps
# everything, tokens included. Exactly what content discovery turns up.
get '/api/debug' do
json('users' => USERS.values, 'orders' => ORDERS, 'coupons' => COUPONS.values)
end
get '/openapi.json' do
json(
'paths' => {
'/login' => %w[post],
'/api/me' => %w[get],
'/api/users/{id}' => %w[get],
'/api/markets' => %w[get],
'/api/markets/{id}' => %w[get],
'/api/orders' => %w[post],
'/api/search' => %w[get],
'/api/redeem' => %w[post]
}
)
end
Mach dir noch nicht über jede Zeile Gedanken — spätere Teile kehren zu jedem Bug der Reihe nach zurück. Starte den Server:
$ ruby labapi.rb
== Sinatra (v4.2.1) has taken the stage on 4567 for development
Lass ihn in diesem Terminal laufen. In einem zweiten Terminal bestätige, dass er antwortet:
$ curl -s http://127.0.0.1:4567/api/markets
{"markets":[{"id":1,"title":"BTC above 70k at noon","price":0.62}, ...]}
Das ist dein Ziel, live und deins.
Dein erster Probe
curl ist für eine Anfrage in Ordnung, aber ein Scanner muss Hunderte senden
und die Antworten programmatisch lesen. Das ist Rubys Aufgabe. Öffne eine neue
Datei, probe.rb. Rubys Standardbibliothek bringt in net/http einen
vollständigen HTTP-Client mit — kein Gem nötig:
require 'net/http'
require 'json'
require 'uri'
BASE = 'http://127.0.0.1:4567'
def post_json(path, data)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Post.new(uri)
req['Content-Type'] = 'application/json'
req.body = data.to_json
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end
def get_json(path, token = nil)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{token}" if token
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end
Zwei Methoden, definiert mit def. Jede baut eine Anfrage, öffnet mit
Net::HTTP.start eine Verbindung und gibt die Antwort zurück. URI("...")
parst einen String zu etwas mit .host und .port. Das token = nil gibt dem
Argument einen Standardwert, sodass get_json('/api/markets') ohne eines
funktioniert. "#{BASE}#{path}" ist String-Interpolation — #{...} setzt
einen Wert in den String ein.
Jetzt nutze sie. Logge dich als alice ein und lies dann ihr Profil mit dem Token, das wir zurückbekommen:
# Log in as alice and read our own profile.
res = post_json('/login', 'name' => 'alice', 'pass' => 'hunter2')
token = JSON.parse(res.body)['token']
puts "[+] token: #{token}"
me = get_json('/api/me', token)
puts "[+] me: #{me.body}"
JSON.parse verwandelt den Antwortkörper (einen String) in einen Ruby-Hash, und
['token'] liest einen Schlüssel daraus. Führe es gegen das laufende Labor aus:
$ ruby probe.rb
[+] token: tok_alice
[+] me: {"id":1,"name":"alice","token":"tok_alice","role":"user","balance":1000}
Du hast dich eingeloggt, ein Token bekommen und damit ein Profil gelesen — die
ganze Anfrage-/Antwort-Schleife, auf der jeder spätere Teil aufbaut. Beachte
schon jetzt etwas: Wir haben das Passwort hunter2 gesendet und es hat
funktioniert, aber jedes Passwort hätte das getan. Das ist der erste Bug, und
wir kommen darauf zurück.
Erkenntnisse
- Ein Labor ist ein Ziel, das komplett dir gehört. Unseres ist eine
Ruby/Sinatra-API, gebunden an
127.0.0.1; lass sie in ihrem eigenen Terminal laufen. Richte diese Werkzeuge niemals gegen etwas, das zu testen du nicht autorisiert bist. - Rubys zentrale Datenstrukturen sind der Hash (
{ key => value }) und das Array ([ ]). Die ganze Datenbank des Labors ist ein paar von beidem. net/httpist ein vollständiger HTTP-Client in der Standardbibliothek:Net::HTTP::Get/Post, setze Header wiereq['Authorization'], sende mitNet::HTTP.start { |http| http.request(req) }.defdefiniert eine Methode; Argumente können Standardwerte haben (token = nil);"#{...}"interpoliert;JSON.parseverwandelt einen Antwortkörper in einen Hash, den du mit['key']indizieren kannst.
Übungen
Bekomm die Schleife in die Finger, bevor du weitermachst:
- Füge
probe.rbeinenGET /api/markets-Aufruf hinzu und gib aus, wie viele Märkte zurückkamen (JSON.parse(res.body)['markets'].size). - Logge dich stattdessen als
bobein und gib seinen Kontostand aus/api/meaus. Versuche dann einen Namen, der nicht existiert, und gib den.codeder Antwort aus — bestätige, dass es401ist. - Frage
/openapi.jsonab und gib mitputsdie Liste der Pfade aus. Du hast gerade dein erstes Stück Recon gemacht; Teil 2 macht daraus eine Karte.
Weiter — Teil 2: Wir nehmen diese openapi.json plus eine Wortliste und
schreiben einen Mapper, der jeden Endpunkt inventarisiert, den die API
freilegt — einschließlich derer, die die Spezifikation nicht erwähnt.