
AI offers us an interesting choice while solving problems. You can have your agent solve a problem for you, or you can have your agent teach you how to solve the problem. Which will likely let you solve more complicated problems later.
You must choose, but choose wisely.
Let’s imagine a simple CTF challenge. It takes one argument, prints something, exits. The goal is to figure out what makes it happy.
$ ./welcome Evan
Hello, Evan. Access denied.
$ echo $?
1
That’s the whole challenge. It’s the smallest possible CTF: no source, no hints, one input. This is a good example of how you use an agent, because there are two completely different things you can type into Claude Code here, and they produce opposite outcomes.
One of them ends the challenge. The other teaches you gdb. Which one you get depends entirely on whether you ask for the answer or ask for the method.
The simple path
Ask the agent to solve it and it will solve it:
Solve this challenge. What argument does ./welcome want?
It runs strings, greps for anything that looks like a token, and hands you the
answer:
$ strings welcome | grep -iE "context|hello|denied"
CONTEXT
Hello, %s. Access granted.
Hello, %s. Access denied.
$ ./welcome CONTEXT
Hello, CONTEXT. Access granted.
Challenge over. The agent did nothing wrong. That is genuinely the correct first
move against an unknown binary, and any reverser would run it before touching a
debugger. But look at what you now know: the password is CONTEXT. That’s it.
You can’t answer a single follow-up. How does the binary compare the string?
Where does your argument live in memory? What would you do if the constant were
built at runtime and strings came back empty?
The flag was never the point. The flag is the thing you trade the lesson for.

He chose poorly.
The other question
Same binary, different prompt:
Don’t tell me the answer. I want to learn gdb. Walk me through how you’d find the comparison, and explain each command before I run it.
That reframing is the whole trick. You’ve moved the agent from answer engine to something that has read every gdb man page and will explain them on demand. It still knows the answer. It just isn’t allowed to say it, so the only thing left to offer you is method.
Here’s where that goes.
Looking at main
gdb defaults to AT&T syntax, which puts the operands backwards from every Intel manual you’ll ever read. Fix that first:
$ gdb -q ./welcome
(gdb) set disassembly-flavor intel
(gdb) disassemble main
You get the whole function. The part that matters is the middle:
0x000000000040120a <+84>: mov rax,QWORD PTR [rbp-0x40]
0x000000000040120e <+88>: add rax,0x8
0x0000000000401212 <+92>: mov rcx,QWORD PTR [rax]
0x0000000000401215 <+95>: lea rax,[rbp-0x30]
0x0000000000401219 <+99>: mov edx,0x1f
0x000000000040121e <+104>: mov rsi,rcx
0x0000000000401221 <+107>: mov rdi,rax
0x0000000000401224 <+110>: call 0x401080 <strncpy@plt>
0x0000000000401229 <+115>: mov BYTE PTR [rbp-0x11],0x0
0x000000000040122d <+119>: lea rax,[rbp-0x30]
0x0000000000401231 <+123>: lea rdx,[rip+0xdde] # 0x402016
0x0000000000401238 <+130>: mov rsi,rdx
0x000000000040123b <+133>: mov rdi,rax
0x000000000040123e <+136>: call 0x4010b0 <strcmp@plt>
0x0000000000401243 <+141>: test eax,eax
0x0000000000401245 <+143>: jne 0x401269 <main+179>
Ask the agent: “Line by line, what is add rax,0x8 doing at +88, and why
0x8 specifically?” You’ll learn that [rbp-0x40] is where argv got spilled,
that pointers are eight bytes, and that argv + 8 is therefore &argv[1], your
argument. That one question turns four opaque instructions into a sentence you
could have written yourself.
The shape is now readable without knowing any of the values. Copy the argument
into a stack buffer at rbp-0x30, compare that buffer against something at
0x402016, and branch on the result. Two calls, one comparison. Everything
interesting happens at +136.
Reading the arguments
x86-64 passes the first two arguments in rdi and rsi. So if we stop the
process exactly at the call — after the operands are loaded, before the call
executes — both strings are sitting in registers waiting to be read.
Breakpoints can go on a raw address with *:
(gdb) break *0x40123e
Breakpoint 1 at 0x40123e
(gdb) run Evan
Breakpoint 1, 0x000000000040123e in main ()
(gdb) info registers rdi rsi
rdi 0x7fffffffde20 140737488346656
rsi 0x402016 4202518
Two numbers, and neither is useful yet. rdi is a stack address, rsi is that
0x402016 we saw in the disassembly. They’re pointers, so dereference them as
strings with x/s:
(gdb) x/s $rdi
0x7fffffffde20: "Evan"
(gdb) x/s $rsi
0x402016: "CONTEXT"
There it is. Same answer strings gave us, except this time you know where it
lives, when it’s loaded, and which function consumes it — and you got there
by a route that still works when strings doesn’t.
Ask the agent: “What else can I put after x/?” This is the single
highest-value question in this whole session. x/s, x/16xb, x/4gx, x/3i:
the format letters are the difference between gdb being usable and gdb being a
wall of hex.
If you want to watch the arguments actually land in the registers, step one
instruction at a time with stepi and have gdb print the upcoming instruction
every time it stops:
(gdb) break *0x40122d
(gdb) run Evan
(gdb) display/i $pc
(gdb) stepi
1: x/i $pc
=> 0x401231 <main+123>: lea rdx,[rip+0xdde] # 0x402016
(gdb) stepi
1: x/i $pc
=> 0x401238 <main+130>: mov rsi,rdx
(gdb) stepi
1: x/i $pc
=> 0x40123b <main+133>: mov rdi,rax
(gdb) stepi
1: x/i $pc
=> 0x40123e <main+136>: call 0x4010b0 <strcmp@plt>
Four instructions, and you can see the entire calling convention assemble itself
in front of you. lea loads the address of the constant, it goes into rsi, the
buffer address goes into rdi, then the call. Nothing hidden.
Step one past the call and you can see the verdict, too:
(gdb) break *0x401243
(gdb) run Evan
(gdb) info registers eax
eax 0x2 2
(gdb) x/2i $pc
=> 0x401243 <main+141>: test eax,eax
0x401245 <main+143>: jne 0x401269 <main+179>
strcmp returned 2, test sets the zero flag only when its operand is zero, so
jne takes us to the failure branch. That is the entire gate, in two
instructions.
Changing the string
Reading memory is half of gdb. Writing it is the half that makes debuggers feel illegal the first time.
We’re stopped at the strcmp call with rdi pointing at our copied argument.
Nothing stops us from replacing it in place:
(gdb) break *0x40123e
(gdb) run Evan
Breakpoint 1, 0x000000000040123e in main ()
(gdb) x/s $rdi
0x7fffffffde20: "Evan"
(gdb) set {char[8]} $rdi = "CONTEXT"
(gdb) x/s $rdi
0x7fffffffde20: "CONTEXT"
(gdb) continue
Hello, CONTEXT. Access granted.
[Inferior 1 (process 94749) exited normally]
We launched the process with the wrong password and it granted access anyway. The comparison never knew.
The syntax is the fiddly part: {char[8]} is a cast telling gdb how much memory
to write and how to interpret the value. Eight bytes for seven characters plus
the terminating NUL. Get that wrong and you’ll leave the old string’s tail
dangling on the end of the new one.
Ask the agent: “Why does set {char[8]} $rdi need the size, and what happens
if I write more bytes than the buffer holds?” The answer to the second half is
the entire field of memory-corruption bugs, arrived at from a direction that
actually makes sense.
You can also get in one step earlier and patch the argument itself, before
strncpy ever copies it. Same idea, different address. Break on the copy instead
of the compare, and rsi is now argv[1] rather than the local buffer. Pass a
seven-character placeholder so the replacement fits exactly where the original
sat:
(gdb) break *0x401224
(gdb) run AAAAAAA
Breakpoint 1, 0x0000000000401224 in main ()
(gdb) x/s $rsi
0x7fffffffe2de: "AAAAAAA"
(gdb) set {char[8]} $rsi = "CONTEXT"
(gdb) continue
Hello, CONTEXT. Access granted.
Note the address: 0x7fffffffe2de, way up the stack, nowhere near the
0x7fffffffde20 buffer. That’s argv living up near the environment block where
the kernel put it at exec time, and seeing those two numbers side by side teaches
you more about process memory layout than any diagram.
There’s a third way, and you can probably see it now — eax held the comparison
result, so set $eax = 0 right before the test skips the string entirely and
walks straight into the success branch. Same gate, three different places to
break it. That’s the actual lesson: a check isn’t one thing, it’s a chain, and
every link is a place to intervene.
What the binary was
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
char name[32];
if (argc != 2) {
fprintf(stderr, "usage: %s <name>\n", argv[0]);
return 1;
}
strncpy(name, argv[1], sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
if (strcmp(name, "CONTEXT") == 0) {
printf("Hello, %s. Access granted.\n", name);
return 0;
}
printf("Hello, %s. Access denied.\n", name);
return 1;
}
Build it with gcc -O0 -no-pie -o welcome welcome.c and your addresses will match
the ones above. -no-pie is why: without it the binary is position-independent
and every address gets a random base at load time, so nothing in this post would
line up twice in a row. Real binaries in 2026 are all PIE, but a tutorial whose
addresses change every run is a tutorial nobody can follow. Once you’re
comfortable, drop the flag and learn to work in offsets from a module base
instead. Ask the agent: “How do I find main in a PIE binary before it’s
loaded?”
The part that generalizes
Both prompts got the right answer. The difference is what was left over afterward, and that difference compounds. The eight-second path leaves you needing the agent again next time, slightly more than you did this time.
So the useful instinct isn’t “don’t use the agent.” It’s noticing the moment when
you’re about to ask for an answer to something you’d rather be able to do, and
asking for the method instead. It costs twenty minutes. The strings shortcut
will still be there afterward, and now you’ll know what it’s hiding from you.