VERE - Rev-4 - 13bit

Published at Mar 15, 2025

#ctf#rev
Challenge Description

I wrote a program that should give you the flag… if you can run it! I changed exactly 13 bits in this program. You’d think that such a small change shouldn’t make it SO hard to fix, right?? How well do you know the ELF file format?


Overview

This challenge comes with a corrupted ELF executable 13bit that, according to the description, had exactly 13 bits flipped from its original functional state. The goal is to identify and correct these bit flips to restore the program, which, when executed, gives us our flag.


Fixed Bits So Far: 0

Running file 13bit initially shows nothing helpful, linux doesn’t recognize it as an ELF file. Unsurprisingly, attempting to execute it fails as well.

file 13bit

The first indication of something identifiably wrong comes when I run the binary through xxd:

xxd 13bit

I can’t remember exactly what the first digit of the ELF header looks like, but the empty } looks strange to me. Fortunately, I have another ELF file that I can compare:

xxd death-star

Do you see that? The first four bytes (The Magic Number, which tells the computer what type of file this is) of both files are different, even though they are both ELFs! 7D 45 4C 46 is close to the working executable’s 7F 45 4C 46, I wonder how different 7D and 7F are when expressed as bits. Writing them out we can see this:

>>> bin(int('7D',16))
'0b1111101'
>>> bin(int('7F',16))
'0b1111111'

Well lookey here, one bit different (the second-last one). I think we’ve found our first corrupted bit! Let’s fix that with the following short python script and see if it does the trick:

with open("13bit", "rb") as f:
    data = bytearray(f.read())
data[0] = 0x7f  # 7D -> 7F
with open("13bit_fixed", "wb") as f:
    f.write(data)

This script just isolates that byte in the file and writes the one that I want it to change to (7F). Running the same identification commands on the new 13bit_fixed file shows:

Identify 13bit_fixed

I’ve never been so happy to see a seg fault. That means our file is now recognized as an executable! …an executable that doesn’t work still, but hey progress is progress.


Fixed Bits So Far: 1

Now that we have a recognizable executable, there’s another command that can help identify the file: readelf. Here is the output of that on my fixed file:

readelf -l 13bit_fixed

Elf file type is DYN (Shared object file)
Entry point 0x10c0
There are 13 program headers, starting at offset 48

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  NULL           0x001e001f0040000d 0x0000000400000006 0x0000000000000040
                 0x0000000000000040 0x0000000000000040         0x2d8
  <unknown>: 2d8 0x0000000000000008 0x0000000400000003 0x0000000000000318
                 0x0000000000000318 0x0000000000000318         0x1c
  <unknown>: 1c  0x0000000000000001 0x0000000400000001 0x0000000000000000
                 0x0000000000000000 0x0000000000000000         0x7c8
  <unknown>: 7c8 0x0000000000001000 0x0000000500000001 0x0000000000001000
                 0x0000000000001000 0x0000000000001000         0x351
  <unknown>: 351 0x0000000000001000 0x0000000400000001 0x0000000000002000
                 0x0000000000002000 0x0000000000002000         0x134
  <unknown>: 134 0x0000000000001000 0x0000000600000001 0x0000000000002dc8
                 0x0000000000003dc8 0x0000000000003dc8         0x3458
  <unknown>: 348 0x0000000000001000 0x0000000600000002 0x0000000000002de0
                 0x0000000000003de0 0x0000000000003de0         0x1e0
  <unknown>: 1e0 0x0000000000000008 0x0000000400000004 0x0000000000000338
                 0x0000000000000338 0x0000000000000338         0x20
  <unknown>: 20  0x0000000000000008 0x0000000400000004 0x0000000000000358
                 0x0000000000000358 0x0000000000000358         0x44
  <unknown>: 44  0x0000000000000004 0x000000046474e553 0x0000000000000338
                 0x0000000000000338 0x0000000000000338         0x20
  <unknown>: 20  0x0000000000000008 0x000000046474e550 0x0000000000002008
                 0x0000000000002008 0x0000000000002008         0x3c
  <unknown>: 3c  0x0000000000000004 0x000000066474e551 0x0000000000000000
                 0x0000000000000000 0x0000000000000000         0x0
  NULL           0x0000000000000010 0x000000046474e552 0x0000000000002dc8
                 0x0000000000003dc8 0x0000000000003dc8         0x238
    ...

As you can see, all of the headers are <unknown>. This isn’t what this is supposed to look like, so there is clearly something still wrong with this header.

Realizing that my knowledge of the ELF header ended at Magic Bytes, and considering that the challenge description indicates that I should know the ELF header in detail, I did some research (This blog summarized it well, but I had to use a couple extra resources to really understand it). To summarize what I found:

ELF Header Requirements:

The ELF header is 64 bytes (in a 64-bit environment) and defines the file’s structure. Here is how it is laid out:

  • Bytes 0-3: Magic number (7F 45 4C 46).
  • Byte 4: Class (02 = 64-bit).
  • Byte 5: Endianness (01 = little-endian).
  • Byte 16-17: Type (02 = ET_DYN, 03 = ET_EXEC).
  • Byte 24-31: Entry point (e_entry, virtual address of first instruction).
  • Byte 32-39: Program header offset (e_phoff, offset to program header table).
  • Byte 56-57: Number of program headers (e_phnum).

With that information now at the forefront of my mind, I looked back at the readelf output, this part in particular:

Entry point 0x10c0
There are 13 program headers, starting at offset 48

and I realized that the program headers start at offset 48. Considering that I just learned that the file headers are 64 bytes long, this seems incorrect. As a less-concrete but still valid point, from the challenge author’s perspective, it makes a lot of sense to alter the entry point — both from an obfuscation standpoint and as a breadcrumb for the solver to follow. The third thing I noticed is that this change is exactly 16 numbers off, which is a binary value, indicating that some binary change happened to cause the difference. Doing the same binary comparison as before on 0x30 (Hex for 48) vs my expected 0x40 (Hex for 64) value, the binary representation is:

>>> bin(int('30',16))
'0b0110000'
>>> bin(int('40',16))
'0b1000000'

Just like I expected, these binary values are remarkably similar, although these have 3 bits differing at positions 2, 3, and 4 (the leading 0 is cut off). I don’t know if the challenge would flip multiple bits for one fix, but I definitely prefer flipping more than one bit each time, that makes for less steps overall. Let’s see if this is the case.

Looking into the xxd:

xxd -l 64 13bit_fixed
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0300 3e00 0100 0000 c010 0000 0000 0000  ..>.............
00000020: 3000 0000 0000 0000 386a 0000 0000 0000  0.......8j......
00000030: 0000 0000 4000 3800 0d00 4000 1f00 1e00  ....@.8...@.....

Referring back to my research on the ELF header requirements, the program header offset e_phoff starts in byte 32. This translates to address 0x20 in hex, which is how the xxd command outputs the addresses. There in address 0x20, is our target value, 30. The binary is currently reading the program header offset as 48, which in hexadecimal is 30, so this is exactly what we would expect to see there (it always feels good to be validated)! Let’s change that to 40 (hex for 64) with a modification to our previous script:

with open("13bit_fixed", "rb") as f:
    data = bytearray(f.read())
data[32] = 0x40  # 30 -> 40
with open("13bit_fixed2", "wb") as f:
    f.write(data)

Let’s see how that affected my file:

file 13bit_fixed2
13bit_fixed2: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/lb-linux-x86-64.so.2, BuildID[sha1]=4c4aeaa3691528f4774aedac52dac2e20e45c924, for GNU/Linux 3.2.0, not stripped
./13bit_fixed2
-bash: ./13bit_fixed2: cannot execute: required file not found
readelf -l 13bit_fixed2

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x10c0
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/lb-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000007c8 0x00000000000007c8  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000351 0x0000000000000351  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x0000000000000134 0x0000000000000134  R      0x1000
  LOAD           0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
                 0x0000000000003458 0x0000000000003488  RW     0x1000
  DYNAMIC        0x0000000000002de0 0x0000000000003de0 0x0000000000003de0
                 0x00000000000001e0 0x00000000000001e0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x0000000000000358 0x0000000000000358 0x0000000000000358
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_EH_FRAME   0x0000000000002008 0x0000000000002008 0x0000000000002008
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
                 0x0000000000000238 0x0000000000000238  R      0x1
    ...

Look! The starting at offset shows 64, and we have Program Headers!

Fixed Bits So Far: 4

Progress! We’re not seg faulting immediately, but now we get this new required file not found when we try to execute. Fortunately, we can see the file that is being looked for in the readelf output above in the line [Requesting program interpreter: /lib64/lb-linux-x86-64.so.2].

I did some research on the missing file, and found that it is a very important Dynamic Linker library. Definitely a problem to be missing, and this took longer for me to recognize the issue than I’d like to admit (due to a combination of dyslexia moments and due to what I choose to believe was some extra deviousness on the part of the challenge author), but eventually I realized that the Dynamic Linker file is named

/lib64/ld-linux-x86-64.so.2
        ^

but the 13bit program is looking for

/lib64/lb-linux-x86-64.so.2
        ^

The lb vs the intended ld was right in front of me the whole time. This is pretty obviously an error from a bit flip to me. Using the same methodology as before, let’s correct that. Here’s the xxd output showing the hex representation of the library:

xxd -s 0x318 -l 0x1c 13bit_fixed2
00000318: 2f6c 6962 3634 2f6c 622d 6c69 6e75 782d  /lib64/lb-linux-
00000328: 7838 362d 3634 2e73 6f2e 3200            x86-64.so.2.

The byte we want is in the fifth hex pair. 62 is the hex representation of the ASCII character b. We want to change that to 64 for d.

>>> bin(int('62',16))
'0b1100010'
>>> bin(int('64',16))
'0b1100100'

Nice! Another two bits to change:

with open("13bit_fixed2", "rb") as f:
    data = bytearray(f.read())
data[0x320] = 0x64  # 'b' (0x62) to 'd' (0x64)
with open("13bit_fixed3", "wb") as f:
    f.write(data)

Let’s look at what our file looks like now:

file 13bit_fixed3
13bit_fixed3: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=4c4aeaa3691528f4774aedac52dac2e20e45c924, for GNU/Linux 3.2.0, not stripped
./13bit_fixed3
Segmentation fault (core dumped)
readelf -l 13bit_fixed3

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x10c0
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000007c8 0x00000000000007c8  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000351 0x0000000000000351  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x0000000000000134 0x0000000000000134  R      0x1000
  LOAD           0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
                 0x0000000000003458 0x0000000000003488  RW     0x1000
  DYNAMIC        0x0000000000002de0 0x0000000000003de0 0x0000000000003de0
                 0x00000000000001e0 0x00000000000001e0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x0000000000000358 0x0000000000000358 0x0000000000000358
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_EH_FRAME   0x0000000000002008 0x0000000000002008 0x0000000000002008
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
                 0x0000000000000238 0x0000000000000238  R      0x1
    ...

Nice! Note that the program interpreter is now correctly displaying as /lib64/ld-linux-x86-64.so.2.

Fixed Bits So Far: 6

We’re back to seg faulting on running the program, so we know that there’s still some more work to do to get it to run. Let’s do an objdump on the most up-to-date file and see what we’re working with.

objdump -d 13bit_fixed3
    ...
Disassembly of section .text:

0000000000001090 <_start>:
    1090:       31 ed                   xor    %ebp,%ebp
    1092:       49 89 d1                mov    %rdx,%r9
    1095:       5e                      pop    %rsi
    1096:       48 89 e2                mov    %rsp,%rdx
    1099:       48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
    109d:       50                      push   %rax
    109e:       54                      push   %rsp
    109f:       45 31 c0                xor    %r8d,%r8d
    10a2:       31 c9                   xor    %ecx,%ecx
    10a4:       48 8d 3d 89 01 00 00    lea    0x189(%rip),%rdi        # 1234 <main>
    10ab:       ff 15 0f 2f 00 00       call   *0x2f0f(%rip)        # 3fc0 <__libc_start_main@GLIBC_2.34>
    10b1:       f4                      hlt
    10b2:       66 2e 0f 1f 84 00 00    cs nopw 0x0(%rax,%rax,1)
    10b9:       00 00 00
    10bc:       0f 1f 40 00             nopl   0x0(%rax)

00000000000010c0 <deregister_tm_clones>:
    10c0:       48 8d 3d 59 61 00 00    lea    0x6159(%rip),%rdi        # 7220 <stdout@GLIBC_2.2.5>
    10c7:       48 8d 05 52 61 00 00    lea    0x6152(%rip),%rax        # 7220 <stdout@GLIBC_2.2.5>
    10ce:       48 39 f8                cmp    %rdi,%rax
    10d1:       74 15                   je     10e8 <deregister_tm_clones+0x28>
    10d3:       48 8b 05 ee 2e 00 00    mov    0x2eee(%rip),%rax        # 3fc8 <_ITM_deregisterTMCloneTable@Base>
    10da:       48 85 c0                test   %rax,%rax
    10dd:       74 09                   je     10e8 <deregister_tm_clones+0x28>
    10df:       ff e0                   jmp    *%rax
    10e1:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)
    10e8:       c3                      ret
    10e9:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)
    ...

This command decompiles the executable and lets us peruse the code. Since the challenge is all about ELF header information, I felt that it was likely that the seg fault likely comes from basic information about the file rather than from a logic error within its code, so I started at the very beginning and walked myself down the assembly code. It didn’t take long to spot something off.

The readelf command from above tells us that the entry point (e_entry), where the program’s <start> function is, lies at address 10c0. Interestingly enough, navigating to that point in the disassembled code, this is not the <start> function, but is instead the <deregister_tm_clones> function. Fortunately, <start> isn’t too difficult to locate — it’s the function immediately preceeding it, at address 1090. I think this is the next bit flip to fix, let’s change the c0 to 90 and point the entry point to the actual <start> function.

>>> bin(int('c0',16))
'0b11000000'
>>> bin(int('90',16))
'0b10010000'

Here is the modification to my script to accomplish this:

with open("13bit_fixed3", "rb") as f:
    data = bytearray(f.read())
data[0x18] = 0x90  # Change C0 -> 90
data[0x19] = 0x10  # (Remains the same)
with open("13bit_fixed4", "wb") as f:
    f.write(data)

That’s 2 more bits!

Fixed Bits So Far: 8

You know the drill by now, let’s see where the program is at. I’ll start by running the newest…

working??

Wait. Did that just run? Hooray! It looks like it didn’t do anything though, so I opened it up in Ghidra to see what’s going on. Here is the decompiled main code.

undefined8 main(void)

{
  int __fd;
  undefined8 uVar1;
  ssize_t sVar2;
  int local_c;
  
  for (local_c = 0; local_c < 0x31ab; local_c = local_c + 1) {
    data[local_c] = data[local_c] ^ (byte)*(undefined4 *)(data2 + (long)(local_c % 4) * 4);
  }
  function(data,0x31ab);
  __fd = open("bad",0x241,0x1a4);
  if (__fd == -1) {
    perror("bad");
    uVar1 = 1;
  }
  else {
    sVar2 = write(__fd,data,0x31ab);
    if (sVar2 == -1) {
      perror("bad");
      close(__fd);
      uVar1 = 1;
    }
    else {
      close(__fd);
      uVar1 = 0;
    }
  }
  return uVar1;
}

On a low level, I can see that the function manipulates a large array (0x31ab values) with some XOR funtions, processes the data, and creates a file called bad and writes the data to it.

Looking back at the directory with my binaries, I see this bad file had successfully written, there, I had just missed it! Based on the code, I’m going to assume that the flag is built from the array and will be printed into this bad file. Once again, let’s check the new file:

file bad
bad: data
xxd bad
00000000: 8260 42ae 444e 4549 0000 0000 d2e1 d825  .`B.DNEI.......%
00000010: 1a57 3a4e 7ffd 555e c800 0000 0001 e008  .W:N..U^........
00000020: 0140 0000 0000 0f00 400a 0000 0000 0078  .@......@......x
00000030: 0200 5000 0000 0003 c010 0280 0000 0000  ..P.............

Well this one looks more corrupted than the original 13bit file. bad lives up to its name! I still have six more bits to flip, but the magic number alone would need more than that to fix this one, so I doubt that the challenge scope is going to have us interacting with bad like we have 13bit, so I turned my attention back to the latter.

There’s a single function in main, so I opened it up to see what it did:

************
* FUNCTION *
************
undefined function()
    undefined         <UNASSIGNED>   <RETURN>
    function          XREF[4]:     Entry Point(*), main:001012b1(c), 
                                                                                          00102034, 001020f8(*)  
        001011bc c3              RET
        001011bd 48              ??         48h    H
        001011be 89              ??         89h
        001011bf e5              ??         E5h
        001011c0 48              ??         48h    H
        001011c1 89              ??         89h
        001011c2 7d              ??         7Dh    }
        001011c3 e8              ??         E8h
    ...

Right away, I see that the first operation is a RET, but there are something like 100 more instructions after it. I’d wager that RET has been corrupted, but the question is, what from?

I tried setting it to NOP (no operation) with the value of 90 to see if I could skip over it, but that caused the program to seg fault again. Turning to the trusty ol’ interwebs, I found this Stack Overflow thread that mentions a standard C function prologue that seems familiar:

// Standard function prologue (setting up the stack frame):

  40052d:       55                      push   %rbp
  40052e:       48 89 e5                mov    %rsp,%rbp

the 48 89 e5 at the end of the first four hex digits in this function match what I have in mine. So I’ll change the RET function (c3) to be push %rbp (55). Here is the byte comparison:

>>> bin(int('c3',16))
'0b11000011'
>>> bin(int('55',16))
'0b01010101'

That’s four bits different, so if it’s successful, that’s 12 bits corrected! I made the change with the following modification to my script:

with open("13bit_fixed4", "rb") as f:
    data = bytearray(f.read())
data[0x11bc] = 0x55  # Change C3 -> 55
with open("13bit_fixed5", "wb") as f:
    f.write(data)

Running ./13bit_fixed5 now runs fully, and still outputs a new file called bad. But now, when I analyze the new file, I get this output:

file bad
bad: PNG image data, 1008 x 195, 8-bit/color RGBA, non-interlaced
xxd bad
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
00000010: 0000 03f0 0000 00c3 0806 0000 0025 59fa  .............%Y.
00000020: 9000 0000 0173 5247 4200 aece 1ce9 0000  .....sRGB.......
00000030: 0004 6741 4d41 0000 b18f 0bfc 6105 0000  ..gAMA......a...

To be honest, I figured bad was going to be a simple text document that just gave us the flag, but apparently it’s a PNG. That explains the large size of the array of data being processed in the main function, that was nagging at the back of my mind because that was much bigger than the simple text file I was expecting. I renamed the file to give it the proper extension for Windows (bad.png), then opened it up in the default photo app:

flag

Artwork to rival Da Vinci! It brought tears to my eyes.

By my count, I only fixed 8 bits plus 4 more bits (so 12 total) from this most recent edit… Maybe I skipped a step, maybe the last bit set up an OCR so I don’t have to hand type the flag out, or maybe I’m bad at counting, who knows? Either way, we took a corrupt ELF file and reverse engineered it by using our knowledge of ELF headers and assembly OP codes in order to fix the program, run it, and read the PNG file it produced. This challenge taught me about the fragility and precision of ELF structures, and perhaps caused a little retrospection on myself and my impact on the world around me. If just 13 seemingly insignificant bits can wreak havoc, what could a couple ordinary people do when united in a cause? Hopefully something better than just corrupt a silly challenge executeable…

Flag

vere{de4db3ef_d4d}

Zac Conlin © 2025