// Ruby for Pentesters · Part 6

Cracking a nonstandard hash

The admin hash isn't in any standard format hashcat ships with, so we reimplement OFBiz's exact hashing in Ruby and brute it against a wordlist. Then we build a thread pool to parallelize it — and meet the GVL, the reason Part 4's threads sped up network work but won't speed up pure hashing.

RubyHashingDigestThreads
6 min read

Part 5 pulled $SHA$d$uP0_QaVBpDWFeo8-dRzDqRwXQ2I off the box. That’s not bcrypt, not a bare SHA1 — it’s OFBiz’s own scheme, so hashcat has no mode for it out of the box. When a hash format is homegrown, the move is to reimplement it in a few lines and crack it yourself. Doing that here teaches Ruby’s Digest and Base64 libraries — and then thread pools, and the ceiling the GVL puts on them.

Reading the format

The string is three fields joined by $:

$SHA$  d  uP0_QaVBpDWFeo8-dRzDqRwXQ2I
 algo  salt         digest

So the recipe is SHA1(salt + password), the raw digest base64-encoded with the URL-safe alphabet (- and _ instead of + and /) and no = padding. Split the target to get the pieces:

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

split('$') returns four parts — the empty string before the first $, then SHA, the salt, and the digest. We only care about the last two, so the first two land in _ (Ruby’s throwaway name).

Read it from the source

Parsing the string tells you the shape; the application’s source tells you the exact recipe, with no guessing. OFBiz is open source, so its HashCrypt.getCryptedBytes is one search away:

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

That’s the whole spec — base64url(SHA1(salt + password)), salt first. For a closed-source target the same move applies: pull the routine out of a decompiled binary, a JS bundle, or the vendor’s docs. Reading the real construction beats guessing hashcat modes — you reproduce one hash and know your implementation is right before committing to a full crack run.

Reimplementing the hash

Two standard-library pieces do the work: Digest::SHA1 for the hash and Base64 for the encoding.

require 'digest'
require 'base64'

def encode(word, salt)
  Base64.urlsafe_encode64(Digest::SHA1.digest(salt + word), padding: false)
end
  • Digest::SHA1.digest(str) returns the raw 20 bytes of the hash (use .hexdigest if you want hex instead).
  • Base64.urlsafe_encode64(bytes, padding: false) encodes those bytes with the URL-safe alphabet and drops the = padding — exactly OFBiz’s format.

We can prove the implementation is right the moment we find the password: its encode output will equal the stored digest.

The sequential crack

Now brute a wordlist through it. Stream the file line by line — you already met File.foreach in Part 5 — and stop at the first match:

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

chomp: true strips the trailing newline off each line so it doesn’t poison the hash. Run it and the password falls out:

$ ruby crack.rb
[+] Password: monkeybizness

Parallelizing it — and the GVL

rockyou.txt is fourteen million lines. The obvious speedup is “throw Part 4’s threads at it” — split the work across a pool of workers. The idiomatic way is a thread-safe Queue that the threads drain:

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 is Ruby’s thread-safe FIFO — many threads can pop from it without corrupting it, so it’s the natural way to feed a pool.
  • queue.pop(true) pops in non-blocking mode; when the queue runs dry it raises, and rescue break retires that worker cleanly.
  • Thread#join waits for each worker to finish before we read found.

Now the catch, and it’s the real lesson: on standard Ruby (MRI) this is not actually faster for pure hashing. Ruby has a Global VM Lock (GVL) — only one thread runs Ruby code at a time. In Part 4 threads still helped, because a thread blocked on the network releases the GVL while it waits. Hashing releases nothing; it’s pure CPU, so the eight workers just take turns on one core.

Don’t take my word for it — measure it. Ruby’s benchmark library times blocks; run the hashing sequentially, then spread across eight threads:

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

The real column — actual wall-clock time — tells the story:

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

Eight threads took slightly longer, not eight times shorter: the GVL made them take turns, and we paid a bit of overhead for the privilege. That’s the lesson in numbers.

So why learn the pattern? Because it’s exactly right, and it pays off the moment the work isn’t pure-Ruby CPU: I/O-bound loops like Part 4, C extensions that release the GVL, or a GVL-free runtime like JRuby or TruffleRuby where these same threads run genuinely in parallel. For true multi-core CPU work on MRI you reach past threads — to separate processes (Process.fork) or Ractors — but that’s a heavier tool than a wordlist crack needs.

Takeaways

  • Homegrown hash formats ($SHA$salt$digest here) are usually a short recipe — reimplement them with Digest + Base64 rather than hunting for a tool mode.
  • The app’s source is the authoritative spec for its hashing — read HashCrypt (or a decompiled routine) instead of guessing hashcat modes. The same read-then-reimplement loop cracks bespoke schemes in any stack.
  • Digest::SHA1.digest returns raw bytes (.hexdigest for hex); Base64.urlsafe_encode64(bytes, padding: false) matches OFBiz’s URL-safe, unpadded encoding.
  • A Queue is Ruby’s thread-safe way to feed a pool of Threads; pop(true) + rescue break is the standard drain-until-empty pattern.
  • The GVL means threads only speed up I/O-bound work (Part 4), not CPU-bound work like hashing — for that you reach for processes or a GVL-free runtime.
  • The benchmark library (Benchmark.bm) times blocks; the real column is wall-clock time — the honest way to check whether an optimization helped.

Exercises

  1. Wrap the hashing in ofbiz_hash(word, salt) and assert it reproduces the known pair — ofbiz_hash('monkeybizness', 'd') should equal the stored digest.
  2. Print progress from the cracker: every 100,000 words, print the count (i % 100_000 == 0).
  3. Re-run the benchmark with 1, 2, 4, and 8 threads and compare the real column. Watch it stay flat — that’s the GVL, measured by your own hand.

Next — Part 7: that password is root’s too. We spend it to escalate — and learn how Ruby runs external programs, from system to a PTY-driven su.

← All parts