// 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.

RubySinatranet/httpBeginner
9 Min. Lesezeit

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/http ist ein vollständiger HTTP-Client in der Standardbibliothek: Net::HTTP::Get/Post, setze Header wie req['Authorization'], sende mit Net::HTTP.start { |http| http.request(req) }.
  • def definiert eine Methode; Argumente können Standardwerte haben (token = nil); "#{...}" interpoliert; JSON.parse verwandelt einen Antwortkörper in einen Hash, den du mit ['key'] indizieren kannst.

Übungen

Bekomm die Schleife in die Finger, bevor du weitermachst:

  1. Füge probe.rb einen GET /api/markets-Aufruf hinzu und gib aus, wie viele Märkte zurückkamen (JSON.parse(res.body)['markets'].size).
  2. Logge dich stattdessen als bob ein und gib seinen Kontostand aus /api/me aus. Versuche dann einen Namen, der nicht existiert, und gib den .code der Antwort aus — bestätige, dass es 401 ist.
  3. Frage /openapi.json ab und gib mit puts die 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.

← Alle Teile