DUCTF 2023 – Wrong Signal

You straight to oops(). Right away.


Medium. 27/1424 solves.

I am getting all the wrong signals from this binary.

Author: hashkitten



On decompilation, the binary appears to be an innocent program which adds and subtracts numbers. The code itself is relatively simple.

// Set the SIGSEGV handler.
sigsegv_sigaction.__sigaction_handler.sa_handler = oops;
sigsegv_sigaction.sa_flags = 4;

// Read input.
puts("Enter the password:");

// Check input for correctness.
local_c0 = &DAT_13386000;
for (i = 0; i < 0x40; i += 1) {
	j = i;
	if (i < 0) {
		j = i + 3;
	switch((int)(char)(buffer[j >> 2] ^ mangle_buf[j >> 2]) >>
		   (((char)i - ((byte)j & 0xfc)) * 2 & 0x1f) & 3) {
	case 0:
		local_c0 = local_c0 + -0x15000;
	case 1:
		local_c0 = local_c0 + -0x1000;
	case 2:
		local_c0 = local_c0 + 0x1000;
	case 3:
		local_c0 = local_c0 + 0x15000;

if (local_c0 == &DAT_13398000) {
	puts("Well done! Wrap that in DUCTF{}.");
else {


  • A sigaction handler takes care of any SIGSEGV faults, outputs Wrong! and exits. SIGSEGVs occur when there are invalid memory accesses (e.g. reads, writes).
  • The for-loop iterates through crumbs (2-bit groups1), xors it with static data (mangle_buf), and modifies local_c0 depending on the value of each crumb. Yeah, that eyesore in the switch condition computes the current crumb.
  • Since 16-bytes are read, this means there are 64 crumbs, therefore 64 operations.
  • Our goal is to modify local_c0 so that it goes from 0x13386000 to 0x13398000—a difference of 0x12000.

The last point is interesting, since there's no way we can get a unique solution. There are multiple ways to reach an offset of 0x12000.

For example, if our crumbs are 3, 1, 1, 1, then we've already arrived at our target address, right? Then we can just fill the rest with 2s and 1s to do nothing to local_c0, right? Right?

wRoNg! Ay c-rumba.

Terminal output showing 'Wrong!'

Using a Z3 script spun by reversing the program, we can output some test payloads. Now obviously this isn't the flag, but I'm interested in testing out some cases. Using I as the first letter, we trigger case 3 (+0x15000) as our first operation.2 Turns out we can't do that as our first move, because it catapults us into oops().

Jail meme. But going to the oops function instead of jail.

We have the best flag. Because of oops().

If instead our first case was 2, the program continues, and we're not thrown straight into jail oops(). So there must be something we're missing.

Where are the segfaults coming from?

While all the above observations are fine and dandy, the decompilation leaves out something crucial. Isn't it weird how local_c0 seems to be working with addresses and jumping around without actually doing anything? Turns out, there's a sneaky little dereference after the switch-case, at 0x401305.

; 0x4012fe. Load `local_c0` from stack to RAX.
MOV        RAX,qword ptr [RBP + local_c0]

; 0x401305. Dereference `RAX` to `AL`.
MOV        AL,byte ptr [RAX]=>DAT_13386000

Don't underestimate these few lines. Even though the dereferenced value is unused, a read is performed nonetheless!

So why are some reads causing segfaults? To answer this, we can use the vmmap command that comes with GDB GEF. This shows various segments of a binary, their address ranges, and whether they're readable/writable.3

Image of vmmap command and output in GEF.

Yikes! That's a lot of segments. Notice how some of them disallow all permissions? Our pointer was trying to read those regions. All that's left is to filter out the regions in our code.

Concluding Remarks

Peeking at the official solve script... it turns out the challenge was a... maze?!? Wut? Didn't expect that. But overall it was a fun little challenge with some nice surprises, and a good reminder to not overlook (or completely ignore) the small details such as that hidden byte read.

Solve Script

I didn't do a step-by-step walkthrough of my solve script this time, but I've littered it with comments, so hopefully it's understandable—even for those new to the Z3 library.



  1. And for the nerds: 4-bits is a nybble/nibble. ↩︎

  2. Verifiable through GDB, with b *main+245 and p $rax. ↩︎

  3. There are other similar tools, but I'm accustomed to GEF's vmmap. ↩︎

Share on

Commenting has vanished into a blackhole and shall return some time in the future (or past?)! Time paradoxes not guaranteed. If you have any feedback or suggestions, please direct your subspace frequencies to the contact form. Thanks!