BSidesSF CTF 2021 Author writeup: secure-asset-manager, a reversing challenge similar to Battle.net bot dev

Hi Everybody!

This is going to be a challenge-author writeup for the Secure Asset Manager challenge from BSides San Francisco 2021.

It's designed to be a sort of "server management software". I sort of chose that theme to play off the Solarwinds thing, the theme wasn't super linked to the challenge.

The challenge was to analyze and reverse engineer a piece of client-side software that "checks in" with a server. For the check-in, the client is required to "validate" itself. The server sends a random "challenge" - which is actually a block of randomized x86 code - and that code used to checksum active memory to prevent tampering. If anybody reading this worked on bots for the original Starcraft (and other Battle.net games), this might seem familiar! It's based on Battle.net's CheckRevision code.

Server

The players don't normally get to see it, but this is my server code. I'd like to draw your attention to assembly-generator.rb in particular, which is what creates the challenge. It just does a whole bunch of random, and really bad checksumming with a few instructions and also randomized NOPs:

  0.upto(rand(1..5)) do
    0.upto(rand(2..5)) do
      # Do something to the value a few times
      s.push([
        "xor eax, #{ random_int }",
        "add eax, #{ random_int }",
        "sub eax, #{ random_int }",
        "ror eax, #{ rand(1..30) }",
        "rol eax, #{ rand(1..30) }",
      ].sample)
    end

    # Mix in the previous value (or seed)
    s.push("xor ecx, eax")
    s.push('')
    s.push(nop())
  end

The server dumps all those random instructions into a file, assembles it with nasm, and sends over the resulting code.

To generate a checksum on the server side, I actually used what I'd consider a solution: dumping client memory.

First solution: dump memory

To validate the client, the server wraps gdb (the GNU Debugger) and sends commands to dump process memory. Here's the code:

def dump_binary(binary, target)
  L.info("Dumping memory from #{ binary } using gdb...")

  begin
    Timeout.timeout(3) do
      Open3.popen2("gdb -q #{ binary }") do |i, o, t|
        # Don't confirm things
        i.puts("set no-confirm")

        # Breakpoint @ malloc - we just need to stop anywhere
        i.puts("break malloc")

        # Run the executable
        i.puts("run")

        # Remove the breakpoint - this is VERY important, the breakpoint will mess
        # up the memory dump!
        i.puts("delete")

        # Get the pid
        i.puts("print (int) getpid()")

        loop do
          out = o.gets().strip()
          puts(out)
          if out =~ /\$1 = ([0-9]+)/
            L.info("Found PID: #{ $1 }")
            L.info("Reading /proc/#{ $1 }/maps to find memory block")
            mappings = File.read("/proc/#{ $1 }/maps").split(/\n/)

            mappings.each do |m|
              if m =~ /([0-9a-f]+)-([0-9a-f]+) (r-xp).*\/secure-asset-manager$/
                L.debug("Found memory block: #{ m }")
                i.puts("dump memory #{ target } 0x#{ $1 } 0x#{ $2 }")
                i.puts("quit")

                loop do
                  out = o.gets()
                  if !out
                    break
                  end
                  puts(out.strip())
                end

                L.info("Memory from original binary dumped to #{ target }")
                return
              end
            end
          end
        end
      end
    end
  rescue Timeout::Error
    L.fatal("Something went wrong dumping the binary! Check the gdb output above")
    exit(1)
  end
end

Then I used a secondary script, which I called not-solution.c, to actually execute the code. But instead of performing the checksum on memory, it performs it on the dumped binary. It even uses the same checksum function from the real client:

uint32_t checksum(data_block_t *code, data_block_t *binary) {
  // Allocate +rwx memory
  uint32_t (*rwx)(uint8_t*, uint8_t*) = mmap(0, code->length, PROT_EXEC | PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0);

  // Populate it with the code
  memcpy(rwx, code->data, code->length);

  // Run the code
  uint32_t result = rwx(binary->data, binary->data + binary->length);

  // Wipe and unmap the memory
  memset(rwx, 0, code->length);
  munmap(rwx, code->length);

  return result;
}

That server-side code is literally what my solution does, and I even use the same not-solution.c script to do it.

In retrospect, I need to stop using gdb in containers. It only causes headaches for our infrastructure guy, David. :)

Alternative solution: proxy

The first time I ever made a Starcraft bot, I used the real game client to connect, redirected it through a proxy, and once the connection was established, I'd disconnect the game and keep the bot going. It was crazy inefficient and a big pain to reconnect, but it worked and I was super proud of it!

I'm happy that at least one team solved it this way.

I actually don't have anything written that implements this, but I'd love to see a writeup where somebody did! I have no idea if there's any tooling out there that can make this easy.

Accidental solution: edit the binary

So it turns out, I only checksummed the code portion. You could freely change the data section of the binary (say, change the check-in command to the flag command) and everything Just Works. D'oh!

Conclusion

For years I've wanted to do a challenge like this. I'd actually like to repackage this and use a similar concept again, only a bit harder and with less opportunity to bypass it. Stay tuned for 2022!

Leave a Reply

Your email address will not be published.