// 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.
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.hexdigestif 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'
Queueis Ruby’s thread-safe FIFO — many threads canpopfrom 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, andrescue breakretires that worker cleanly.Thread#joinwaits for each worker to finish before we readfound.
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$digesthere) are usually a short recipe — reimplement them withDigest+Base64rather 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.digestreturns raw bytes (.hexdigestfor hex);Base64.urlsafe_encode64(bytes, padding: false)matches OFBiz’s URL-safe, unpadded encoding.- A
Queueis Ruby’s thread-safe way to feed a pool ofThreads;pop(true)+rescue breakis 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
benchmarklibrary (Benchmark.bm) times blocks; therealcolumn is wall-clock time — the honest way to check whether an optimization helped.
Exercises
- Wrap the hashing in
ofbiz_hash(word, salt)and assert it reproduces the known pair —ofbiz_hash('monkeybizness', 'd')should equal the stored digest. - Print progress from the cracker: every 100,000 words, print the count
(
i % 100_000 == 0). - Re-run the benchmark with 1, 2, 4, and 8 threads and compare the
realcolumn. 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.