First, I summarize the methodology, findings, and implications for the threat intelligence ecosystem. Then, I discuss my contributions to the project, as well as the design choices in more detail than the publication.
Our paper and slides can be found on the NDSS website.
The cybersecurity industry and community relies heavily on collecting and sharing threat intelligence (TI). Security vendors and other defenders analyze artifacts and produce indicators such as IP addresses, domains, and file signatures to detect and respond to threats. Yet despite the scale of the TI ecosystem, it remains largely a black box; it is inherently difficult to investigate the sharing patterns and tendencies of security vendors. We know little about how security vendors triage artifacts, collect indicators of compromise (IoCs), and disseminate this information. Vendors themselves know little about how their intelligence is being used and what threat vectors may exist in their analysis.
In our work, we set out to measure the TI ecosystem end-to-end, from initial artifact submission to disruption of threats. Our approach reveals not only what vendors participate, but also how quickly vendors act, the depth of their analysis, and the threat vectors of this ecosystem.
The end goal of our research is threefold, and is centered around answering the following research questions (RQs):
RQ1 (Propagation): How do security vendors differ in their ability to analyze malware and share extracted indicators of compromise (IoCs) across the ecosystem?
RQ2 (Disruption): How do differences in analysis and sharing affect the speed and effectiveness with which vendors block IoCs or take down (or suspend) infrastructure?
RQ3 (Evasion): How are adversaries exploiting gaps in analysis, sharing, or disruption, and what strategies can improve the ecosystem’s resilience against such evasions?
To penetrate this black box, our key idea was to devise malware as probes in order to map the TI ecosystem supply chain. To do this, we devised a measurement pipeline to answer these questions. The goal of this pipeline is to track the binaries as they traverse the ecosystem from submission to execution(s) to disruption; this is done by deploying a set of observers monitoring for watermarked emissions. The pipeline is shown below:

The Generator produces a binary (“Rocket”) which is submitted to a set of TI platforms, sandboxes, and antivirus engines; each binary is unique to each submission. The binary is a defanged malware that is intended to trigger a malicious verdict and subsequent execution. Upon execution the Rocket collects a set of information about the sandbox environment, encodes the information in an HTTP request to a controlled domain, and drops a modified copy of itself with an updated provenance trail.
The Observer consists of emission sensors, a DNS authority and HTTP server, that monitors for requests produced by the Generator, which indicates execution. Identifiers in the request are uniquely mapped to submissions to track emissions with high confidence. The Observer also consists of IoC probes which actively track whether domains, IPs, and artifact hashes appear in blocklists (Google Safe Browsing, Quad9, VirusTotal, AlienVault OTX, commercial feeds).
This design allows us to observe each stage of the IoC propagation chain, which we can intuitively consider as an IoC lifecycle. We can either derive information or receive indicators at 1) submission; 2) first execution (sandbox); 3) sharing and each subsequent execution, and location of execution; 4) when IoCs were put into action to disrupt threats (blocking); 5) domain suspension.
We submitted unique Rockets to 30 vendors across three categories:
We registered 9 domains, assigning each domain to a category type and carrying out multiple experiments. The domains were chosen such that no prior registrations were found in DNS zonefiles to avoid experiment contamination. Each Rocket was unique to the vendor and manually submitted to each vendor for an experiment.
To label vendors, we use data exfiltrated from the sandbox. The first execution of a Rocket is attributed to the submission vendor, while later executions can be attributed by clustering similar sandbox environments with relatively high certainty. This resulted in 62 labeled clusters, 19 of which were known from first submission and 43 generated via clustering.
Despite extraction being common, sharing of TI and action is rare. 20/30 submitted Rockets were executed, but only 5 shared the extracted TI. Even more, only 2 vendors contributed to downstream domain takedowns. Hence, there is a big gap between IoC extraction and actionable intelligence dissemination.
“Nexus” vendors may create single points of failure. We label 4 vendors as “nexus” vendors, which have high in- and out-degree in the TI graph; they both consume and share TI.
Adversaries actively exploit sandbox fingerprints. Using VirusTotal Retrohunt, we identified 874 malware samples uploaded within a 90-day window (March-June 2025) containing sandbox-specific IPs from our experiments. Two popular open-source stealer families, such as this one (GitHub) dynamically download IP blocklists from GitHub to detect and evade sandboxes. Simulating evasion using public blocklists showed a 25% reduction in vendors receiving extracted TI.
Network IoCs are reshared far more frequently than binaries. While this is naturally more efficient, it means that some vendors lose out on information that may otherwise be contextually relevant. In some cases, domain lookups were up to 20x more prevalent than binary executions.
Sharing delays propagate downstream. Although IoCs are typically extracted within minutes, sharing delays of hours to days propagate across the supply chain and lifecycle. In case studies, domains were blocked by DNS firewalls within 1-13 hours but takedowns took 8-11 days. This leaves a wide exploitation window for adversaries.
In our paper, we provide recommendations to improve ecosystem resilience.
For vendors:
For operators:
For researchers:
While this research was only recently published, I began work on this project in mid-2023. Most of my work focused on the design decisions of the Rocket malware, while my co-authors did much of the data analysis.
The core purpose of the Rocket design is to build a probing binary that vendors will dynamically analyze and run, and have it leave a provenance trail to trace where the binary propagates across the TI ecosystem. This is easier said than done, because competing constraints may have to be simultaneously satisfied. For instance, the binary must be malicious enough to trigger dynamic execution consistently, while being defanged for ethical considerations. It must exfiltrate environment information from the sandbox, but must do so in a privacy-preserving manner.
Not all binaries submitted to a vendor will be executed; some will be processed statically. However, the experiments require that Rockets consistently trigger dynamic analysis, so the binary needs to exhibit behaviors that static analysis deems suspicious enough to proceed to dynamic analysis.
To do this, we use known rules to induce a malicious verdict during static triaging. We decided to use a combination of malicious YARA signatures (byte sequences, strings) and other techniques to consistently produce malicious verdicts in a subscription-based private sandbox.
Assuming the binary proceeds to dynamic analysis, we also need to ensure that the sandbox produces a malicious verdict to encourage downstream sharing of IoCs. Verdicts are decided in sandboxes through a variety of rules and behaviors: creating processes or files, network activity, API call sequences (often done through hooking user-mode APIs) and monitoring syscalls, or evasion detection. Conveniently, much of the behavior needed for our measurement (environment fingerprinting, file writes/dropping Satellites, and network emissions) already contributes to suspicion. Importantly, we use a defanged keylogging behavior (which makes desired API calls) in our construction to reinforce a malicious verdict.
During execution, the binary collects and exfiltrates environment information by querying a lab-controlled HTTP server (“emission”). However, this profiling process is not enough to track where the IoCs have been spread, nor who has executed the binary; we can only associate the submission to a vendor to an initial execution. Hence, the key idea is to use a type of provenance tracking in our profiling approach. During profiling, the binary embeds information about the current environment into itself (the “provenance trail”), and during the emission phase, the entire provenance trail is exfiltrated.
To implement this, we must first discuss data exfiltration to understand the restrictions. As mentioned earlier, we use a DNS authority and HTTP server that monitors for requests produced. To share the fingerprint with the Observer, the Generator uses a simple HTTP request and encodes the provenance trail in the fully qualified domain name (FQDN).
This leads to an interesting problem. FQDNs can be 253 characters long, but a given subdomain label can be just 63 characters. Domain names are also case-insensitive, so our encoding strategy limits labels to [a-z0-9], 36 characters. Furthermore, a DNS label must be separated by a ., so 4 characters are lost on label separation. Hence we are upper bounded at \(249\log_{2}36\approx 1287\) bits of information to encode the provenance trail. Certainly, this is not enough to encode long strings or IoCs to perfectly uniquely identify each submission. Note that the provenance trail must also be encoded in these 1300 bits of information, so the FQDN becomes even more restrictive.
The fingerprints generated by the binary during execution were based on a pilot experiment I first ran in late 2023 to validate prior work, such as SandPrint: Fingerprinting Malware Sandboxes to Provide Intelligence for Sandbox Evasion. For this pilot, we profiled sandboxes using 16 features, and performed single-link hierarchical clustering to group sandboxes by vendor. Then, we chose a subset of features that maximizes mutual information \(I(F_1;C)=H(F_1)+H(C)-H(F_1,C)\).
We can visualize the clustering using a dendrogram below; each color shows a different cluster.

The table below displays information gain for each field, which helps with intuition, but is not perfectly representative of the information gain onto a joint variable.

I computed all \(2^{16}\) subsets and ultimately settled on install_date, ram, and sys_manufac as our set of exfiltrated features to maximize mutual information while being privacy preserving. We can validate by comparing the clustering with the feature subset to the ground truth (clustering with all 16 features); in over 99% of pairs of executions, the same labeling is achieved. Notably, no significant increase in similarity was found when using a larger subset of features.
This algorithm describing single-link hierarchical clustering can be thought of as an implementation of a union-find data structure, in which we form clusters by combining observations until a threshold of features is reached.

Thus, we can generate a hashed fingerprint based on these three features, which we call \(H_i\). It turns out this is not yet sufficient, as a single vendor may execute the same binary multiple times across identical sandbox environments, producing indistinguishable fingerprints. To disambiguate, each execution also generates a random execution ID (\(\epsilon_i\)) that uniquely identifies each individual run. The combination of the system fingerprint and execution ID allows us to distinguish repeat executions within the same vendor from executions across different vendors, which is important for accurately reconstructing the provenance trail.
Furthermore, we generate a unique binary ID \(b\) for each of 30 submissions, grouped in 10 by vendor type. Each group is assigned a domain, and each binary ID is assigned a letter between A-I.
Checksums can also be implemented in the provenance trail. Background noise/fuzzing can be detected by our observers, and it is important to separate real observations with fuzzers. I initially implemented a CRC error detection algorithm in our data analysis pipeline, but this turns out to be largely unnecessary since errors can be detected by malformed provenance trails.
Finally, we create the provenance trail with the following construction:
\[\langle b\mid\mid\epsilon_nH_i\mid\mid\dots\mid\mid\epsilon_1H_1\rangle\]Each execution appends then exfiltrates the entire provenance trail, allowing us to see the entire history of where the binary has been shared. Importantly, if history diverges at a point, we can observe this fact when the provenance trail is forked. If the dropped Satellite binary is also executed, we can see this when two executions are made from a similar sandbox.
Consider these log entries for examples:

There are many deductions we can automatically make from this.
In chain 1, the Rocket is first executed by INT-TI-3 (\(t_1\)). The resulting Satellite A1 is then executed by EXT-DE-1 (\(t_2\)), whose provenance trail contains INT-TI-3’s execution ID, meaning EXT-DE-1 received the Satellite from INT-TI-3. Next, Satellite A2 is executed by INT-TI-3 again (\(t_3\)), with a provenance trail containing both prior execution IDs. This implies EXT-DE-1 shared the Satellite back to INT-TI-3, revealing a cyclic sharing relationship between the two vendors. Note furthermore that the fingerprint (not the execution ID) is the same in \(t_1\) and \(t_3\).
In Chain 2, the Rocket is first executed by INT-SB-1 (\(t_4\)). Its Satellite B1 is then executed by INT-TI-3 (\(t_5\)) via HTTP, and later the same Satellite’s domain is probed by EXT-NZ-1 (\(t_6\)) via DNS only. The DNS-only probe at \(t_6\) indicates that INT-TI-3 shared the domain IoC with EXT-NZ-1, rather than the binary itself; notably this differentiates binary sharing and IoC sharing.
The binary itself was written in Go, for a couple of reasons:
The main implementation challenge is the Satellite dropping mechanism. Recall that upon execution of a Rocket, it must drop a modified copy of itself (Satellite) with an updated provenance trail.
To do this, we reserve a fixed region in the compiled binary as effectively a buffer for the provenance trail, which has a max length. During execution, the Rocket drops a Satellite, finds the buffer, and overwrites the buffer with a new provenance trail. Thus, the in-place modification only changes a small portion of memory. See this portion of the early version of the code:
srcFile, err := os.Open(os.Args[0])
if err != nil {
fmt.Println(err)
}
defer srcFile.Close()
destFile, err := os.Create(newFilename)
if err != nil {
fmt.Println(err)
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
if err != nil {
fmt.Println(err)
}
content, err := ioutil.ReadFile(newFilename)
if err != nil {
fmt.Println(err)
}
r := regexp.MustCompile("<<<<.{256}>>>>")
match := r.FindStringIndex(string(content))
matchStr := content[match[0]:match[1]]
replacedContent := bytes.Replace(content, matchStr, []byte(newHistory), 1)
err = ioutil.WriteFile(newFilename, replacedContent, 0644)
if err != nil {
fmt.Println(err)
}
destFile.Close()
// delete itself when done
cmd := exec.Command("cmd.exe", "/c", "del " + os.Args[0])
cmd.Start()
// if copy flag is true, replace itself
if copy == "true" {
err = os.Rename(newFilename, "temp.exe")
if err != nil {
fmt.Printf("Error renaming the file: %v\n", err)
return
}
cmd2 := exec.Command("cmd.exe", "/c", "move temp.exe", os.Args[0])
cmd2.Start()
}
In one experiment to look at sandbox evasion, we pack the rocket with the UPX packing algorithm. We also encrypt the main sandbox profiler with AES, where an “outer profiler” decrypts the Rocket:
//go:embed innerprofiler.enc
var encFile string
var basename string
func main() {
key := []byte{0x2b, 0x32, 0x2c, 0xad, 0x83, 0xeb, 0xc4, 0x31, 0xd1, 0xee, 0xe3, 0x86, 0x8e, 0x48, 0xbc, 0x4f}
iv := []byte{0xfe, 0xe2, 0xb9, 0x2c, 0xf5, 0xb8, 0xb2, 0x60, 0xe2, 0x92, 0x96, 0x68, 0xc0, 0x99, 0xf1, 0x9d}
encFile2, err := hex.DecodeString(encFile)
if err != nil {
fmt.Println("Error decoding hex string:", err)
return
}
block, err := aes.NewCipher(key)
if err != nil {
fmt.Println("Error creating cipher block:", err)
return
}
mode := cipher.NewCBCDecrypter(block, iv)
decryptedData := make([]byte, len(encFile2))
mode.CryptBlocks(decryptedData, encFile2)
paddingLength := int(decryptedData[len(decryptedData)-1])
decryptedData = decryptedData[:len(decryptedData)-paddingLength]
outputFilename := basename
err = ioutil.WriteFile(outputFilename, decryptedData, 0644)
if err != nil {
fmt.Println("Error writing decrypted data to file:", err)
return
}
}
This experiment (Experiment II in the paper) tests whether vendors recursively analyze dropped files, which has consequences in ecosystem extraction and analysis depth. Our results show that packed Rockets were executed 35% less often than an unpacked Rocket (Experiment I), despite having more AV detections on VirusTotal. This suggests that vendors are not properly unpacking and analyzing dropped payloads.
In another unpublished experiment, we hypothesized that fuzzy hashing (such as ssdeep) would indicate that many similar samples of our Rocket have been previously submitted, impacting detection rates and sharing behavior. To combat this, we tried two things:
I also contributed to the Observer’s IoC probing mechanism. Recall that to answer RQ2 (disruption), we need to track when and how vendors act on the intelligence they extract, such as when domains are blocked by DNS resolvers, OSINT blocklists, or suspended by registrars. To do this, we implement a set of cronjobs every couple hours that probe external services for the presence of our IoCs. These probes check:
We can log the timestamp and the result to reconstruct a timeline to see when each IoC transitioned from active to blocked to suspended. Combined with the emission logs from the Generator, this gives us the full lifecycle of the IoC.
We can observe results in the table from our paper below:

Notably, we see that while commercial DNS blocking could happen within an hour of sharing, domain suspension took 8-11 days on average, and in many cases never happened at all within our 90-day observation window.
Actively measuring security infrastructure naturally raises ethical concerns. Since our binaries are submitted to real vendor pipelines, it consumes real compute resources and generates real IoCs that could affect vendors downstream. We adopted certain safeguards to minimize harm:
We reached out to all 30 vendors we studied to inform them of our studies and coordinate public disclosure. Despite this, only 17 vendors responded, many appreciative of the detailed analysis. Interestingly, some did not take the study as a vulnerability at all, instead opting to label their behavior as a design decision.
In a weird coincidence, we see that our experiments were detected by Palo Alto Networks in their 2025 IEEE paper “Resolution Without Dissent: In-Path Per-Query Sanitization to Defeat Surreptitious Communication Over DNS”:
There are 104 cases where we cannot decide the purpose of queries at a confident determination. The FQDNs of these cases look like tunneling and there is no useful information on the Internet. For example, the domain 9mn[.]lat has a number of tunneling-like FQDNs, e.g., dpdf3d[…redacted…]skpx.9mn[.]lat and dc81[…redacted…]cof.9mn[.]lat. The only useful information we found is that 9mn[.]lat was registered on 2023-12-23 and will expire on 2024-12-23. Actually, 70 of the 104 domains are registered within one year and the expiration dates are also within one year. We posit that blocking these low-profile new domains should have trivial business impact on enterprise networks.
It seemed like they were studying specifically DNS rather than actively looking up the IoCs, so they did not come across the disclaimer. This is nice validation though that our binary behaved realistically enough to trigger production security systems.
This project has been in the works since mid-2023, and I’m very grateful to many people who made it possible. First, I’d like to thank my advisor Fabian Monrose, whose intuition for the right research questions greatly guided the direction of this work. I’d also like to thank Tillson Galloway and Omar Alrawi, whose expertise in TI research and network security was invaluable throughout this project.
]]>Capture the Flag (CTF) competitions present complex challenges that require a diverse set of techniques to solve cybersecurity challenges. In recent years, agentic systems and LLMs have risen in popularity when solving CTFs, and we have observed powerful agentic systems deployed by other CTF teams in the past to reduce the solve time of challenges significantly. Following in these footsteps, our team has created an advanced agentic framework to automatically solve CTF challenges, which we call Squid Agent. Using the CTFTiny dataset by NYU, we benchmarked Squid Agent and solved 92%, or 46/50 of the challenges in the dataset. In this blog, we describe the construction of the multi-agent framework as well as lessons learned during the process of developing Squid Agent.
The initial development of Squid Agent was motivated by observations from DEFCON CTF Finals. Here, both Perfect Blue and Maple Mallard Magistrates (Theori AIxCC) had powerful agentic systems that were able to help solve challenges and find bugs. For instance, Perfect Blue’s system, designed specifically for CTFing in mind, was able to solve speedpwn challenges in significantly less time (7-10 minutes) than even the best human pwn player (30-40 minutes). This was a wake-up call for us; we are firmly in the age of agents, and we have to adapt or be left behind.
When we began developing the system, we discovered the CSAW Agentic Automated CTF, which provided a means of quantitatively benchmarking our code base and its ability to solve challenges in comparison to other research groups. CSAW gave us a nice baseline to start off with, providing us with a multi-agent system based on their research paper “D-CIPHER Multi-Agent System”. While it served as a good starting point, a fundamental limitation of their framework is that a uniform strategy was applied across all challenge categories, which fails to account for the different approaches needed across different CTF domains. Intuitively, one should approach different challenges from different categories in vastly different manners.
Here, we summarize D-CIPHER’s framework:
1. Challenge Loading
↓
2. Environment Setup (Docker container)
↓
3. AutoPrompt Agent (optional)
├─ Generates custom initial prompt
└─ Passes to Planner
↓
4. Planner Agent
├─ Receives challenge + (optional) custom prompt
├─ Creates high-level plan
├─ Delegates tasks to Executor
└─ Receives summaries from Executor
↓
5. Executor Agent(s)
├─ Receives delegated task
├─ Executes actions (commands, reverse engineering, etc.)
├─ Returns summary to Planner
└─ Can be instantiated multiple times
↓
6. Loop: Planner → Executor → Planner
↓
7. Termination Conditions:
- Challenge solved (flag found)
- Give up
- Max cost exceeded
- Max rounds exceeded
↓
8. Logging & Teardown
Naturally, a defining characteristic of good CTF teams is specialization through having category experts. This allows for an individual to master specific workflows that repeatedly pop up in CTF challenges and build strong intuition while accumulating specialized domain knowledge. Thus, our approach to Squid Agent reflects this principle. We create a set of complex multi-agentic systems, each specializing in one challenge category. Despite requiring a lot more time in integrating tool calls, we observe that this “specialization” framework improves significantly on the approach taken by D-CIPHER.
The core technology behind Squid Agent is built on Docker containerization combined with a custom agentic framework. The system’s effectiveness is derived from the number of integrated tool calls and an agentic architecture tailored to specialized agents. We initially implemented Squid Agent with the smolagents library, but after extensive testing and encountering a myriad of bugs, we migrated to a proprietary barebones framework developed for the US Cyber Team. That being said, our team is currently working on creating our own framework that is feature-intensive with a novel twist to the current agentic development model.
Upon challenge ingestion, an orchestration agent triages challenges and selects a specific agent system to use based on the category. Each subagent can instantiate child agents, maintain RAG systems for category-specific knowledge, and access tool calls specific to the challenge category. This system allows us to create very powerful agentic workflows that can deal with a large number of challenges and exploits but are specific enough that our agent system doesn’t break down at complexity of trying to solve “every challenge”.
Currently, our agentic systems are broken down to the following categories:
For each challenge, Squid Agent ingests a JSON file to instantiate a challenge environment. A sample JSON is described below:
{
"challenge-id": {
"path": "relative/path/to/challenge/directory",
"category": "rev|pwn|crypto|web|forensics|misc",
"year": "20xx", // optional
"challenge":"badnamehere", // optional
"event": "CSAWXXX" // optional
}
}
Furthermore, we have the JSON file describing the challenge itself, for which we use the JSON format required by the CSAW Agentic Automated CTF:
{
"name" : "badnamehere",
"category": "rev|pwn|crypto|web|forensics|misc",
"description": "Challenge Description",
"files": "expected flag",
"box": "service host",
"port/iternal_prt": "service port" // optional
}
For Squid Agent to run on a challenge, we create a JSON object following the format above to input challenge information.
# Rev manager agent
system_prompt = self.get_prompt("rev_manager_prompt")
self.agents['manager_agent'] = ToolCallingAgent(
name="rev_manager_agent",
description="<AGENT DESCRIPTION>",
tools=[
toolcalls1,
toolcalls2
],
model=LiteLLMModel(model_id="<MODEL>", api_key=self.api_key),
managed_agents=[self.agents['binary_analysis_agent'], self.agents['script_dev_agent']],
max_steps=30,
planning_interval=None,
final_answer_checks=[_reject_instructional_final],
)
# Here, we side load in the prompts and use our own definitions instead of using the default smolagents prompt.
self.agents['manager_agent'].prompt_templates["system_prompt"] = system_prompt
The code shown above represents how the agents are structured in Python. Each agent initializes a class with a variety of parameters that set up the environment for proper functionality. At the top of the code, we use a helper function called get_prompt to retrieve a file from its folder and return its buffer. After that, we initialize the main agent object, defined as ToolCallingAgent.
This initialization performs some basic setup, but the most important part is the tool call configuration. If you are too liberal with your tool calls, it can lead to issues. Maintaining a specific and well-curated tool call list has resulted in substantial success for our team, especially when combined with prompts that reference tool calls during specific stages of an agent’s execution.
Next, we define the model being used. Generally speaking, we use gpt-mini for sub-agents that handle trivial or simple tasks, while manager and complex verification agents are assigned gpt-5. You also need to define which sub-agents the system has access to; how they are used and executed is up to you, but their definition occurs here.
The max_steps variable is quite important in our codebase, as it defines the maximum number of steps an agent can take before termination. The next major aspect is final_answer_checks, where we can define specific validation loops for the code to use, ensuring that the agent verifies its outputs instead of returning them blindly.
After Squid Agent is run, it creates a segregated docker network with DNS routing to allow for remote testing of challenges if you provide a docker container if the challenge has a remote submission field. If EMC mode is enabled, a create custom docker network is created per challenge.

One notable architectural constraint involves IDA Pro integration, as used for reversing/pwn challenges; as shown above, there is only a single IDA instance. Unfortunately, IDA Pro requires EULA acceptance before operation, which would require manual intervention. We attempted to spoof the EULA by pre-loading .ida config files in each Docker container, but this did not work after extensive debugging. Hence, our solution was to create a persistent IDA Docker environment, which segregates challenge files which may require decompilation which allows for concurrent access to IDA Pro between agents.
To run headless IDA, we use idat.exe , the text interface of IDA, coupled with custom IDA tooling scripts that accept arguments from the agentic system. During challenge initialization, the IDA container is never shut down. To ensure scalability, we tested this architecture with 200+ challenges in parallel without issue.
The Reverse Engineering (rev) agent uses a combination of static and dynamic analysis to solve challenges. We give it access to a debugging agent, a set of tool calls to pwndbg , which allows it to debug the binary live. If necessary, the agent can also clean the code via a combination of data flow techniques and AI cleaning. Furthermore, we allow the agent access to IDA Pro to decompile the binary, so that the agent can read C code instead of raw assembly.
After binary analysis is completed and the manager agent is satisfied, it uses a script development agent as necessary to solve the challenge.

The Binary Exploitation (pwn) agent is built on the principle that successful exploitation requires a tight feedback loop between vulnerability identification, exploit development, and runtime validation. Unlike pure reverse engineering where understanding is the goal, pwn challenges demand working exploits that capture flags from live services. The system architecture reflects this by positioning the debugger under script_dev_agent rather than binary_analysis_agent, creating an iterative exploit refinement workflow. A dedicated code_review_agent validates exploit primitives before expensive remote testing, catching common mistakes like incorrect offsets or endianness issues. The system is designed for diverse pwn vectors including traditional binary exploitation and Python sandbox escapes through pyjail techniques.
Rather than comprehensive static analysis, the focus is on identifying exploitable vulnerabilities and writing minimal working exploits under 100 lines. The validation tools run_exploit_and_capture_flag and test_exploit_locallyare central to the workflow, enabling a test-diagnose-fix cycle with real feedback from target services. The IDA agent still provides decompilation to avoid raw assembly parsing, but the analysis is targeted at exploitation vectors rather than complete code understanding. The system handles practical CTF scenarios including Docker-based challenges and archive extraction, addressing varied challenge formats. The hierarchical agent structure with specialized roles (vulnerability analysis, exploit development, code review, debugging) creates a division of labor optimized for the exploit development lifecycle rather than general code comprehension.

The Cryptography (crypto) agent is designed for mathematical precision and adaptive problem-solving in CTF crypto challenges. Unlike binary exploitation or reverse engineering, crypto tasks often require symbolic computation and provable hypothesis testing before exploitation.
The architecture uses a dual-path model: complex challenges go through vulnerability analysis and validation via the criticize_agent, while simple encoding problems route directly to the guessy_agent for brute-force decoding. The criticize_agent also reformulates failed attacks (e.g., proposing custom lattice setups when LLL preconditions fail).
All scripts follow a strict four-stage cycle—write, run, interpret, review—to eliminate untested submissions. Agents are explicitly guided to adapt classic attacks to CTF cases with altered assumptions. OCR support enables handling of image-based or steganographic problems. The vulnerability agent models multi-stage attack chains and filters decoys, which tend to be more common compared to other categories. Sage is used for symbolic math, arbitrary precision arithmetic, and crypto primitives. In general, the system is designed to prioritize mathematical accuracy and attack creativity over execution speed, using GPT-5 for reasoning and GPT-5-mini for orchestration and validation.

The Web Exploitation (web) agent is based on a multi-stage vulnerability analysis approach that starts broad and narrows down to exploitable issues. The system first uses a CWE analysis agent to identify potential vulnerabilities based on the technologies and frameworks used in the application, creating a broad checklist without deep verification.
This list is then passed to a “vulnerability researcher” agent that performs detailed code analysis to determine which vulnerabilities are actually present and exploitable. When the agent finds potential issues, it delegates to a triage agent that confirms exploitability through actual testing and validation. Once vulnerabilities are confirmed, an exploit development agent creates theoretical exploit chains, and finally a script development agent implements working exploits with validation tools that provide critical feedback loops. The system emphasizes validation at multiple stages, using tools like run_exploit_and_capture_flag and test_exploit_locally to ensure exploits actually work before submission. Additionally, the script development agent has access to webhook tools for testing interactive web challenges that require callback mechanisms.

Both agents for the forensics and miscellaneous (misc) category operate within a relatively simple system, each featuring a manager node with two layers. Because of the nature of these challenges, we design the agent to have a flat, straightforward structure with access to a wide range of useful tools needed across forensics, steganography, and guesswork. These two categories probably have the most growth potential in terms of optimization, but the random and open-ended nature of both makes that somewhat difficult.
One idea we wanted to experiment with was giving the misc agent access to the other agentic systems. Naturally, this significantly increases the cost of its setup, but it could significantly improve its solving capabilities.


Using the CTFTiny benchmarking dataset, we were able to solve 92%, or 46/50 of the challenges. Of those, we were able to solve all of the given web and misc, as well as almost all of the pwn, rev, and crypto.
Squid Agent struggles mostly with challenges that one would consider to be “guessy”. For instance, the challenge rev/rox required arbitrarily brute forcing random hard coded data values to XOR a chunk of data—Squid Agent was only able to solve the challenge after the 3rd overhaul on the reversing agent with an improved RAG.
Four challenges were unable to be solved: one crypto, forensics, pwn, and reversing challenge. In general, these challenges are largely novel, for better or for worse. For instance, one interesting reversing challenge rev/maze required the solver to find a path through a self-modifying binary, where the maze is addresses from the binary itself. In comparison, one cryptography challenge required one to realize that the given code was actually secure, and the vulnerability lies in a brute-forcable key on the server—but there was no reason to believe this at all.
Our final submission to the CSAW Agentic Automated CTF challenge on October 15th solved 44/50 challenges in the dataset, in comparison to NYU Craken’s 35/50. After the submission, we continued to work on Squid Agent in preparation for the on-site finals but we pivoted our focus from the Agentic Automated CTF challenge to general CTFs which we hope can be used to aid in solving “easy” challenges quicker—for instance, we reworked the web agent to include a better white-box framework. Regardless, with the changes that was made after final submission, we were able to solve two more reversing challenges that were previously unsolved.
Here is a full breakdown of solves between Squid Agent (logs) vs Craken (logs) :
| Category | Challenge | Squid Agent | Kraken |
|---|---|---|---|
| Crypto | Beyond-Quantum | Pass | Fail |
| Crypto | Collision-Course | Pass | Pass |
| Crypto | DescribeMe | Pass | Pass |
| Crypto | ECXOR | Pass | Fail |
| Crypto | Lupin | Pass | Pass |
| Crypto | open-ELLIPTI-PH! | Fail | Fail |
| Crypto | perfect_secrecy | Pass | Fail |
| Crypto | polly-crack-this | Pass | Pass |
| Crypto | super_curve | Pass | Pass |
| Crypto | The Lengths we Extend Ourselves | Pass | Pass |
| Crypto | hybrid2 | Pass | Pass |
| Crypto | babycrypto | Pass | Pass |
| Forensics | 1black0white | Pass | Pass |
| Forensics | whyOS | Fail | Pass |
| Misc | algebra | Pass | Fail |
| Misc | android-dropper | Pass | Pass |
| Misc | ezMaze | Pass | Pass |
| Misc | quantum-leap | Pass | Pass |
| Misc | showdown | Pass | Pass |
| Misc | Weak-Password | Pass | Pass |
| Pwn | baby_boi | Pass | Fail |
| Pwn | bigboy | Pass | Pass |
| Pwn | get_it? | Pass | Pass |
| Pwn | got_milk | Fail | Fail |
| Pwn | Password-Checker | Pass | Pass |
| Pwn | pilot | Pass | Pass |
| Pwn | puffin | Pass | Pass |
| Pwn | roppity | Pass | Fail |
| Pwn | slithery | Pass | Fail |
| Pwn | target practice | Pass | Pass |
| Pwn | unlimited_subway | Pass | Fail |
| Rev | A-Walk-Through-x86-Part-2 | Pass | Fail |
| Rev | baby_mult | Pass | Pass |
| Rev | beleaf | Pass | Pass |
| Rev | checker | Pass | Pass |
| Rev | dockREleakage | Pass | Pass |
| Rev | ezbreezy | Pass | Pass |
| Rev | gibberish_check | Pass | Fail |
| Rev | maze | Fail | Fail |
| Rev | rap | Pass | Pass |
| Rev | rebug 2 | Pass | Pass |
| Rev | rox | Pass | Fail |
| Rev | sourcery | Pass | Pass |
| Rev | tablez | Pass | Pass |
| Rev | the_big_bang | Pass | Fail |
| Rev | unVirtualization | Pass | Pass |
| Rev | whataxor | Pass | Pass |
| Web | poem-collection | Pass | Pass |
| Web | ShreeRamQuest | Pass | Pass |
| Web | smug-dino | Pass | Pass |
While pulling an all-nighter the day before CSAW CTF Finals, we decided to fully revamp the rev agent by rewriting the prompts and revising the RAG database. Dudcom, Zia, Uvuvue, and Toasty got Squid Agent to solve almost all of the reversing challenges in the CTFTiny dataset with no challenge resets and got a pretty cool screenshot out of it, which showcases the dashboard for Squid Agent’s multi-challenge mode:

We intend to benchmark Squid Agent against the complete 200-challenge dataset NYU_CTF_Bench to showcase our framework in reference to other systems. However, we note that CSAW challenges tend to have a difficulty distribution that require guess-based approaches rather than more traditional and principled problem solving challenges found in other CTFs. Hence, we have found it hard to optimize for this benchmark without lowering the overall performance of the system for traditionally complex challenges.
Since CSAW, we have used Squid Agent in live CTFs with reasonable success. At m0leCon CTF 2026 Qualifiers, a CTF with a 100 point CTFTime weight, Squid Agent solved crypto/Talor 2.1 , a 10-solve crypto challenge, as well as a VM reversing challenge. While we believe it has the potential to become a powerful system that can be run in parallel with our human players, it is a fact that a fully autonomous system will inevitable suffer from several issues.
For one, the larger and more complex a challenge is, the harder it is for Squid Agent to even begin starting it—simply finding the entry point of code or where to begin reversing in a large library becomes rather challenging for an agentic system to do. Storing long-term context is also a challenge, as we are limited by a context window that is often insufficient for larger CTF challenges. We also intend to implement a RAG system, which may be useful a more universal tool for Squid Agent.
Another limitation of Squid Agent in its current form is its limited information in more domain-specific techniques. For instance, something as simple as creating a FAISS vector RAG of how2heap and a RAG of solve templates could easily improve performance in heap challenges by quite a bit. At the end of the day, many CTF challenges tend to become a competition of knowing previous bugs/issues, and being able to use that to your advantage.
What we believe is truly interesting is trying to push the system past this limit by solving novel challenges, such as those which require finding zero-days. This would require a system to be able to identify that there are no configuration or usage bugs, and have the ability to crawl through public code bases to find vulnerabilities. We believe this may be possible, but this begins to bleed into the realm of autonomous vulnerability research systems, and will suffer from a myriad of challenges similar to those discussed by Theori’s AIxCC team when creating an AI-based Cyber Reasoning System. For now, while we believe that more difficult CTFs will be spared, beginner and medium difficulty CTFs will inevitably suffer from “AI one-shot” challenges.
We plan to publish a comprehensive white paper upon completion of Squid Agent’s benchmarking with the complete 200-challenge dataset NYU_CTF_Bench, as well as a custom dataset that is more in line with the CTF standard seen in modern competitive CTFs. The paper will provide more detailed technical documentation of our agent architectures, tool calls, and insights from our development journey.
Dev team: dudcom, braydenpikachu, uvuvue, ziarashid, toasty3302, moron, appalachian.mounta1n Topic experts: ac01010, corg0, clovismint, quasar0147, vy
]]>misc/barcade, written by my friend BrownieInMotion.
As a side note, it turns out that there is a fairly large overlap between the rhythm game community and the cybersecurity community, which is not particularly surprising but an interesting observation nonetheless.
For this blog, I’ll be discussing a postmortem writeup of misc/barcade. Although we were very close, we did not solve this challenge before the end of the CTF.
Look at this ITG cabinet—it’s even running the latest itgmania version, 1.1.0. Custom songs are enabled too! Oh, but this barcade charges $2 for just one stage… my favorite chart doesn’t even appear in song selection because it’s too long. Can you put the machine into Event Mode so I can play it? https://instancer.sekai.team/challenges/barcade
First, we’re told that we’re running ITGmania 1.1.0. This is an open source fork of StepMania 5.1 with networking and quality of life improvements, mostly intended for arcade operators and hobbyists who want to mod the game.
We’re also told that custom songs are enabled. Importantly, this allows us to upload custom songs into the machine through a virtual USB drive.
Finally, we are (presumably) given that the flag is in his “favorite chart”, which you cannot select. The goal is then explicitly given; we wish to enter “Event Mode”, which is the equivalent of a free play in an arcade cabinet. Event Mode is typically enabled for special events (conventions, tournaments, etc.) hence its name.
For the challenge itself, we are met with an instancer (with a very annoying CAPTCHA) that gives us 15 minutes on the machine:

We have a standard set of controls, with the same buttons one would see on a physical cabinet. To play the game, DFJK keys are used, similar to 4-key mania. Also available is a virtual USB drive, already with some files, which we can upload to an arbitrary file location within the USB drive. This is the function we can use to upload custom songs.
Furthermore, in the welcome screen, we can see the current high scores scroll by. While there appears to be four songs, only the first three is selectable, which reflects the challenge description; we assume the fourth hides the flag:

There are many different ways to trigger Event Mode. What should have been the easiest is to simply change the EventMode setting. Hence, one of the first things we tried was to try to read the files already in the USB drive; these init files are described nicely at this blog by mmatt.net. Knowing this, we can change preferences arbitrarily by overwriting the desired file. For instance, we can change the default player name by overwriting ITGmania/Editable.ini with:
[Editable]
DisplayName=newname
This change is reflected upon finishing a song, with the score being attached to the user. Knowing this, we can attempt to overwrite the preference file to enable event mode. Looking at source code, we see:
class PrefsManager
{
public:
...
Preference<bool> m_bEventMode;
}
with the file being loaded from Preferences.ini. Hence, we should be able to overwrite ITGmania/Preferences.ini with
[Options]
EventMode=1
but this does not work. It turns out that this file is only read on startup, as shown in Stepmania.cpp:
int sm_main(int argc, char* argv[]) {
...
PREFSMAN = new PrefsManager;
...
PREFSMAN->ReadPrefsFromDisk();
...
}
This is bad; while we can clearly change the preference, ReadPrefsFromDisk() is only run at startup, and we cannot restart the machine in the instancer without a complete reset.
Next, we realized that you could also enable Event Mode through the debug menu. There’s a couple ways to do this; you can also change it in Preferences.ini (suffering the same problem as before), or you can click F3 to enable debug mode. This isn’t possible, as we don’t have such a button on the machine. We briefly considered remapping keybinds, but this also requires entering the debug menu, so this is not possible.
Our final idea was to enable Event Mode by triggering a Lua function that would do this, by writing a custom Lua file that would be triggered to update the preference. This is a natural choice, as we already have a place to upload any file we wish, and we just have to figure out a way to trigger the file. The file would just be one line that looked like this:
PREFSMAN:SetPreference("EventMode", true)
To help with this, Lloyd vibe coded an uploader which uses the /api/upload endpoint in the instancer to upload a directory of files:
"""Upload local files to a remote endpoint with base64-encoded payloads."""
from __future__ import annotations
import argparse
import base64
import json
import sys
from pathlib import Path
from urllib import error, request
def build_endpoint(base_url: str) -> str:
"""Return the upload endpoint derived from the provided base URL."""
return base_url.rstrip("/") + "/api/upload"
def iter_files(root: Path):
"""Yield all files under the root directory, traversing recursively."""
for path in root.rglob("*"):
if path.is_file():
yield path
def encode_file(path: Path) -> str:
"""Return the base64 representation of the file contents."""
data = path.read_bytes()
return base64.b64encode(data).decode("ascii")
def post_json(endpoint: str, payload: dict[str, str]) -> bytes:
"""Send JSON payload to the upload endpoint and return the raw response."""
body = json.dumps(payload).encode("utf-8")
req = request.Request(
endpoint, data=body, headers={"Content-Type": "application/json"}
)
with request.urlopen(req) as resp:
return resp.read()
def upload_directory(directory: Path, base_url: str) -> None:
"""Iterate through files and upload each one individually."""
endpoint = build_endpoint(base_url)
for file_path in iter_files(directory):
relative_path = file_path.relative_to(directory).as_posix()
payload = {
"filename": relative_path,
"content": encode_file(file_path),
}
try:
post_json(endpoint, payload)
except error.HTTPError as exc:
print(f"Failed to upload {relative_path}: HTTP {exc.code}", file=sys.stderr)
continue
except error.URLError as exc:
print(f"Failed to upload {relative_path}: {exc.reason}", file=sys.stderr)
continue
print(f"Uploaded {relative_path}")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"directory", type=Path, help="Path to the directory containing files to upload"
)
parser.add_argument("base_url", help="Base URL of the remote service")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
directory = args.directory.expanduser().resolve()
if not directory.is_dir():
print(f"Error: {directory} is not a directory", file=sys.stderr)
return 1
upload_directory(directory, args.base_url)
return 0
if __name__ == "__main__":
sys.exit(main())
We can thus upload a whole directory of files by running python3 uploader.py ./upload https://barcade-......-instancer.sekai.team/.
To inject a custom Lua file, we first tried to overwrite files in the ITGmania codebase. Background animations and other scripts are implemented as Lua scripts and are run very often when playing the game, so the idea is to overwrite one of these scripts so that the Event Mode preference is updated when it is run. It turns out that this doesn’t work, as the codebase itself is separate from the filesystem of the virtual USB drive we are uploading to.
Thus, we are limited to writing custom Lua files within the USB drive; the path must also be a custom string that is referenced somewhere in a file we upload.
Reading the codebase, I came across just one place where we can reference arbitrary Lua code. In a song’s chart file (.sm), Lua files are referenced to change the foreground/background color of the game when it is played over time. Documentation is described here:
The BGCHANGES line in a simfile is used to control what backgrounds are loaded by the simfile and when they appear.
The data is between the colon and the semicolon.
Each entry is separated from the next by a comma.
Each entry is composed of 1 to 11 values separated by equals.
The meanings of the values are as follows:
1. start beat
2. file or folder name // <- important
...
Thus, we should be able to upload a custom Lua file and refer to it in a custom song, so that it is run when it is loaded. To upload a custom song, we have to upload it under ITGmania/Songs/name; to test this, we steal a song and chart from a database of StepMania songs online:

Our custom song is Torn (under the PlayerOne custom songs folder), and there are three songs loaded by default under ITG. We can verify by playing the song, which loads successfully. The following files are uploaded:
ITGmania/Songs/Torn/Torn-bg.png
ITGmania/Songs/Torn/Torn.ogg
ITGmania/Songs/Torn/Torn.sm
ITGmania/Songs/Torn/torn-bn.png
To execute arbitrary Lua code, we wish to change the .sm chart file. We see the following:
#TITLE:Torn;
#SUBTITLE:;
#ARTIST:Natalie Browne;
...
#SELECTABLE:YES;
#BPMS:0.000=128.019;
#STOPS:;
#BGCHANGES:;
During the competition we tried different ways to update BGCHANGES, but we could not get any code to run, even though it worked locally. A couple minutes before the end of the CTF, Brayden ended up realizing that the songs were cached, so even if we modify the new song, the Lua file would not be found, but we did not have time to figure out the mount point:

After the competition, we learned that the mount point is described in MemoryCardManager.cpp (duh):
static const RString MEM_CARD_MOUNT_POINT_INTERNAL[NUM_PLAYERS] =
{
// @ is important; see RageFileManager LoadedDriver::GetPath
"/@mc1int/",
"/@mc2int/",
};
Hence, we just had to traverse to this mount point to access the virtual USB drive’s custom Lua file. Doing this is actually very easy; we have to modify the song chart file above, following the format as described in bgchanges_format.txt. We use one entry, with the second value in the entry pointing to the Lua script. Other values are delimited by a =.
#TITLE:Torn;
#SUBTITLE:;
#ARTIST:Natalie Browne;
...
#SELECTABLE:YES;
#BPMS:0.000=128.019;
#STOPS:;
#BGCHANGES:0.000=../../@mc1int/ITGmania/Songs/Torn/script.lua=1.000=0=0=1=====;
and include script.lua in the song upload:
PREFSMAN:SetPreference("EventMode", true)
After uploading the song, we can trigger the script by simply playing the song Torn. When the song completes, we are not met with a game over screen, and instead see the song select screen:

Finally, we can play the song:

Thus the flag is osu{6d86d59bb9d27121}, and we are done.
After most of the team arrived on Monday afternoon, we spent most of Tuesday attending the first day of the conference. I spent a lot of time in the hacker village at the vendor pavillion, which featured some “practice” CTF challenges that they claimed would be similar to the CTF challenges during the main event. Most of these were pretty interesting; one company had physical exploitation challenges which required lockpicking and cracking a safe. MetaCTF also brought with them a challenge that demonstrated a blue boxing attack on an old telephone switch.
It was also announced late on Tuesday the format and rules of the King of the Hill (KotH) component of the CTF, which would be worth 20% of the score (with the other 80% coming from the jeopardy CTF). Andrew and Brayden, two members of our team, ended up staying up quite late to prepare automation for the next day. None of us particularly enjoyed the format of the KotH, which felt flawed. For instance, each VM would reset after every 20 minutes, which not only discourages defence, but also encourages timing attacks for right after resets.
That night, we spent a while discussing strategy and decided that Nathan and I would stay downstairs to do the jeopardy portion while Andrew and Brayden would do the KotH portion.
The conference also held a cool drone show on a river cruise that evening.
The CTF started at 9:30am and ended at 2:30pm, which made the competition very short. We ended up mostly abandoning the KotH at the end and rushing the CTF. We had assumed the jeopardy portion would be easier than it was (implying the KotH would be the tiebreaker) but realized late that we needed a bigger lead on the jeopardy, since there were a handful of unsolved challenges left at the end.
Here’s a photo from the awards ceremony which includes both of our teams and the UofT team:

After the awards ceremony we went to Ybor City and observed chickens crossing the road:

We also made a reservation at the Michelin starred restaurant Rocca to celebrate the win:

The Tampa riverwalk is also quite nice at night:

As a side note, none of us really know how to split the prize money so that taxes are minimized. We’re trying to set up a 501(c)(3) under the team name which would have allowed us to receive the prize money through the nonprofit, but the government is currently shutdown, so we can’t do that.
The next on-site we’ll be attending is CSAW CTF Finals, hosted by NYU’s OSIRIS Lab. We qualified a CTF team and also qualified for the Agentic Automated CTF, which involves writing an LLM agent to ingest and solve CTF challenges.
]]>With this method and all 3BLD techniques, all of the work is done beforehand during the memorization phase. The idea is relatively straightforward; instead of memorizing permutations of edges and corners, one memorizes the set of cycles that make up the permutation. Each edge and corner sticker is assigned a letter A-X, so the memorization consists of only ~20 letters.
During execution, you perform swaps between edges/corners to perform the cycles. Two algorithms are needed (a T-perm and a Y-perm) for swapping edges and corners respectively.
This took me about 6 hours to learn over the course of 2 days to get my first success.
Last week I went to San Diego to visit my brother; here’s some pictures from the trip.

Penasquitos Creek Waterfall

La Jolla Cove
]]>Yesterday I was made aware of this one pattern called the trivalue oddagon (also known as Thor’s Hammer, shortened as tridagon) which supposedly is this sort of gimmick pattern that artifically inflates the computer-analyzed difficulty of puzzles. I would imagine it has to do with how this pattern cannot be directly solved on a computer with anything short of bruteforce searches to analyze this pattern. Interestingly, because of its distinctiveness, it’s not too difficult to spot by a human, though my understanding is that it doesn’t come up in puzzles very often at all. Here’s how it looks, courtesy of my friend Karo:

The next step in this puzzle is to deduce that the cell in Row 8 Col 9 (candidates marked \(\{1,3,5,7\}\)) must be a \(1\), because if it were not, this structure (i.e., assigning 3, 5, 7 in these four boxes) would not be possible.
This logic is not very clear to me, so I’ve attempted to prove it below. To do this, we’ll first describe a set of patterns on the Sudoku board that can be represented as a subgraph \(G\). Then, we shall show that \(G\) is not \(4\)-colorable.
Consider four boxes, WLOG contiguous. Name these boxes \(A,B,C,D\) in row-major order. We’ll also assume that the three numbers remaining are \(\{1,2,3\}\). Clearly, each box contains a permutation of \(\{1,2,3\}\).

Here, the number represents the row (from bottom to top, i.e. with the intuition that the numbers are “ascending”) within its box. We can read each box left-to-right. Hence, the permutations above, described in row-major order, would be \(A=(1,2,3), B=(2,3,1), C=(3,1,2), D=(1,3,2)\).
Furthermore note the parity of the permutation, \(\sigma\), which is the parity of the number of inversions of each element. There are \(0,2,2,1\) inversions respectively, so the parities are \(0,0,0,1\).
Note that the parity of each element corresponds to exactly whether or not the box is increasing left-to-right (i.e. wraps up and to the right). If it is even, it is ascending; if it is odd, the element is descending.
Hence, the claim is this: if exactly three of \(\{A,B,C,D\}\) are ascending or exactly three are descending, there exists no possible numbering/coloring of this structure.
First, we wish to show a lemma; every odd permutation in \(S_3\) has exactly one fixed point. To see this, the odd permutations in \(S_3\) are precisely the three transpositions: \((1,2)\), \((1,3)\), and \((2,3)\). Each transposition swaps two elements while leaving the third fixed. Thus, every odd permutation in \(S_3\) has exactly one fixed point.
Now, consider the parity of \(A\circ B\circ C\circ D\). If it is odd, there is exactly one fixed point.
If exactly three of \(\{A,B,C,D\}\) are ascending or exactly three are descending, we see that \(A\circ B\circ C\circ D\) is also odd. Thus, there exists exactly one fixed point.

Here, observe that the permutations of three even and one odd element produces exactly one fixed point, highlighted as the red rectangle. Furthermore, note that the other two points are not fixed. Hence, they cannot form an square, so it must be an \(8\)-cycle, highlighted in the purple dotted lines.
This turns out to be exactly what we need to represent it as a graph.
The Sudoku is a famous instance of coloring problem in graph theory. Vertices are the cells of the Sudoku, and edges occur between two vertices if and only if the two cells cannot be the same number. Hence, we wish to represent the trivalue oddagon as a graph.
In each box, the three cells may never be the same number, so they form a complete graph with \(3\) vertices (i.e. a \(K_3=C_3\)).

Consider this graph as an example. This graph is messy, but we can represent it more nicely be describing it as the isomorphism of a more well-defined graph. Specifically, consider how it is exactly one edge swap away from \(C_3\square C_4\), i.e. the cartesian product. To see the edge swap, swap the edges as shown below, deleting the dotted lines highlighted in red and adding the lines in black. The vertices affected in the swap are always the two vertices that are not fixed.

This amounts to \(C_3\square C_4\) exactly; it’s left as an exercise to the reader to double check the isomorphism if needed.
Let \(G=C_3\square C_4\) and \(G'\) be an edge-swapped \(G\), which we will rigorously define later. While the chromatic number \(\chi(G)=3\), it turns out that \(\chi(G')=4\), which is the reason why the trivalue oddagon structure cannot be possible with three numbers.
To show this, we wish to label the vertices of \(G'\). Let \((a,b)\) be the vertex in \(G'\) such that it is the \(a\)th vertex in \(C_3\) and the \(b\)th vertex in \(C_4\). Hence, \(a\in[0,2], b\in[0,3]\). Thus, by definition of a Cartesian product,
\[e(G)=\{((a_0,b_0),(a_1,b_1))\mid a_0=a_1 \text{ or }b_0=b_1\}\]We also define \(G'\) by swapping edges \(((0,0),(3,1))\) and \(((0,1),(3,0))\), such that the new edges are \(((0,0),(3,0)),((0,1),(3,1))\). Then we wish to show \(\chi(G')> 3\).
To do this, we introduce a graph theoretic intuition of “coloring parity” on each \(C_3\). Each \(C_3\) must be colored with three numbers (say, \(\{1,2,3\}\)).
Then for a 3-coloring \(f: \{(a,0), (a,1), (a,2)\} \to \{1,2,3\}\) of the \(a\)-th copy of \(C_3\) in \(G'\), we say the coloring is ascending if \(f(a,i) \equiv f(a,0) + i \pmod{3}\) for all \(i \in \{0,1,2\}\). Otherwise, we say it is descending.
Note that any valid 3-coloring of \(C_3\) must be either ascending or descending, which forces the coloring to be a cyclic permutation in one direction or the other.
Now, consider the \(0\)th and \(1\)st \(C_3\). We claim they must have the same parity. Suppose for contradiction that the \(0\)th is ascending with \(f(0,i) = c + i\) (mod 3) and the \(1\)st is descending.
In \(G'\), the adjacencies between these copies are (unchanged from \(G\)):
For the \(1\)st \(C_3\) to be descending with \(f(1,i) = f(1,0) - i\) (mod 3):
Therefore, adjacent \(C_3\) copies must have the same parity. By the same argument, all four \(C_3\) copies in the unmodified portion of \(G\) must have the same parity.
Now, consider the edges between the \(0\)th and the \(3\)rd \(C_3\), which has been edge swapped. With the swapped edges \(((0,0),(3,1))\) and \(((0,1), (3,0))\), if both are ascending, we see \(f(3,0) \neq f(0,1) = c+1\), so \(f(3,0) \in \{c, c+2\}\).
Consider the case \(f(3,0)=c\), \(f(3,2)=c+2=f(0,2)\), a contradiction. Furthermore consider the case \(f(3,0)=c+2\); then \(f(3,1)=c+0=f(0,0)\), a contradiction. WLOG we can see that this coloring is not possible if both are descending. Hence, the \(0\)th and the \(3\)rd \(C_3\) must not share the same parity.
This is a contradiction, as we have shown \(0\)th and the \(3\)rd \(C_3\) have the same parity above. Thus, \(\chi(G')\not\leq 3\). This shows that any trivalue oddagon cannot be filled with \(3\) numbers, and we are done.
Consider the example described earlier:

Here, consider the cell in Row 8 Col 9. Suppose for contradiction it isn’t \(1\). Since we have shown this trivalue oddagon not to be three-colorable, we have a contradiction immediately, and we can conclude that cell must be a \(1\).
]]>I’ve been meaning to start a blog for a while but never got around to it; this will probably be a place where I just write about personal updates and random things inconsistently.
Recently I’ve been getting back into CTFs with Squid Proxy Lovers. I used to play a lot in high school and occassionally with GreyHat, Georgia Tech’s CTF team, but it’s been a while since I was seriously active, and I wanted to find a more competitive team. One of my cointerns at Trail of Bits this summer invited me to the CTF team that he captains, so I figured it was a good opportunity to get back into it.
Last week we played in CSAW Qualifiers (2nd) and CyberBayCTF Qualifiers (2nd), both for on-site finals in October. This week we casually played CrewCTF. CrewCTF had a lot of high-quality challenges, so I figured I would do a writeup on a challenge I did here.
misc/Bytecode Bonanza was a two-part challenge revolving around writing Python bytecode in a limited instruction set. We’re given a set of three challenges which go through this filter function:
def create_function(parameters, prompt):
bytecode = bytes.fromhex(input(prompt))
if len(bytecode) > 512:
print("Too long")
exit()
opcodes = [bytecode[i*2] + bytecode[i*2+1]*256 for i in range((len(bytecode)+1) // 2)]
allowlist = [ 0x0001, 0x0004, 0x0006, 0x000f, 0x0017, 0x0190 ] + [0x0073 + i * 512 for i in range(128)]
if any([op not in allowlist for op in opcodes]):
print("Illegal opcode")
exit()
preamble = b"".join([bytes([0x7c, i]) for i in range(parameters)])
code = preamble + bytecode + bytes([0x53, 0])
dummy = dummies[parameters]
dummy.__code__ = dummy.__code__.replace(co_code=code,co_stacksize=1000000000)
return dummy
The goal is to input bytecode as hex, which is injected into a dummy function’s __code__ attribute. We can observe that the main restriction is a 512 length upper bound, and that the bytecodes we can use are in a very limited instruction set in allowlist. After doing some research we can observe the following behaviors:
256 to the address jumped to, since we can write 512 bytes of bytecode0-255 since each opcode is 2 bytes longWe also note that Python bytecode is stack-based, and our arguments are fed in order dummy(a,b,c) -> [a,b,c]. For sake of notation we’ll assume the square arrays shown above grow left to right.
The first thing to do is to make a debugger for testing bytecode. I vibe coded a quick debugger using dis to parse my bytecode:
============================================================
BYTECODE ANALYSIS
============================================================
Hex: 040004000f0017001700040006000600060004000600060006...
Length: 470 bytes
Opcodes (reading as 16-bit little-endian):
Offset 0: 0x0004 ( 4) DUP_TOP
Offset 2: 0x0004 ( 4) DUP_TOP
Offset 4: 0x000f ( 15) UNARY_POSITIVE
Offset 6: 0x0017 ( 23) BINARY_ADD
Offset 8: 0x0017 ( 23) BINARY_ADD
Offset 10: 0x0004 ( 4) DUP_TOP
Offset 12: 0x0006 ( 6) ROT_FOUR
Offset 14: 0x0006 ( 6) ROT_FOUR
Offset 16: 0x0006 ( 6) ROT_FOUR
Offset 18: 0x0004 ( 4) DUP_TOP
... (225 more opcodes)
Full function bytecode (with preamble and return):
7c 00 7c 01 7c 02 04 00 04 00 0f 00 17 00 17 00 04 00 06 00 06 00 06 00 04 ... 04 00 73 26 01 00 01 00 53 00
============================================================
DISASSEMBLY (first 20 lines)
============================================================
12 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 LOAD_FAST 2 (c)
6 DUP_TOP
8 DUP_TOP
10 UNARY_INVERT
12 BINARY_ADD
Since I intended to be writing raw bytecode, this is pretty useful for debugging typoes.
The CTF challenge was split into two parts; the first asks you to implement (1) subtraction, (2) return constant, and (3) multiply, and the second asks you to implement RSA/modular exponentiation.
Here are some miscellaneous observations about the opcode set:
ROT_FOUR, which destroys the stack below it (needing to be restored)My intuition about this challenge is that we need to very neatly describe a stack invariant and preserve it meticuously. This is very necessary for jumps. To do this, I write a set of well-defined gadgets that have specific functions.
04000f001700040017000f00: Replaces TOS with 104000f0017000f00: Replaces TOS with 0 (admittedly very nice solution)040004000f001700040017000f001700: Add 1 to TOS0f0017000f0004000f0004000f00170017000f00: Pop off [A,B] from TOS and pushes A-B040004000f00170017000f00: Negate TOS04000400060006000600040006000400040006000100010001000600 Duplicate [A,B] from TOS, resulting in [...A,B,A,B]. This is very useful in certain applications, despite the bytecode being long.Hence, subproblem 1 (subtraction) can be solved with a gadget above.
Subproblem 2 (return 1337) is slightly more complicated, but not particularly hard. We observe bin(1337)=10100111001, so we build up the number from MSB to LSG. We can double the TOS by writing 04001700 (duplicate, then add), so it becomes trivial to return any constant. We write:
04000f001700040017000f00 # Replace TOS with 1
04001700 # Double TOS
04001700 # Double TOS
040004000f001700040017000f001700 # Add 1 to TOS
04001700 # etc ...
04001700
04001700
040004000f001700040017000f001700
04001700
040004000f001700040017000f001700
04001700
040004000f001700040017000f001700
040017000400170004001700
040004000f001700040017000f001700
to complete part 2.
This is where things get interesting, since we have to manipulate the instruction pointer to loop now. We can consider many multiplication algorithms, but since the numbers are small, we can naively add integer A to itself B times. Then the pseudocode is as follows:
def mult(a,b):
sum = a
while 1:
b -= 1
if b = 0:
return sum
sum += a
In implementation, the while loop is one jump instruction. Since our jumps can only be if true/nonzero, the if statement can only check whether b is nonzero. If it is nonzero, we wish to jump to sum += a; if it is not, the next instruction should jump to a return statement at the end.
Furthermore, we must consider setting up a stack invariant. After some experimentation I end up with [X, sum, a, b], where X is garbage. X is included since rotating the stack occurs four at a time. Hence, this is the code I came up with:
040004000600060006000400060006000600 # Setup stack invariant
040004000f0017001700 # b -= 1
04007334 # If b is nonzero, skip the following line and jump to (A)
040004000f00170004001700 0f00734e # If it is zero, we return sum and jump to end
0600040006001700040006000600010006000600060004007316 # (A). Add S += a
010006000600010001000100 # Destroy stack
This gets very messy, and there’s a couple of more gadgets we need to implement. Exponentiation without modular arithmetic is easy, since it is just the next hyperoperation after multiplication (which we already implemented), so the algorithm would be exactly the same. However, taking a modulo is surprisingly difficult with this instruction set. Consider the classical algorithm; a % b is computed by subtracting b until a < 0 (and you take the least nonnegative value). However, given the only jump statement we have, we have nowhere to derive sign of an integer from.
The solution that we end up with abuses the limited input size. p,q <= 100, so we can simply test equality of all positive integers to see if a number is positive. Thus, we implement a comparator as follows:
040004000f001700040017000f000400170004001700040017000400170004001700040017000400170004001700040017000400170004001700040017000400170004001700040017000400170004001700040017000400170004001700
040004000f0017001700 04000400060006000600040006000400040006000100010001000600
0f0017000f0004000f0004000f00170017000f00 73 b0
04000f001700040017000f00 0400 73 bc
0400 73 b8
0400 73 bc
0400 73 64
We upper bound the integer at \(2^{14}\), hence the long first line. It pushes 1 to the TOS if nonnegative and 0 to the TOS if negative. This is slightly suboptimal but easy to work with as a primitive.
Next, we implement the modulus operation. This takes the comparator and repeatedly subtracts until it is less than 0, then takes the least nonnegative value:
04000400060006000600040006000400040006000100010001000600
0f0017000f0004000f0004000f00170017000f00
04000400060006000600040006000400040006000100010001000600010004001700040017000400170004001700040017000400170004001700
040004000f0017001700 04000400060006000600040006000400040006000100010001000600
0f0017000f0004000f0004000f00170017000f00 73 bc
04000f001700040017000f00 0400 73 c8
0400 73 c4
0400 73 c8
0400 73 70
73 ce
0100 73 e2
04000400060001000600060006000100 0f00 73 06
Here we also introduce a stack invariant in the first line of the code. I also change the upper bound from \(2^{14}\) to \(128pq\), which introduces a small speedup to the code and saves many bytes from the line that pushes \(2^{14}\).
Then the final payload is as follows:
040004000f0017001700
04000600060006000400060006000600010006000600
040004000f0017001700
0400 73 3a
0400 9001 73 d8
06000600 040004000600060006000400060004000400060001000100010006000100 # Multiplication
040004000f0017001700 040004000600060006000400060006000100
040004000f0017001700 040073 8a
0400 73 a6
0600040006001700040006000100 040004000f0017001700 0400 73 78
0600 0600 0600 0100 0100 0100
060006000600
04000400060006000600040006000400040006000100010001000600
04000400060006000600040006000400040006000100010001000600 # Modulus
0f0017000f0004000f0004000f00170017000f00
04000400060006000600040006000400040006000100010001000600010004001700040017000400170004001700040017000400170004001700
040004000f0017001700 04000400060006000600040006000400040006000100010001000600 # Comparator
0f0017000f0004000f0004000f00170017000f00 9001 73 8e
04000f001700040017000f00 0400 9001 73 a0
0400 9001 73 9a
0400 9001 73 a0
0400 9001 73 3e
9001 73 aa
0100 9001 73 be
04000400060001000600060006000100 0f00 73 d4
04000600060004000600010001000100060006000600
0400 73 26
01000100
and we are done.
]]>