puck tools — test your egress policy against real C2 traffic →
#ai#linux

How to learn while using AI

The Grail Knight from Indiana Jones and the Last Crusade, standing before a table of chalices in the torchlit temple, mid-sentence.

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.

Walter Donovan in Indiana Jones and the Last Crusade, aged to a screaming skull seconds after drinking from the wrong grail.

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.