<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Excession Labs</title>
    <description>The latest articles on DEV Community by Excession Labs (excessionlabs).</description>
    <link>https://dev.to/excessionlabs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F16008%2Fe647c7ea-6a70-48bc-bc9a-ff0061737cd3.png</url>
      <title>DEV Community: Excession Labs</title>
      <link>https://dev.to/excessionlabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/excessionlabs"/>
    <language>en</language>
    <item>
      <title>Race condition vulnerability in a single-use coupon endpoint</title>
      <dc:creator>Vladimir Osipov</dc:creator>
      <pubDate>Wed, 11 Nov 2026 01:18:27 +0000</pubDate>
      <link>https://dev.to/excessionlabs/race-condition-vulnerability-in-a-single-use-coupon-endpoint-3kn</link>
      <guid>https://dev.to/excessionlabs/race-condition-vulnerability-in-a-single-use-coupon-endpoint-3kn</guid>
      <description>&lt;h1&gt;
  
  
  Race condition vulnerability in a single-use coupon endpoint
&lt;/h1&gt;

&lt;p&gt;We reproduced a race condition vulnerability in a single-use coupon endpoint running on Python's threaded HTTP server. Widening the gap between the check and the write made sixteen concurrent redemption attempts against one coupon succeed sixteen times out of sixteen, every round. With no added delay the same flaw still let through extra redemptions on three of five rounds, with 3, 8 and 4 successes. A single conditional SQL update closed the gap and held at exactly one success across every retest.&lt;/p&gt;

&lt;p&gt;This is a scripted Python and curl experiment with downloadable source and responses. The sixteen concurrent requests were sent with Python threads calling the standard library's &lt;code&gt;urllib&lt;/code&gt;, not &lt;code&gt;curl --parallel&lt;/code&gt;; curl sent the sequential control requests. No AI agent or model ran the test.&lt;/p&gt;

&lt;p&gt;On this page: &lt;a href="https://excessionlabs.ai/blog/race-condition/#scope" rel="noopener noreferrer"&gt;Scope&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/race-condition/#reproduce" rel="noopener noreferrer"&gt;Reproduce the run&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/race-condition/#results" rel="noopener noreferrer"&gt;Observed results&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/race-condition/#finding" rel="noopener noreferrer"&gt;Verify the finding&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/race-condition/#fix" rel="noopener noreferrer"&gt;Fix and retest&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/race-condition/#limits" rel="noopener noreferrer"&gt;Limits&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the race condition vulnerability and test scope
&lt;/h2&gt;

&lt;p&gt;A race condition vulnerability appears when a server checks a condition, then acts on that check, and a second request changes the underlying state somewhere in between. The two steps are not one operation, so correctness depends on no other request landing in the gap. Our fixture issues a coupon, &lt;code&gt;SAVE10&lt;/code&gt;, with exactly one use. The rule is that one redemption request should succeed and every other concurrent request should receive a 409.&lt;/p&gt;

&lt;p&gt;This fixture demonstrates &lt;a href="https://cwe.mitre.org/data/definitions/367.html" rel="noopener noreferrer"&gt;CWE-367, Time-of-check Time-of-use (TOCTOU) Race Condition&lt;/a&gt;, a specific case of the broader &lt;a href="https://cwe.mitre.org/data/definitions/362.html" rel="noopener noreferrer"&gt;CWE-362, Concurrent Execution using Shared Resource with Improper Synchronization&lt;/a&gt;. A race condition attack against a coupon, a credit balance, or an inventory count is sometimes described as a double spend, after the same failure mode in payment systems: two requests both pass a check that was only ever supposed to let one through.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; one HTTP server built on &lt;code&gt;http.server.ThreadingHTTPServer&lt;/code&gt;, bound to IPv4 loopback, &lt;code&gt;127.0.0.1&lt;/code&gt;. Each connection runs on its own thread, the same model many small Python services use. The runner selects free ports.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Allowed requests:&lt;/strong&gt; &lt;code&gt;POST /redeem&lt;/code&gt; with a coupon code header, and &lt;code&gt;GET /status&lt;/code&gt; to read a coupon's remaining uses.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Coupons:&lt;/strong&gt; &lt;code&gt;SAVE10&lt;/code&gt; for the concurrent burst, and a separate &lt;code&gt;CONTROL1&lt;/code&gt; used only for a sequential positive and negative control. Both are fixed synthetic demo codes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Data:&lt;/strong&gt; a temporary on-disk SQLite file created fresh per server process and removed on shutdown. There are no customer records or external services.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixture is intentionally insecure in its vulnerable mode and has no configurable bind address; it always listens on loopback. For a staging engagement, agree the hosts, accounts, records and stopping conditions with the system owner before replacing this scope. The &lt;a href="https://excessionlabs.ai/aup/" rel="noopener noreferrer"&gt;Excession Labs Acceptable Use Policy&lt;/a&gt; describes authorized use. This article gives no permission to test another system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the downloadable fixture
&lt;/h2&gt;

&lt;p&gt;Download &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/fixture.py" rel="noopener noreferrer"&gt;fixture.py&lt;/a&gt; and &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/verify.py" rel="noopener noreferrer"&gt;verify.py&lt;/a&gt; into the same directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 verify.py --output rerun
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output directory must be new. The runner starts a fresh fixture process for each of three conditions, five times each: &lt;code&gt;vulnerable-baseline&lt;/code&gt; (no added delay), &lt;code&gt;vulnerable-widened&lt;/code&gt; (a stated 50 ms sleep inserted between the check and the write), and &lt;code&gt;fixed&lt;/code&gt; (one conditional UPDATE, no separate check step at all). Each round runs a negative control, a sequential positive and negative control on &lt;code&gt;CONTROL1&lt;/code&gt;, a burst of sixteen simultaneous redemption attempts on &lt;code&gt;SAVE10&lt;/code&gt;, and a final read of the coupon's remaining uses, then stops the server.&lt;/p&gt;

&lt;p&gt;Our recorded command was &lt;code&gt;python3 verify.py --output recorded-run&lt;/code&gt;. It exited successfully, having run fifteen rounds with no failed controls and exactly one success in every &lt;code&gt;fixed&lt;/code&gt; round. The run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, every command, every round's raw counts and source SHA-256 hashes are in &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/recorded-run/results.json" rel="noopener noreferrer"&gt;results.json&lt;/a&gt;. A rerun will use different ports, timestamps and, because this is a race, a different spread of outcomes in the unfixed modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observed results: how many redemptions actually succeeded
&lt;/h2&gt;

&lt;p&gt;The single-use policy says one of the sixteen concurrent requests per round should succeed. The recorded run produced these counts. Each round used a fresh server and a fresh coupon:&lt;/p&gt;

&lt;p&gt;Successful redemptions out of 16 concurrent requests, by round&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Added check-to-write delay&lt;/th&gt;
&lt;th&gt;Round 1&lt;/th&gt;
&lt;th&gt;Round 2&lt;/th&gt;
&lt;th&gt;Round 3&lt;/th&gt;
&lt;th&gt;Round 4&lt;/th&gt;
&lt;th&gt;Round 5&lt;/th&gt;
&lt;th&gt;Policy expects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vulnerable-baseline&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vulnerable-widened&lt;/td&gt;
&lt;td&gt;50 ms&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rounds of the unwidened baseline landed on exactly one success, the correct outcome, by chance. An unwidened race on one laptop does not fail every time. With a stated 50 millisecond sleep between the read and the write, all sixteen requests succeeded in each of the five rounds. The fixed condition held at exactly one success in all five rounds with no added delay at all, because it has no window for a second request to land in.&lt;/p&gt;

&lt;p&gt;The sequential controls on &lt;code&gt;CONTROL1&lt;/code&gt; passed in every round and every condition, including the vulnerable ones: a solo first redemption returned 200, a solo second redemption returned 409. The flaw appears only under concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the finding before reporting it
&lt;/h2&gt;

&lt;p&gt;In the recorded first round of the widened condition, the server reported &lt;code&gt;uses_remaining: -15&lt;/code&gt; after the burst, on port 56808. This is the final status check, reformatted across lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl --noproxy &lt;span class="s1"&gt;'*'&lt;/span&gt; --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request GET \
  &lt;span class="s1"&gt;'http://127.0.0.1:56808/status?code=SAVE10'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/recorded-run/vulnerable-widened-burst-final-status.http" rel="noopener noreferrer"&gt;recorded response&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="no"&gt;OK&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"SAVE10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"uses_remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A negative remaining count is strong evidence on its own: the counter started at one, and ending at negative fifteen means sixteen decrements were applied to a coupon that was supposed to allow exactly one. &lt;code&gt;results.json&lt;/code&gt; also records each of the sixteen individual responses, their status codes and bodies, so the finding does not rest on the counter alone. A weaker form of this evidence would be sixteen 200 responses with no record of the final state, since a server could return 200 before actually writing anything.&lt;/p&gt;

&lt;p&gt;A useful finding records the endpoint, the shared resource, the check and the write it should have been one operation, the number of concurrent requests sent, how many succeeded against how many should have, and the resulting state of the resource. Here the impact is one coupon consumed sixteen times instead of once. This experiment does not establish a production severity rating; that depends on what the coupon is worth and whether the application has any other backstop, such as a reconciliation job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix: an atomic check-and-update, and retest
&lt;/h2&gt;

&lt;p&gt;The same fixture source has two code paths. In vulnerable mode, the handler reads the row, sleeps, then writes a separate decrement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;check_conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"SELECT uses_remaining FROM coupons WHERE code = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"coupon_exhausted"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;write_conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"UPDATE coupons SET uses_remaining = uses_remaining - 1 WHERE code = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixed mode replaces both statements with one conditional update that is itself the check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"UPDATE coupons SET uses_remaining = uses_remaining - 1 "&lt;/span&gt;
    &lt;span class="s2"&gt;"WHERE code = ? AND uses_remaining &amp;gt; 0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"redeemed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no read step to race against, because the condition and the write happen inside the same statement. SQLite's documentation on transactions says that &lt;a href="https://www.sqlite.org/lang_transaction.html" rel="noopener noreferrer"&gt;any SQL command that is not already inside a &lt;code&gt;BEGIN&lt;/code&gt; automatically starts an implicit transaction, which commits when that statement finishes&lt;/a&gt;. SQLite allows one writer per database at a time. A second connection's UPDATE waits, up to the 5 second busy timeout, for the first to commit, then evaluates &lt;code&gt;uses_remaining &amp;gt; 0&lt;/code&gt; against the new value. Other databases give the same guarantee for a single conditional UPDATE through row locks. If the data lives in the memory of one process, a lock held across the check and the write also closes the gap. A lock does not help when several processes or hosts serve the endpoint.&lt;/p&gt;

&lt;p&gt;The runner reran the full sequence against fixed mode for five fresh rounds, each with a new database and a new coupon. Every round produced exactly one 200 and fifteen 409 responses, and the &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/recorded-run/fixed-burst-final-status.http" rel="noopener noreferrer"&gt;final status check&lt;/a&gt; returned &lt;code&gt;uses_remaining: 0&lt;/code&gt; each time, never negative. The sequential controls on &lt;code&gt;CONTROL1&lt;/code&gt; still passed, so the fix did not block the legitimate single redemption it was meant to allow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect the evidence bundle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/recorded-run/results.json" rel="noopener noreferrer"&gt;Machine-readable results&lt;/a&gt;: all fifteen rounds, every one of the sixteen burst responses per round, expected and observed outcomes, and source hashes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/recorded-run/commands.sh" rel="noopener noreferrer"&gt;Exact recorded commands&lt;/a&gt;: the curl invocations and the fixture startup command for every round. Ports were ephemeral and are no longer listening.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/SHA256SUMS" rel="noopener noreferrer"&gt;SHA-256 manifest&lt;/a&gt;: compare downloaded sources and evidence. This is an integrity check, not independent attestation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/README.txt" rel="noopener noreferrer"&gt;Fixture instructions and limitations&lt;/a&gt;, &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/fixture.py" rel="noopener noreferrer"&gt;server source&lt;/a&gt; and &lt;a href="https://excessionlabs.ai/blog/evidence/race-condition/verify.py" rel="noopener noreferrer"&gt;verification source&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All coupon codes and the data they touch are synthetic public demo values. The bundle includes the actual recorded HTTP response text for a representative round of each condition, in addition to the summary counts quoted above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this experiment does not cover
&lt;/h2&gt;

&lt;p&gt;Sixteen threads against one in-process SQLite file on one laptop show one check-then-act window closing under one fix. We did not test a real production database under production-scale concurrent load, where lock contention and connection pooling behave differently than a temp file does. We did not test a distributed system where the check and the write happen on different hosts, a file-system race such as a symlink swap. We did not test races inside &lt;a href="https://excessionlabs.ai/blog/api-authorization-testing/" rel="noopener noreferrer"&gt;authorization checks&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a real engagement, identify every check-then-act sequence that touches a limited resource, such as a rate limit, a one-time code, or a stock count, and test each with concurrency that matches production traffic rather than sixteen threads on one laptop. Keep the unfixed baseline's results alongside the fixed retest, including rounds where the race happened not to trigger. Related categories worth reviewing on the same system include &lt;a href="https://excessionlabs.ai/blog/api-rate-limiting/" rel="noopener noreferrer"&gt;API rate limiting&lt;/a&gt;, &lt;a href="https://excessionlabs.ai/blog/broken-access-control/" rel="noopener noreferrer"&gt;broken access control&lt;/a&gt;, and the &lt;a href="https://excessionlabs.ai/blog/owasp-api-top-10/" rel="noopener noreferrer"&gt;OWASP API Top 10&lt;/a&gt; as a whole.&lt;/p&gt;

&lt;p&gt;Excession Labs tests for race condition vulnerabilities as part of its &lt;a href="https://excessionlabs.ai/security-assessment/" rel="noopener noreferrer"&gt;security assessments&lt;/a&gt;. See how a finding like this looks in a &lt;a href="https://excessionlabs.ai/sample-report/" rel="noopener noreferrer"&gt;sample report&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://excessionlabs.ai/blog/race-condition/" rel="noopener noreferrer"&gt;https://excessionlabs.ai/blog/race-condition/&lt;/a&gt; . The test code and recorded output are there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>appsec</category>
      <category>concurrency</category>
    </item>
    <item>
      <title>SQL injection example: login bypass and UNION reads on a local API</title>
      <dc:creator>Vladimir Osipov</dc:creator>
      <pubDate>Wed, 11 Nov 2026 01:12:23 +0000</pubDate>
      <link>https://dev.to/excessionlabs/sql-injection-example-login-bypass-and-union-reads-on-a-local-api-2084</link>
      <guid>https://dev.to/excessionlabs/sql-injection-example-login-bypass-and-union-reads-on-a-local-api-2084</guid>
      <description>&lt;h1&gt;
  
  
  SQL injection example: login bypass and UNION reads on a local API
&lt;/h1&gt;

&lt;p&gt;SQL injection happens when an application pastes request data into the text of a query, so the input can change what the query means. In a small sqlite3-backed HTTP API we bypassed a login with one crafted username and read every user's password hash through a search parameter. Binding values with placeholders stopped both attacks, but a sort parameter still leaked data until we replaced it with an allowlist.&lt;/p&gt;

&lt;p&gt;This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.&lt;/p&gt;

&lt;p&gt;On this page: &lt;a href="https://excessionlabs.ai/blog/sql-injection/#scope" rel="noopener noreferrer"&gt;Definition and scope&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#reproduce" rel="noopener noreferrer"&gt;Reproduce the run&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#results" rel="noopener noreferrer"&gt;Observed results&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#attacks" rel="noopener noreferrer"&gt;The attacks&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#finding" rel="noopener noreferrer"&gt;Verify a finding&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#fix" rel="noopener noreferrer"&gt;Fix and retest&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#identifiers" rel="noopener noreferrer"&gt;What parameters do not cover&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#evidence" rel="noopener noreferrer"&gt;Evidence&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/sql-injection/#limits" rel="noopener noreferrer"&gt;Limits&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What SQL injection is and what the fixture covers
&lt;/h2&gt;

&lt;p&gt;SQL injection is a flaw where untrusted input becomes part of the SQL statement instead of staying a value. MITRE lists it as &lt;a href="https://cwe.mitre.org/data/definitions/89.html" rel="noopener noreferrer"&gt;CWE-89, Improper Neutralization of Special Elements used in an SQL Command&lt;/a&gt;. The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP SQL Injection Prevention Cheat Sheet&lt;/a&gt; names parameterized queries as the preferred defense, and it says parts of a query that cannot be parameterized, such as table names, column names and sort direction, should be mapped to a strict allowlist of fixed values the application owns.&lt;/p&gt;

&lt;p&gt;The fixture is one Python file with an in-memory sqlite3 database and four endpoints on &lt;code&gt;127.0.0.1&lt;/code&gt;. It has three modes so each defense can be tested alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;vulnerable:&lt;/strong&gt; every query is built by string concatenation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;bound:&lt;/strong&gt; values use &lt;code&gt;?&lt;/code&gt; placeholders, but the &lt;code&gt;sort&lt;/code&gt; parameter is still concatenated into &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;fixed:&lt;/strong&gt; values use placeholders and &lt;code&gt;sort&lt;/code&gt; must match an allowlist.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Endpoints are &lt;code&gt;POST /login&lt;/code&gt;, &lt;code&gt;GET /search&lt;/code&gt; (with &lt;code&gt;q&lt;/code&gt; and &lt;code&gt;sort&lt;/code&gt;), &lt;code&gt;POST /register&lt;/code&gt; and &lt;code&gt;GET /history&lt;/code&gt;. The data is synthetic: three users (alice, bob and o'brien), four products and three orders. Demo passwords are public test strings, and the fixture stores unsalted SHA-256 hashes for brevity, which is not a model for real password storage. The fixture is insecure on purpose and always binds loopback. Test only systems you own or have written permission to test; the &lt;a href="https://excessionlabs.ai/aup/" rel="noopener noreferrer"&gt;Acceptable Use Policy&lt;/a&gt; describes authorized use, and this article gives no permission to test anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the downloadable fixture
&lt;/h2&gt;

&lt;p&gt;Download &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/fixture.py" rel="noopener noreferrer"&gt;fixture.py&lt;/a&gt; and &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/verify.py" rel="noopener noreferrer"&gt;verify.py&lt;/a&gt; into one directory. You need Python 3.9 or later and curl. Python uses only the standard library. Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 verify.py --output rerun
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output directory must not exist. The runner starts each mode on a free port, sends 14 real curl requests to it, saves every raw response, and stops the server. Requests bypass proxies, time out after three seconds and never leave loopback.&lt;/p&gt;

&lt;p&gt;Our recorded command was &lt;code&gt;python3 verify.py --output recorded-run&lt;/code&gt;. It printed &lt;code&gt;{"checks": 42, "passed": true, "failures": []}&lt;/code&gt;. That result means all 42 responses matched what we expected, including the unsafe ones. It does not mean the vulnerable mode is safe. The run started at 22:46:49 UTC on 10 November 2026 with Python 3.15.2, SQLite 3.54.1 and curl 8.7.1 on macOS ARM64. Ports, timestamps and response dates differ on a rerun. &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/recorded-run/results.json" rel="noopener noreferrer"&gt;results.json&lt;/a&gt; holds the exact versions and source hashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observed results across three modes
&lt;/h2&gt;

&lt;p&gt;Each row is one test. The vulnerable column is the unsafe baseline, the bound column shows what placeholders alone give you, and the fixed column adds the sort allowlist. Legitimate requests appear as controls so a server that rejects everything cannot pass.&lt;/p&gt;

&lt;p&gt;Observed responses from 42 local requests&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Safe server should return&lt;/th&gt;
&lt;th&gt;vulnerable&lt;/th&gt;
&lt;th&gt;bound&lt;/th&gt;
&lt;th&gt;fixed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login, right password&lt;/td&gt;
&lt;td&gt;200 as alice&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login, wrong password&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login as o'brien, right password&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login as &lt;code&gt;alice'--&lt;/code&gt;, wrong password&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200 as alice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search &lt;code&gt;Widget&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;200, two rows&lt;/td&gt;
&lt;td&gt;200, two rows&lt;/td&gt;
&lt;td&gt;200, two rows&lt;/td&gt;
&lt;td&gt;200, two rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search with UNION payload&lt;/td&gt;
&lt;td&gt;200, no rows&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, 3 user rows with hashes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200, no rows&lt;/td&gt;
&lt;td&gt;200, no rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort by &lt;code&gt;price&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;200, ids 4,1,3,2&lt;/td&gt;
&lt;td&gt;200, ids 4,1,3,2&lt;/td&gt;
&lt;td&gt;200, ids 4,1,3,2&lt;/td&gt;
&lt;td&gt;200, ids 4,1,3,2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort by &lt;code&gt;nosuchcolumn&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort expression, true condition&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, ids 4,1,3,2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, ids 4,1,3,2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort expression, false condition&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, ids 2,3,1,4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, ids 2,3,1,4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Register &lt;code&gt;carol&lt;/code&gt;, read history&lt;/td&gt;
&lt;td&gt;201, then her one order&lt;/td&gt;
&lt;td&gt;201, one order&lt;/td&gt;
&lt;td&gt;201, one order&lt;/td&gt;
&lt;td&gt;201, one order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Register &lt;code&gt;x' OR '1'='1&lt;/code&gt;, read history&lt;/td&gt;
&lt;td&gt;201, then no orders&lt;/td&gt;
&lt;td&gt;201, then &lt;strong&gt;all 3 orders&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;201, no orders&lt;/td&gt;
&lt;td&gt;201, no orders&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Login bypass, UNION read and second-order injection
&lt;/h2&gt;

&lt;p&gt;The vulnerable login builds its query like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SELECT username FROM users WHERE username = '"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"' AND pw_hash = '"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pw_hash&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the username &lt;code&gt;alice'--&lt;/code&gt; the text becomes &lt;code&gt;... WHERE username = 'alice'--' AND pw_hash = '...'&lt;/code&gt;. The &lt;code&gt;--&lt;/code&gt; starts a SQL comment, so the password test disappears. The server answered &lt;code&gt;{"ok": true, "user": "alice"}&lt;/code&gt; to a request with the password &lt;code&gt;wrong&lt;/code&gt;. The same fixture returned a 500 for the legitimate user o'brien, because his apostrophe closed the string early. An unexplained 500 on a name with an apostrophe is a common first sign of this flaw.&lt;/p&gt;

&lt;p&gt;The search endpoint builds &lt;code&gt;name LIKE '%&lt;/code&gt; + &lt;code&gt;q&lt;/code&gt; + &lt;code&gt;%'&lt;/code&gt;. A UNION payload in &lt;code&gt;q&lt;/code&gt; appends a second SELECT from another table with the same column count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl --get --data-urlencode &lt;span class="s2"&gt;"q=zzz' UNION SELECT id, username, pw_hash FROM users --"&lt;/span&gt; \
  http://127.0.0.1:53155/search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/recorded-run/vulnerable-search-union.http" rel="noopener noreferrer"&gt;recorded response&lt;/a&gt; listed alice, bob and o'brien in the &lt;code&gt;name&lt;/code&gt; field with their SHA-256 hashes in the &lt;code&gt;price&lt;/code&gt; field. The &lt;a href="https://excessionlabs.ai/blog/api-authorization-testing/" rel="noopener noreferrer"&gt;authorization checks&lt;/a&gt; around an endpoint do not help here, because the query itself reads data the endpoint was never meant to touch.&lt;/p&gt;

&lt;p&gt;Second-order SQL injection stores the payload first and triggers it later. In the fixture, &lt;code&gt;/register&lt;/code&gt; inserts the username with placeholders, so registering &lt;code&gt;x' OR '1'='1&lt;/code&gt; succeeds with a 201. Later, &lt;code&gt;/history&lt;/code&gt; reads the stored name back and concatenates it into an orders query, on the assumption that data from its own database is safe. In vulnerable mode that request returned the orders of alice, bob and carol. The bug sits in the second query, even though the first one looked clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify a SQL injection finding before you report it
&lt;/h2&gt;

&lt;p&gt;A single odd error is only a lead. A finding needs a controlled difference. For the login bypass, send three requests: the right password, a wrong password, and the injected username with the same wrong password. A bypass is verified when only the injected request succeeds and the response identifies the user. For the UNION read, check that the returned values come from the other table, here the hashes in &lt;code&gt;price&lt;/code&gt;. Record the endpoint, parameter, payload, request, response and the data class exposed. Stop after proving access. Do not dump a real table to show impact.&lt;/p&gt;

&lt;p&gt;This experiment shows the flaw in a toy. It gives no severity rating for a production system, and a 500 alone does not prove injection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parameterize the queries and retest
&lt;/h2&gt;

&lt;p&gt;The fix for values is to keep the SQL text fixed and pass data separately. This is the login query in bound and fixed mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT username FROM users WHERE username = ? AND pw_hash = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search value and the history query use the same placeholder style. The retest used the same 14 requests against fresh servers on ports 53170 and 53186. &lt;code&gt;alice'--&lt;/code&gt; returned 401, the UNION payload returned an empty result list, o'brien logged in with a 200, and the stored payload matched no orders. The positive controls still passed: alice's correct login, the Widget search and carol's history all returned the same data as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What parameters do not cover: ORDER BY and other identifiers
&lt;/h2&gt;

&lt;p&gt;Placeholders bind values. They cannot stand in for a column name, a table name or a sort direction, so developers often concatenate those. Our bound mode concatenates &lt;code&gt;sort&lt;/code&gt; this way, and it still failed. Sorting by &lt;code&gt;nosuchcolumn&lt;/code&gt; returned a 500, which shows the input reaches the parser. Two expressions then showed that the order of the results can carry one yes-or-no answer per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nf"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'4'&lt;/span&gt;
     &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the first character of alice's hash matched, ids came back as 4,1,3,2. With a wrong character they came back as 2,3,1,4. We ran one true and one false probe and did not extract a whole hash, but the channel works in both vulnerable and bound modes. In fixed mode the server looks up the requested name in a dictionary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="no"&gt;SORT_COLUMNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"invalid_sort"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SORT_COLUMNS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both probes and the unknown column returned 400 in fixed mode, and &lt;code&gt;sort=price&lt;/code&gt; still returned 200. This matches the OWASP guidance to map user choices to fixed application-owned values. Quoting an identifier is a weaker option, because it only controls syntax and does not restrict which columns a caller can name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect the evidence bundle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/recorded-run/results.json" rel="noopener noreferrer"&gt;results.json&lt;/a&gt;: all 42 requests with commands, expected and observed values, and hashes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/recorded-run/commands.sh" rel="noopener noreferrer"&gt;commands.sh&lt;/a&gt;: the exact curl commands. Their ports are no longer listening.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/SHA256SUMS" rel="noopener noreferrer"&gt;SHA256SUMS&lt;/a&gt;: an integrity check, not independent attestation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/README.txt" rel="noopener noreferrer"&gt;README.txt&lt;/a&gt;, &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/fixture.py" rel="noopener noreferrer"&gt;fixture.py&lt;/a&gt; and &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/verify.py" rel="noopener noreferrer"&gt;verify.py&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The folder also holds one raw &lt;code&gt;.http&lt;/code&gt; file per request, for example the &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/recorded-run/vulnerable-login-bypass.http" rel="noopener noreferrer"&gt;vulnerable login bypass&lt;/a&gt; and the &lt;a href="https://excessionlabs.ai/blog/evidence/sql-injection/recorded-run/fixed-sort-unknown-column.http" rel="noopener noreferrer"&gt;fixed-mode rejection of an unknown sort column&lt;/a&gt;. All data is synthetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this experiment does not cover
&lt;/h2&gt;

&lt;p&gt;We tested one database engine and one request style. We did not test blind or time-based techniques, out-of-band channels, stacked statements, ORMs, stored procedures, JSON or XML inputs, other SQL dialects, database account privileges or a web application firewall. The OWASP cheat sheet also recommends least-privilege database accounts as a second layer. The fixture does not model that. Passing the fixed mode shows that these 14 requests no longer misbehave. It says nothing about other queries in an unrelated codebase.&lt;/p&gt;

&lt;p&gt;For related reading, see how &lt;a href="https://excessionlabs.ai/blog/path-traversal/" rel="noopener noreferrer"&gt;path traversal&lt;/a&gt; abuses a different untrusted input, and the &lt;a href="https://excessionlabs.ai/blog/owasp-api-top-10/" rel="noopener noreferrer"&gt;OWASP API Top 10&lt;/a&gt; for a test list per API risk. The 2023 API list has no separate injection category.&lt;/p&gt;

&lt;p&gt;Excession Labs runs this kind of injection testing in its &lt;a href="https://excessionlabs.ai/security-assessment/" rel="noopener noreferrer"&gt;security assessments&lt;/a&gt;, using test accounts and synthetic records to confirm each finding. See how a finding like this looks in a &lt;a href="https://excessionlabs.ai/sample-report/" rel="noopener noreferrer"&gt;sample report&lt;/a&gt;. Download this finding written up as a &lt;a href="https://excessionlabs.ai/sample-report/sql-injection-finding.pdf" rel="noopener noreferrer"&gt;one-page sample report (PDF)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://excessionlabs.ai/blog/sql-injection/" rel="noopener noreferrer"&gt;https://excessionlabs.ai/blog/sql-injection/&lt;/a&gt; . The test code and recorded output are there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>appsec</category>
      <category>sql</category>
    </item>
    <item>
      <title>IDOR vulnerability: a local example with PUT and DELETE</title>
      <dc:creator>Vladimir Osipov</dc:creator>
      <pubDate>Wed, 11 Nov 2026 01:11:28 +0000</pubDate>
      <link>https://dev.to/excessionlabs/idor-vulnerability-a-local-example-with-put-and-delete-30a5</link>
      <guid>https://dev.to/excessionlabs/idor-vulnerability-a-local-example-with-put-and-delete-30a5</guid>
      <description>&lt;h1&gt;
  
  
  IDOR vulnerability: a local example with PUT and DELETE
&lt;/h1&gt;

&lt;p&gt;An IDOR vulnerability (insecure direct object reference) lets a signed-in user act on another user's record by sending that record's ID. In a small local invoices API, a second user listed, edited and deleted the first user's invoices. Replacing numeric IDs with UUIDs did not stop the attack, because a list endpoint returned every ID. An ownership check on each method stopped all of it.&lt;/p&gt;

&lt;p&gt;This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.&lt;/p&gt;

&lt;p&gt;On this page: &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#definition" rel="noopener noreferrer"&gt;What it is&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#scope" rel="noopener noreferrer"&gt;Scope&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#reproduce" rel="noopener noreferrer"&gt;Reproduce the run&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#results" rel="noopener noreferrer"&gt;Observed results&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#uuid" rel="noopener noreferrer"&gt;UUIDs&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#finding" rel="noopener noreferrer"&gt;Verify the finding&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#fix" rel="noopener noreferrer"&gt;Fix and retest&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#evidence" rel="noopener noreferrer"&gt;Evidence&lt;/a&gt; · &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/#limits" rel="noopener noreferrer"&gt;Limits&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What an IDOR vulnerability is
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP IDOR Prevention Cheat Sheet&lt;/a&gt; defines IDOR as a flaw that arises when attackers can access or modify objects by manipulating identifiers in URLs or parameters. It lists three ingredients: an object, a reference to it (an ID, UUID, account number, token or slug), and a missing object-level authorization check.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cwe.mitre.org/data/definitions/639.html" rel="noopener noreferrer"&gt;CWE-639&lt;/a&gt;, Authorization Bypass Through User-Controlled Key, describes the same weakness: the authorization logic does not stop one user from reaching another user's data by changing the key that identifies it. OWASP's API list files it as &lt;a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/" rel="noopener noreferrer"&gt;API1:2023, Broken Object Level Authorization&lt;/a&gt;, and notes that object IDs can be sequential integers, UUIDs or strings.&lt;/p&gt;

&lt;p&gt;Our earlier post on &lt;a href="https://excessionlabs.ai/blog/api-authorization-testing/" rel="noopener noreferrer"&gt;API authorization testing&lt;/a&gt; shows a cross-account read. This post adds enumeration, changes with PUT, removal with DELETE, and the effect of switching to UUIDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope of the fixture
&lt;/h2&gt;

&lt;p&gt;The fixture is an invoices API with three synthetic invoices. Alice owns &lt;code&gt;1001&lt;/code&gt; and &lt;code&gt;1002&lt;/code&gt;. Bob owns &lt;code&gt;1003&lt;/code&gt;. The policy is that each user may list, read, update and delete only their own invoices.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; short-lived HTTP servers on &lt;code&gt;127.0.0.1&lt;/code&gt;, started on free ports by the runner. The server has no option to bind elsewhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Methods:&lt;/strong&gt; &lt;code&gt;GET /invoices&lt;/code&gt;, &lt;code&gt;GET /invoices/{id}&lt;/code&gt;, &lt;code&gt;PUT /invoices/{id}&lt;/code&gt; and &lt;code&gt;DELETE /invoices/{id}&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Identities:&lt;/strong&gt; the public demo bearer values &lt;code&gt;demo-alice-token&lt;/code&gt; and &lt;code&gt;demo-bob-token&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configurations:&lt;/strong&gt; numeric or UUID IDs, each with the ownership check off or on. That gives four servers and 16 requests per server.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The vulnerable configurations omit the ownership check on purpose. In both of them the list endpoint returns every invoice without filtering by caller. For testing a real system, agree hosts, accounts, records, methods and stopping conditions with its owner first. The &lt;a href="https://excessionlabs.ai/aup/" rel="noopener noreferrer"&gt;Acceptable Use Policy&lt;/a&gt; describes authorized use, and this article gives no permission to test any other system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the downloadable fixture
&lt;/h2&gt;

&lt;p&gt;Download &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/fixture.py" rel="noopener noreferrer"&gt;fixture.py&lt;/a&gt; and &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/verify.py" rel="noopener noreferrer"&gt;verify.py&lt;/a&gt; into one directory. You need Python 3.9 or later (standard library only) and curl. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 verify.py --output rerun
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output directory must be new. Every curl call bypasses proxies, has a one-second connect timeout and a three-second total timeout, and follows no redirects.&lt;/p&gt;

&lt;p&gt;Our recorded command was &lt;code&gt;python3 verify.py --output recorded-run&lt;/code&gt;. It printed &lt;code&gt;{"checks": 64, "passed": true, "failures": [], "policy_violations_observed": 16}&lt;/code&gt; and exited with status 0. That line means every response matched what the fixture was built to produce, including the vulnerable ones. The 16 policy violations are the reproduced IDOR behavior. The run took place on 10 November 2026 with Python 3.15.2 and curl 8.7.1 on macOS ARM64. &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/recorded-run/results.json" rel="noopener noreferrer"&gt;results.json&lt;/a&gt; holds the exact versions, timestamps, commands and source hashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observed results for the IDOR example
&lt;/h2&gt;

&lt;p&gt;Each server started fresh. Bob first listed invoices. He then requested Alice's invoices by ID, changed one with PUT, and deleted the other. Alice re-read both invoices afterwards to see what the server stored. The IDs Bob used came from his own list response whenever it contained them.&lt;/p&gt;

&lt;p&gt;In the sequential configuration, Bob did not need the list at all. Numeric guesses for &lt;code&gt;1001&lt;/code&gt; and &lt;code&gt;1002&lt;/code&gt; returned 200 with Alice's invoices, and &lt;code&gt;1004&lt;/code&gt; returned 404, which tells an attacker where the sequence ends. Bob's PUT set Alice's invoice to &lt;code&gt;amount_cents&lt;/code&gt; 1 and status &lt;code&gt;paid&lt;/code&gt;. His DELETE removed &lt;code&gt;1002&lt;/code&gt;, and Alice's next read of it returned 404.&lt;/p&gt;

&lt;p&gt;Four controls ran in every configuration and behaved the same each time. Alice read her own invoice (200). Alice updated her own invoice (200). Bob deleted his own invoice (200), after which a read returned 404. An anonymous request returned 401. A server that rejected everything would pass the attack rows and fail these, so the controls show that the fix leaves legitimate use working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why UUIDs alone do not fix an IDOR vulnerability
&lt;/h2&gt;

&lt;p&gt;OWASP's API1 prevention advice says to prefer random, unpredictable values as record IDs, and the cheat sheet adds that complex identifiers such as GUIDs can make valid values practically impossible to guess, but that access control checks are essential even then. In our run, Bob's numeric guesses against the UUID servers returned 404 for all three IDs, so blind enumeration stopped working.&lt;/p&gt;

&lt;p&gt;The unfiltered list endpoint gave the IDs away. Bob's &lt;code&gt;GET /invoices&lt;/code&gt; on the UUID server returned three invoices, including both of Alice's with their UUIDs. Bob then read, changed and deleted them. The attack needed one extra request. The same applies to any place an ID can surface to another user: a shared link, an email, a log line, an export or a support ticket.&lt;/p&gt;

&lt;p&gt;A random ID makes a valid reference harder to find. The server still needs an ownership check to decide whether the caller may use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the finding before you report it
&lt;/h2&gt;

&lt;p&gt;A 200 from a PUT shows that the server accepted the request. It does not prove that another account's data changed. Confirm each write or delete by reading the object back as its owner. This is the exact command from the recorded sequential run, split across lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl --noproxy &lt;span class="s1"&gt;'*'&lt;/span&gt; --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request PUT \
  --header &lt;span class="s1"&gt;'Authorization: Bearer demo-bob-token'&lt;/span&gt; \
  --header &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; \
  --data &lt;span class="s1"&gt;'{"amount_cents": 1, "status": "paid"}'&lt;/span&gt; \
  http://127.0.0.1:52911/invoices/1001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/recorded-run/sequential-none-bob-updates-alice.http" rel="noopener noreferrer"&gt;recorded response&lt;/a&gt; was 200 with &lt;code&gt;"owner": "alice"&lt;/code&gt; and &lt;code&gt;"amount_cents": 1&lt;/code&gt;. Alice's &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/recorded-run/sequential-none-alice-reads-after-update.http" rel="noopener noreferrer"&gt;follow-up GET&lt;/a&gt; returned the same changed values, which shows that the write persisted under the real owner's account. For DELETE, the matching evidence is Alice's 404 on an invoice that the policy says she still owns.&lt;/p&gt;

&lt;p&gt;A report should name the method and path, the acting identity, the object owner, the policy that was expected, the observed status and the changed or disclosed fields. It should also record the controls that passed. The impact here is limited to synthetic invoices. This experiment cannot rate severity for a real system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one ownership check on every method
&lt;/h2&gt;

&lt;p&gt;All three object methods in the fixture call one function. In the fixed configurations it compares the invoice's owner with the authenticated identity before any read, update or delete happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"not_found"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authz&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"owner"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"forbidden"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list endpoint needs the same rule, applied as a filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authz&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"owner"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because every method goes through one function, PUT and DELETE get the same check as GET. A check added only to GET would leave them open. The check also has to run on the server for every request, whatever the client UI shows.&lt;/p&gt;

&lt;p&gt;The retest used the same requests. With the check on, Bob's list returned one invoice, his GET, PUT and DELETE on Alice's invoices returned 403, and Alice's follow-up reads showed the original amount, &lt;code&gt;12500&lt;/code&gt;, and her second invoice intact. This held with numeric IDs and with UUIDs. Numeric guesses for Alice's IDs returned 403 on the sequential server and 404 on the UUID server. All four controls still passed. This check works for a model where each invoice has one owner. Shared invoices, roles and tenant hierarchies need a richer policy, as in &lt;a href="https://excessionlabs.ai/blog/tenant-isolation/" rel="noopener noreferrer"&gt;tenant isolation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence bundle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/recorded-run/results.json" rel="noopener noreferrer"&gt;results.json&lt;/a&gt;: all 64 cases with identities, expected and observed statuses, bodies and response hashes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/recorded-run/commands.sh" rel="noopener noreferrer"&gt;commands.sh&lt;/a&gt;: the exact curl commands. The ports belong to the finished run.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/SHA256SUMS" rel="noopener noreferrer"&gt;SHA256SUMS&lt;/a&gt;: an integrity check on the published files, not independent attestation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/README.txt" rel="noopener noreferrer"&gt;README.txt&lt;/a&gt;, &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/fixture.py" rel="noopener noreferrer"&gt;fixture.py&lt;/a&gt; and &lt;a href="https://excessionlabs.ai/blog/evidence/idor-vulnerability/verify.py" rel="noopener noreferrer"&gt;verify.py&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All tokens, names and invoices are synthetic. The bundle contains the full HTTP responses, including dates and server headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this experiment does not cover
&lt;/h2&gt;

&lt;p&gt;The fixture is a toy with one owner per object. It shows that a missing ownership check allows read, write and delete across accounts, and that UUIDs do not compensate for it. It does not show how common this is, how severe it is elsewhere, or that the fixed server is secure in any wider sense.&lt;/p&gt;

&lt;p&gt;We did not test PATCH, bulk or batch endpoints, nested resources, IDs in request bodies or query strings, GraphQL, shared objects, role changes, caching, or races between a check and a write. Our UUID guesses were numeric strings. We did not try to guess real UUIDs. The 403 on an existing object also confirms that the object exists; some APIs return 404 for both cases to avoid that. For a broader view of the class, see &lt;a href="https://excessionlabs.ai/blog/broken-access-control/" rel="noopener noreferrer"&gt;broken access control&lt;/a&gt; and the &lt;a href="https://excessionlabs.ai/blog/owasp-api-top-10/" rel="noopener noreferrer"&gt;OWASP API Top 10&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Excession Labs runs this kind of object-level test, with two accounts and every method, in its &lt;a href="https://excessionlabs.ai/security-assessment/" rel="noopener noreferrer"&gt;security assessments&lt;/a&gt;. See how a finding like this looks in a &lt;a href="https://excessionlabs.ai/sample-report/" rel="noopener noreferrer"&gt;sample report&lt;/a&gt;. Download this finding written up as a &lt;a href="https://excessionlabs.ai/sample-report/idor-finding.pdf" rel="noopener noreferrer"&gt;one-page sample report (PDF)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://excessionlabs.ai/blog/idor-vulnerability/" rel="noopener noreferrer"&gt;https://excessionlabs.ai/blog/idor-vulnerability/&lt;/a&gt; . The test code and recorded output are there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>appsec</category>
      <category>idor</category>
    </item>
  </channel>
</rss>
