07 - Asynchronous Execution for Request-Heavy Exploit Stages
A practical examination of when and how asynchronous execution improves exploit performance. This article focuses on comparing linear and asynchronous approaches and highlighting common async pitfalls
Any situation where many requests are required in order to brute force a token, extract information from a database blindly, or perform any other attack that is I/O bound makes asyncio or threading worth considering. While a purely linear approach that makes one request at a time will work and, assuming the exploit code is correct, will eventually produce the desired result, the bottleneck is almost always waiting. By using async in a request-heavy stage, those requests can be sent without waiting for the previous one to return. Instead of idling while the code waits on network responses, useful work continues.
Async in Python allows many tasks to run “at the same time” within a single thread by pausing and resuming execution instead of blocking it. Under the hood, async tasks register work with an event loop that keeps track of all pending operations. Tasks are resumed when they are ready rather than being blocked by other unfinished tasks. When a function is defined with async def, it becomes a coroutine, meaning it can be paused and resumed. When an awaitable is awaited, execution of that coroutine is paused, allowing the event loop to schedule other tasks instead of sitting idle.
If async is still unfamiliar, the official Python asyncio documentation and the first few sections of the Real Python async guide provide a solid overview of the syntax and mental model. Nothing in this article requires deep knowledge of event loop internals. There are also plenty of high-quality talks and walkthroughs available. While taking the OSWE, I found the ArjanCodes YouTube channel particularly helpful; it covers asyncio along with broader discussions on writing clearer, more maintainable Python
To reiterate, async is not parallel execution. Only one piece of Python code is running at any given moment, and this does not bypass the GIL. In environments where an offensive workstation is running inside a virtual machine, heavily threaded programs may perform poorly compared to a single-threaded program that makes efficient use of the event loop. A useful way to think about async is traffic control. When a task yields to allow another task to run, it does not lose its progress. When it resumes, it continues from where it left off. The event loop tracks which task runs next and preserves the state of each paused task.
Tasks also do not need to execute in the order they were created. Out-of-order execution is expected. In practical terms, this means one request may succeed while many others performing the same check are still queued or in progress. Continuing to wait for those remaining tasks wastes time and resources during that exploit stage. Async provides mechanisms for cancelling pending work and shutting down cleanly once the objective is met.
Programmers without prior async exposure often approach brute forcing as a linear sequence of requests, looping until the search space is exhausted or a successful result is found. For example, a PoC may generate a list of URLs with candidate tokens embedded in a GET request. To find the correct token, one might write a function like the following:
def sync_validate_token(urls: list[str]) -> str | None:
client = httpx.Client(timeout=2.0)
for url in urls:
response = client.get(url)
if response.status_code == 200:
return url
return NoneUsing functions like this are perfectly reasonable. If you run this as part of a PoC, you just need to wait for the one URL that is validated. Notice that each request imposes a blocking in the code execution. During the loop, the request will take some finite amount of time to receive the response. Let's say that there are 100 URLs that need testing and each response takes 1 second. 100 seconds max to test all the tokens. With async, after a request is sent and waits for a response, other requests are queued and sent, each awaiting for a response. This will cut down the waiting time significantly.
At this point, it’s tempting to reach for asyncio.gather() and consider the problem solved. After all, if the bottleneck is waiting on network I/O, then firing off all requests asynchronously should be faster. A naïve async implementation often looks like creating a list of requests, awaiting them all at once, and then checking the results afterward.
async def naive_async_validate(urls: list[str]) -> str | None:
async with httpx.AsyncClient(timeout=2.0) as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
for response in responses:
if response.status_code == 200:
return str(response.request.url)
return NoneThe problem with this approach is subtle but important. While the requests are sent concurrently, the code still waits for every request to complete before continuing. Even if the correct token is discovered early, the event loop continues to wait on hundreds or thousands of unnecessary requests. In practice, this often performs no better—and sometimes worse—than a linear implementation, especially when the target server processes requests serially or enforces its own rate limits.
This is a common early mistake when learning async: assuming that concurrency alone is enough. In exploit development, especially during brute-force or blind extraction stages, early exit matters just as much as concurrency. If the code cannot stop outstanding work once the goal is achieved, most of the potential performance gains are lost.
I'm going to make a demonstration here that you can run on your own if so inclined. I made a design decision that deviates from most production ready web servers, but I did so to illustrate how async can be faster than without. The web server introduced below can only accept one request at a time. It would be tempting to disable threading entirely by running Flask with threaded=False. While that would avoid the race condition in this specific setup, it hides the underlying problem rather than solving it. Any real deployment, or even a small configuration change, could reintroduce concurrency. By protecting shared state explicitly with a lock, the behavior remains correct regardless of how the server is run.
What this server does is generate a 4-digit token between 0000 and 5000. The client code then attempts to recover that token in two ways:
A purely linear brute-force approach
A bounded asynchronous approach In each run, the linear method searches first. Once the token is found, the asynchronous method performs the same search against the endpoint. After both methods succeed, the server rotates the token and the process repeats for a total of ten runs. Each attempt records the elapsed time from the start of the search until the correct token is discovered.
To avoid unbounded request flooding, the asynchronous implementation limits the number of concurrent tasks using a controlled worker pool. Concurrency starts at five workers and increases incrementally up to fifty, allowing us to observe how performance scales and where diminishing returns begin.
I ran the web server on a Raspberry Pi on my home network at IP address 192.168.1.30. The client machine was also on the same network using WiFi. This setup introduced realistic network latency. Running the server on the same machine resulted in average latencies around 0.2 ms, compared with roughly 4.3 ms over the network. Even this is still far more responsive than a typical Internet-hosted service, but it provides a more honest baseline than localhost testing.
The goal of the client code is not to be clever, but to be honest about the work being performed. Both approaches generate the same candidate space, hit the same endpoint, and stop as soon as the correct value is observed. The only difference is how requests are issued and managed. The linear version is intentionally straightforward: one request at a time, blocking until a response is received. This establishes a baseline that is easy to reason about and verify. The asynchronous version does not attempt to do everything at once. Instead, it uses a bounded worker model backed by a queue and an early-exit signal. This ensures outstanding work is cancelled as soon as the objective is achieved, rather than continuing to consume time and network resources. In practice, this mirrors how exploit code should behave: aggressive enough to make progress quickly, but controlled enough to stop immediately when the condition you care about is met.
Sample from run.txt:
5
64.20
20.64
3.11×
10
48.38
11.16
4.33×
15
59.42
14.07
4.22×
20
66.82
15.87
4.21×
25
51.75
11.98
4.32×
30
42.65
10.43
4.09×
35
50.71
11.81
4.29×
40
65.76
16.40
4.01×
45
38.33
9.20
4.17×
50
46.65
11.72
3.98×
Across all tested concurrency levels, the asynchronous approach consistently outperformed the linear brute-force method by roughly 3 to 4 times, with peak gains appearing between 10 and 35 concurrent tasks. Beyond that range, additional concurrency produced diminishing returns and increased variance, likely due to server-side contention and scheduling overhead. Importantly, even at higher concurrency levels, async never regressed to linear performance, reinforcing that the gains come from eliminating idle wait time rather than raw parallelism.
In the final article of this series, I apply these same ideas to a more realistic and punishing problem: blind SQL injection. Rather than brute-forcing a small search space, we will extract data one bit or character at a time and examine how different strategies affect execution time. I will start with a purely linear approach, move to binary search techniques, and then introduce asynchronous variants of both. The focus is not on clever payloads, but on how algorithm choice and request orchestration dominate performance in latency-bound exploit scenarios.
Last updated