// Ruby für Pentester · Teil 6

Einen Nicht-Standard-Hash knacken

Der Admin-Hash liegt in keinem Standardformat vor, das hashcat mitbringt — also bilden wir OFBiz' genaues Hashing in Ruby nach und bruten es gegen eine Wortliste. Dann bauen wir einen Thread-Pool, um es zu parallelisieren — und treffen die GVL, den Grund, warum Teil 4's Threads Netzwerkarbeit beschleunigten, reines Hashing aber nicht.

RubyHashingDigestThreads
6 Min. Lesezeit

Teil 5 hat $SHA$d$uP0_QaVBpDWFeo8-dRzDqRwXQ2I von der Box geholt. Das ist kein bcrypt, kein bloßes SHA1 — es ist OFBiz’ eigenes Schema, also hat hashcat von Haus aus keinen Modus dafür. Wenn ein Hash-Format selbstgebaut ist, ist der Zug, es nachzubauen — in ein paar Zeilen — und selbst zu knacken. Das hier lehrt Rubys Digest- und Base64-Bibliotheken — und dann Thread-Pools und die Decke, die die GVL ihnen aufsetzt.

Das Format lesen

Der String besteht aus drei Feldern, verbunden durch $:

$SHA$  d  uP0_QaVBpDWFeo8-dRzDqRwXQ2I
 algo  salt         digest

Das Rezept ist also SHA1(salt + password), der rohe Digest base64-kodiert mit dem URL-sicheren Alphabet (- und _ statt + und /) und ohne =-Padding. Zerlege das Ziel, um die Teile zu bekommen:

TARGET = '$SHA$d$uP0_QaVBpDWFeo8-dRzDqRwXQ2I'
_, _, salt, digest = TARGET.split('$')   # ['', 'SHA', 'd', 'uP0_...']

split('$') liefert vier Teile — den leeren String vor dem ersten $, dann SHA, das Salt und den Digest. Uns interessieren nur die letzten zwei, also landen die ersten zwei in _ (Rubys Wegwerf-Name).

Aus dem Quellcode lesen

Den String zu parsen verrät dir die Form; der Quellcode der Anwendung verrät dir das genaue Rezept, ohne Raten. OFBiz ist quelloffen, also ist sein HashCrypt.getCryptedBytes eine Suche entfernt:

messagedigest.update(salt.getBytes(UTF_8));
messagedigest.update(passwordBytes);
return Base64.encodeBase64URLSafeString(messagedigest.digest());

Das ist die ganze Spezifikation — base64url(SHA1(salt + password)), Salt zuerst. Bei einem Closed-Source-Ziel gilt derselbe Zug: Zieh die Routine aus einem dekompilierten Binary, einem JS-Bundle oder der Hersteller-Doku. Die echte Konstruktion zu lesen schlägt das Raten von hashcat-Modi — du reproduzierst einen Hash und weißt, dass deine Umsetzung stimmt, bevor du einen ganzen Crack-Lauf startest.

Den Hash nachbauen

Zwei Teile der Standardbibliothek erledigen die Arbeit: Digest::SHA1 für den Hash und Base64 für die Kodierung.

require 'digest'
require 'base64'

def encode(word, salt)
  Base64.urlsafe_encode64(Digest::SHA1.digest(salt + word), padding: false)
end
  • Digest::SHA1.digest(str) gibt die rohen 20 Bytes des Hashes zurück (nimm .hexdigest, wenn du stattdessen Hex willst).
  • Base64.urlsafe_encode64(bytes, padding: false) kodiert diese Bytes mit dem URL-sicheren Alphabet und lässt das =-Padding weg — genau OFBiz’ Format.

Wir können beweisen, dass die Umsetzung stimmt, sobald wir das Passwort finden: Seine encode-Ausgabe wird gleich dem gespeicherten digest sein.

Der sequentielle Crack

Jetzt eine Wortliste hindurchbruten. Streame die Datei Zeile für Zeile — File.foreach hast du in Teil 5 kennengelernt — und halte beim ersten Treffer:

File.foreach('rockyou.txt', chomp: true) do |word|
  if encode(word, salt) == digest
    puts "[+] Password: #{word}"
    break
  end
end

chomp: true streift den abschließenden Zeilenumbruch von jeder Zeile, damit er den Hash nicht vergiftet. Lauf es, und das Passwort fällt heraus:

$ ruby crack.rb
[+] Password: monkeybizness

Parallelisieren — und die GVL

rockyou.txt hat vierzehn Millionen Zeilen. Der naheliegende Speedup ist „wirf Teil 4’s Threads darauf” — teile die Arbeit auf einen Pool von Arbeitern auf. Der idiomatische Weg ist eine thread-sichere Queue, die die Threads leeren:

queue = Queue.new
File.foreach('rockyou.txt', chomp: true) { |word| queue << word }

found = nil
workers = 8.times.map do
  Thread.new do
    until found || queue.empty?
      word = queue.pop(true) rescue break
      found = word if encode(word, salt) == digest
    end
  end
end
workers.each(&:join)

puts found ? "[+] Password: #{found}" : '[-] Not found'
  • Queue ist Rubys thread-sichere FIFO — viele Threads können daraus popen, ohne sie zu beschädigen, also ist sie der natürliche Weg, einen Pool zu füttern.
  • queue.pop(true) popt im nicht-blockierenden Modus; wenn die Queue leerläuft, wirft sie, und rescue break verabschiedet diesen Arbeiter sauber.
  • Thread#join wartet, bis jeder Arbeiter fertig ist, bevor wir found lesen.

Nun der Haken, und er ist die eigentliche Lektion: Auf Standard-Ruby (MRI) ist das für reines Hashing nicht wirklich schneller. Ruby hat einen Global VM Lock (GVL) — nur ein Thread führt zur gleichen Zeit Ruby-Code aus. In Teil 4 halfen Threads trotzdem, weil ein Thread, der am Netzwerk blockiert, die GVL freigibt, während er wartet. Hashing gibt nichts frei; es ist reine CPU, also wechseln sich die acht Arbeiter nur auf einem Kern ab.

Glaub mir nicht — miss es. Rubys benchmark-Bibliothek stoppt die Zeit von Blöcken; lauf das Hashing sequentiell, dann über acht Threads verteilt:

require 'benchmark'

words = File.readlines('rockyou.txt', chomp: true).first(2_000_000)

Benchmark.bm(12) do |bm|
  bm.report('sequential') { words.each { |w| encode(w, salt) } }
  bm.report('8 threads') do
    words.each_slice(words.size / 8).map do |slice|
      Thread.new { slice.each { |w| encode(w, salt) } }
    end.each(&:join)
  end
end

Die real-Spalte — die tatsächliche Uhrzeit von Anfang bis Ende — erzählt die Geschichte:

                   user     system      total        real
sequential     4.960000   0.010000   4.970000 (  4.963283)
8 threads      5.260000   0.030000   5.290000 (  5.270911)

Acht Threads brauchten etwas länger, nicht achtmal kürzer: Die GVL ließ sie sich abwechseln, und wir zahlten ein wenig Overhead für das Vergnügen. Das ist die Lektion in Zahlen.

Warum also das Muster lernen? Weil es genau richtig ist und sich auszahlt, sobald die Arbeit nicht reine Ruby-CPU ist: I/O-gebundene Schleifen wie Teil 4, C-Erweiterungen, die die GVL freigeben, oder eine GVL-freie Laufzeit wie JRuby oder TruffleRuby, wo dieselben Threads wirklich parallel laufen. Für echte Mehrkern-CPU-Arbeit auf MRI greifst du an Threads vorbei — zu getrennten Prozessen (Process.fork) oder Ractors —, aber das ist ein schwereres Werkzeug, als ein Wortlisten-Crack braucht.

Erkenntnisse

  • Selbstgebaute Hash-Formate (hier $SHA$salt$digest) sind meist ein kurzes Rezept — bau sie mit Digest + Base64 nach, statt nach einem Tool-Modus zu suchen.
  • Der Quellcode der App ist die maßgebliche Spezifikation für sein Hashing — lies HashCrypt (oder eine dekompilierte Routine), statt hashcat-Modi zu raten. Dieselbe Lesen-dann-Nachbauen-Schleife knackt Eigenbau-Schemata in jedem Stack.
  • Digest::SHA1.digest gibt rohe Bytes zurück (.hexdigest für Hex); Base64.urlsafe_encode64(bytes, padding: false) trifft OFBiz’ URL-sichere, padding-lose Kodierung.
  • Eine Queue ist Rubys thread-sicherer Weg, einen Pool von Threads zu füttern; pop(true) + rescue break ist das Standardmuster „leeren, bis leer”.
  • Die GVL bedeutet, dass Threads nur I/O-gebundene Arbeit beschleunigen (Teil 4), nicht CPU-gebundene wie Hashing — dafür greifst du zu Prozessen oder einer GVL-freien Laufzeit.
  • Die benchmark-Bibliothek (Benchmark.bm) stoppt die Zeit von Blöcken; die real-Spalte ist die Uhrzeit von Anfang bis Ende — der ehrliche Weg zu prüfen, ob eine Optimierung geholfen hat.

Übungen

  1. Pack das Hashing in ofbiz_hash(word, salt) und stell sicher, dass es das bekannte Paar reproduziert — ofbiz_hash('monkeybizness', 'd') sollte dem gespeicherten Digest gleichen.
  2. Gib Fortschritt aus dem Cracker aus: alle 100.000 Wörter die Anzahl drucken (i % 100_000 == 0).
  3. Lauf den Benchmark erneut mit 1, 2, 4 und 8 Threads und vergleich die real-Spalte. Sieh zu, wie sie flach bleibt — das ist die GVL, von deiner eigenen Hand gemessen.

Weiter — Teil 7: Dieses Passwort gehört auch root. Wir setzen es zum Eskalieren ein — und lernen, wie Ruby externe Programme ausführt, von system bis zu einem PTY-gesteuerten su.

← Alle Teile