DEV Community

Cover image for A CTF Session With No Flag — and Why It Still Counted
auto_majicly
auto_majicly

Posted on

A CTF Session With No Flag — and Why It Still Counted

I spend most of my time building a small offensive-security framework. Today I spent a session working a target the manual way and finished it without capturing a single flag. I want to write about that session anyway, because the reason it was still worthwhile is, I think, the most useful thing I've learned this year.

The tooling milestone behind the session
First, some context on what I've been building. The framework — I call it HALO — recently crossed an important threshold: the tool arsenal grew from roughly thirty integrations to forty-two, and the new additions form a complete web pipeline. Reconnaissance flows into web attack, which flows into flag capture, all coordinated behind a single authorization gate rather than requiring me to shuttle output between terminals by hand.

The more significant change, however, isn't a tool at all. It's a design principle I keep having to relearn:

A system should never be the sole judge of its own success.

Earlier versions of HALO would execute an exploit, observe output that resembled success, and report a compromise. It was frequently wrong. The problem wasn't a weak model — it was the absence of any external definition of "did this actually work." Any string that looked like a win was treated as one. In one memorable run it reported compromising twenty-three of twenty-three services. The verified number was zero.

The fix was architectural, not intellectual. I moved the definition of success outside the component being evaluated: evidence-based confirmation that a shell is genuinely interactive, a challenge-response the executing agent cannot reason its way around, and verification that does not rely on the attacker's own account of events. When the proof of success lives inside the process that wants to succeed, that process will always find a way to pass. The judge has to sit outside the room.

I mention this now because the session that followed was, in effect, the human version of the same lesson.

The target
I was working an introductory HackingHub environment (their VulnBegin hub) alongside an AI assistant. The division of labor suited me: I direct the engagement and run the in-network commands, and the assistant handles rapid reconnaissance and reasoning. The hub has an interesting structure — each time you spin up the environment, it assigns you a single, randomly selected flag to solve. You don't choose which one. Every spawn is therefore a self-contained puzzle, and I had a handful of stubborn ones remaining.

Reconnaissance returned the expected web application, plus two details worth attention:

A second HTTP service on a high port, which the application's admin dashboard described as an "API SERVER — CONNECTED."
A second SSH daemon, on an unusual high port and a different build from the primary one.
The application also disclosed an API token. So I had a token, a service advertising itself as an API, and a dashboard insisting the two were linked. That is a compelling narrative, and the instinct is to follow it.

Two false leads, and how they were ruled out
The token. It had every appearance of a key. I supplied it to the main application in every reasonable form — as a header, a query parameter, a bearer credential — across every route I could enumerate. The result was not rejection; it was indifference. The application gave no indication it recognized the token at all.

The "API server." This is the lead that would have consumed an entire evening a year ago. The high-port service returned ERROR - NOT PART OF THE CTF/TRAINING to every request. My assumption was a routing problem: wrong path, wrong method, wrong host header, wrong token format. I tested each of them. A bare GET / with the valid token produced the same error. A POST produced the same error. A spoofed Host header produced the same error.

At that point the pattern resolves: the service is not rejecting my request, it is returning that identical string to everything. It is not the challenge's API server — it is the platform's global out-of-scope guard, the same boundary every environment presents for infrastructure outside the current exercise. The dashboard's "CONNECTED" label was decoration. The disclosed token was a distractor.

Ruling out a lead is a skill in its own right, and it obeys the same principle as the tooling work above: you do not get to declare a path dead because you have grown tired of it — the evidence has to declare it. A single authenticated request receiving the same canned response as a nonsense one is evidence. Intuition is not.

What actually worked
Amid the noise, the login form contained a genuine and instructive flaw: it revealed whether a given username existed. A wrong password against a valid account returned "Password is invalid," while any other input returned "Username is invalid." That is username enumeration — a legitimate vulnerability class — and I was able to execute it end to end: assemble a candidate list, run it through the oracle, and confirm the valid account.

It did not directly yield the flag. But it is a technique I will now recognize immediately in real environments, which I value more than a single hash.

Where the clock won
By elimination, this spawn's flag almost certainly resided behind the second SSH daemon: a weak-password brute-force, among the most common introductory challenge types. I mishandled the execution. My attack host was recently rebuilt and did not yet have its password wordlists unpacked. I lost the final ten minutes resolving wordlist paths, located the correct list, launched the attack — and the environment timed out mid-run.

The SSH hypothesis is therefore untested, not disproven. That distinction matters, and I am recording it honestly.

Why the session still counted
The outcome, stated plainly:

I converted an unknown target into a documented map — what is real, what is a wall, and what remains untested.
I executed a real username-enumeration exploit from start to finish.
I eliminated two convincing false leads on the basis of evidence rather than fatigue.
I documented everything, so the next attempt begins where this one ended rather than at zero.
No flag was captured. But "no flag" and "no progress" are not the same statement, and treating them as equivalent is precisely how people abandon work that was nearly finished.

The connecting thread between the tooling and the session is a single principle: be most skeptical of the conclusions you most want to reach. A framework that certifies its own success will always succeed. An analyst who abandons a lead out of boredom will walk past the real vulnerability. And a session measured only by flags will read as failure the moment the only acceptable receipt is a flag.

Define success outside the thing being measured. Let the evidence decide. Record what you learned.

Then spin the environment up again.

Top comments (0)