Decompiling AMX Plugins, Part 3

Last time, we saw how to decompile a simple native back to source code. Let’s finish up the first procedure we were working on, then move on.

The next mini-block of disassembly in the plugin_init function:

 0x54        PUSH.C                  0x0
 0x5C        PUSH.C                  0x0
 0x64        PUSH.C                 0x78
 0x6C        PUSH.C                 0x4C
 0x74        PUSH.C                 0x10
 0x7C        SYSREQ.C      register_cvar

Looking at the native prototype:

register_cvar(const name[],const string[],flags = 0,Float:fvalue = 0.0);

The two optional parameters are always pushed – they’re not excluded even though they’re optional. Since it pushed two zeroes, we can guess that the author used the defaults. Checking the DAT section for those offsets:

So, plugin_init looks like:

public plugin_init()
{
    register_plugin("Hello", "1.0", "BAILOPAN")
    register_cvar("pl_enabled", "1")
}

The rest is the epilogue:

 0x8C        ZERO.pri  
 0x90        RETN

Since the PRI register handles return values, this means that the function is returning 0.

Now, let’s take the next public function:

 0x94        PROC                        ; client_authorized
 0xA4        PUSH.C                 0x80
 0xAC        PUSH.C                  0x4
 0xB4        SYSREQ.C       get_cvar_num

In our string table, 0×80 is “pl_enabled”. But wait – this string already occurred earlier! It’s clear that Small 2.7.3 does not optimize memory usage, and it simply repeats strings in memory rather than combining them. The result of get_cvar_num, like all procedure calls, will be stored in PRI. Next:

 0xC4        NOT       
 0xC8        JZER              jump_0000
 0xDC        ZERO.pri 

The compiler is inverting the boolean value of PRI (PRI = !PRI), then testing if it’s zero (Jump on ZERo). If the value is zero, it will branch off into another direction. Otherwise, it will continue. NOT+JZER exists as one instruction, JNZ — more evidence that the compiler only does trivial optimization. So the code should be read as ‘Jump if the return value of get_cvar_num() is not zero’. The branch then looks like this:

public client_authorized(id)
{
   if (get_cvar_num("pl_enabled") != 0)
   {
       //Conditional code
   }
   //branch location
}

From the disassembler’s lower right pane, we can see that jump_0000 is location 0xE4. Up to that, we have:

 0xDC        ZERO.pri  
 0xE0        RETN      

Therefore, this procedure probably says:

public client_authorized(id)
{
   if (!get_cvar_num("pl_enabled"))
      return PLUGIN_CONTINUE
   //branched code
}

Now, let’s finish this function off by looking at the branched code.

 0xF0        PUSH.C                  0x0
 0xF8        CONST.pri              0xD0
 0x100       HEAP                    0x4
 0x108       MOVS                    0x4
 0x110       PUSH.alt  
 0x114       PUSH.C                  0x0
 0x11C       CONST.pri              0xCC
 0x124       HEAP                    0x4
 0x12C       MOVS                    0x4
 0x134       PUSH.alt  
 0x138       PUSH.S                  0xC
 0x140       PUSH.C                 0xAC
 0x148       PUSH.C           0x41200000
 0x150       PUSH.C                 0x1C
 0x158       SYSREQ.C           set_task
//set_task reference:
set_task(Float:time,const function[],id = 0,parameter[]="",len = 0,flags[]="", repeat = 0)

The first push is 0. That takes care of repeat.

Next, we have 0xD0 being stored in PRI. The disassembler seems confused at this — it has a mysterious ‘Array[2]‘ entry in DAT, which starts at 0xCC. therefore, 0xD0 is Array[1]. The array is ‘empty’. Next, the HEAP 4 instruction is allocating four bytes on the heap and storing the address in ALT. MOVS 4 is copying those four bytes from PRI (the DAT address) into ALT (the HEA address). The address of ALT is then pushed, which can only be the flags parameter. What just happened?

The array that the disassembler couldn’t figure out was the compiler storing two strings of zero length (empty strings). The default parameter to flags is empty, but we can’t pass in a DAT offset to something non-const, as it could be modified. So the compiler is allocating temporary memory in the heap, copying the string, and passing the address. At the end of the function, the heap will be restored. This trick is used another time, and we can eliminate parameter and len, until this appears:

0x138       PUSH.S                  0xC

This is the first FRM offset instruction we’ve seen. The FRM is relative to each procedure, so each procedure has a baseline for where its local stack starts and the parent’s stack ends. CALL+PROC add two entries onto the stack, and the final PUSH.C which declares the parameter width is a third entry. Therefore, parameters always start at the 12th bytes (0xC) for local calls. This instruction is pushing the first parameter, which for

client_authorized
, is ‘id’. To try to make this clearer, I made a diagram:

Next is simple – look up the offset 0xAC for string “display”. The next number is Float:time – but it’s disassembled as 0×41200000! Floats are stored in a format called IEEE Single/Double Precision. To decode it, I’ve used a simple C program:

#include <stdio .h>

typedef union
{
        int num;
        float f;
} floatswp;

int main()
{
        floatswp swp;
        scanf("%x", &swp);
        printf("%f\n", swp.f);
}

The output is:

[email protected] ~ $ cc float.c -ofloat
[email protected] ~ $ ./float
<i>0x41200000</i>
10.000000

So, we finally have all the parameters, and most are defaults. Our function probably looks like this:

public client_authorized(id)
{
   if (!get_cvar_num("pl_enabled"))
      return
   set_task(10.0, "display", id)
   return
}

As you can probably see by now, decompiling is a very time consuming task, especially step by step — this article was quite long. Tomorrow we’ll finish up this plugin. After that, we’ll take a quick look at an actual closed source plugin currently in use, and then finish the series off with a 64bit discussion.

3 Responses to “Decompiling AMX Plugins, Part 3”

  1. Freecode says:

    This is a great tutorial. Now only if there was a real decompiler without going through this time consuming tutorial.

  2. BAILOPAN says:

    Decompiling is an “undecidable” problem, meaning, it’s not computable. You can only guess what the original source code was, and with optimizing compilers, it’s extremely difficult. However, you can get an excellent idea of how things work which is what’s important. Writing decompilers is a black art and not something I want to attempt right now ;]

  3. Wraith says:

    To get back to the original code from only the opcodes in the file the decompiler would have to be able to recognise every point which may be the result of an optimization or elimination and correct expand. It would have to be capable of mapping every decision pathway in the compiler backwards. Even then the human usable context information like source formatting and variable names are lost. Its probably possible to do but the time and effort you would expend doing so are far in excess of any benefit you would feel.

    More pointledly getting decompiling or even disassembly right is a non-trivial problem at best i find. Its easy to output an instruction stream you can look at but lacking any real knowledge of what you’re doing it quickly overwhelms you. The small amount of context the Winforms or SCAsm views provide is enough to let you see chunks of info with a few hints about what references might mean. It never claims to be right (because there is always a reasonable chance it isn’t) but its a help when you’ve a low level problem you just can’t get at from a source code perspective.

Leave a Reply

You must be logged in to post a comment.