In a previous post I've played a bit with my NAS and how we can hijack its dynamic libraries to bypass authentication locally. This allowed me to create a script that could retrieve information  just like the frontend would by simply calling the CGI binary. This got me thinking though: can we reverse engineer how it works behind the scenes?

A quick note here: I'm not a professional reverse engineer. Although I've done it in the past, it was a very long time ago, on a snowy night, drinking Club-Mate and eating pizza. So be aware that most of the stuff I'm doing here is based on simple guessing and having fun :)

Ok, let's start with something fun: fan speed.

Not this type of fan, although I've used it once or twice to help cooling in very hot days...

My NAS has a single fan and I'd like to know where does it get its current speed from, as it's shown in the web interface:

A quick look at the DevTools indicates it is reading this from the sysinfo.cgi, a very known friend:

As we know, sysinfo.cgi is a binary file provided by the OS which is called by the HTTP daemon. Simple CGI stuff. It also uses dynamic libraries for some stuff, so the lib folder is important as well. By copying everything onto a different machine and loading them all on Ghidra (first time using it, ironically), we can get a look at how it works (sort of). It has a bunch of functions, so simply looking up for "fan" gives us some interesting results:

Following up on the Hal_Fan_Get_Speed function, we get to the first library: libnhal.so. Interesting name, as HAL could stand here for Hardware Abstraction Layer. Ghidra nicely provides a decompiled C-ish code that we can use to help us understand what is going on. My assembly skills are very rusty, so this helps a lot. I've also rewritten a few of the types (probably messed them up as well) and variable names to help understanding what is going on:

int Hal_Fan_Get_Speed(int fan_id,int *fan_speed)

{
  int retval;
  int platform_identity;
  int fan_count;
  int local_20;
  int local_1c [3];
  
  platform_identity = 0;
  fan_count = 0;
  local_20 = 0x3c;
  retval = Hal_Get_Platform_Fan_Count(&fan_count);
  if (-1 < retval) {
    retval = -0x16;
    if ((-1 < fan_id) && (fan_id <= fan_count)) {
      retval = Hal_Get_Platform_Identity(&platform_identity);
      if (-1 < retval) {
        retval = Hal_Get_Nas_Model();
        if (retval < 0) {
          local_20 = 0x3c;
        }
        if (platform_identity == 1) {
          retval = It87_Get_Fan_Speed(fan_id,fan_speed);
          local_1c[0] = *fan_speed;
        }
        else {
          if (platform_identity == 2) {
            retval = Lm63_Get_Fan(fan_id,local_1c);
            *fan_speed = local_1c[0];
          }
          else {
            if (platform_identity - 0x15U < 7) {
              retval = It87_Get_Fan_Speed_Ex(fan_id,fan_speed,local_20);
              local_1c[0] = *fan_speed;
            }
            else {
              if (platform_identity - 3U < 2) {
                retval = Mcu_Get_Fan_Speed(fan_speed);
              }
              local_1c[0] = *fan_speed;
            }
          }
        }
        if (local_1c[0] < 0x14) {
          *fan_speed = 0;
        }
      }
    }
  }
  return retval;
}

Here we can clearly see that the function expects two arguments: a fan index and the pointer of the integer variable which will hold the fan speed if the function succeeds. Also, there's something called "platform identity" that tells how the function should get the fan speed, which indicates this library is shared between multiple NAS models. This makes sense, as I'm pretty sure Asustor is not interested in making everything custom for every single model.

Just for fun, I've wrote a simple C program that uses this function and passes 0 as fan index (my NAS has a single fan) and checked the result:

#include <stdlib.h>
#include <stdio.h>

extern int Hal_Fan_Get_Speed(int param_1,int *param_2);

void fan_speed() {
  int param1 = 0;
  int param2 = 0;
  int retval = Hal_Fan_Get_Speed(param1, &param2);
  printf("Hal_Fan_Get_Speed: %d %d %d\n", param1, param2, retval);
}

int main() {
  fan_speed();
  return 0;
}

Building it was a bit more challenging. You see, the NAS OS is Linux-based, but it does not have GCC or any other build tools on it. Although I've added Entware on it, there's not a compatible GCC that runs on this system. So I've ran a Docker container with Ubuntu on it, built everything there and then ran it on the main OS.

Also, building that simple C code required linking against a shit ton of libraries, as everything is dynamic. It makes sense, and simply linking against all libraries available solved the issue. Running it yields the current fan speed, nice!

root@vault:/volume1/.@root/ubuntu # ./test
Hal_Fan_Get_Speed: 0 525 0
root@vault:/volume1/.@root/ubuntu #

This proves that fan index 0 is correct and we're on the right track! But, as we show on the decompiled code, Hal_Fan_Get_Speed gets the speed based on which "plataform identity" we're running. So let's take a look at it (again, probably messed up the types!):

int Hal_Get_Platform_Identity(int *platform_identity)
{
  int retval;
  uint nas_pch;
  
  nas_pch = 0;
  retval = Probe_Nas_Pch((int *)&nas_pch);
  if (7 < nas_pch) {
    *platform_identity = 0xb;
    return -0x96;
  }
  switch(nas_pch) {
  case 0:
    *platform_identity = 1;
    return retval;
  case 1:
    *platform_identity = 0x15;
    return retval;
  case 2:
    *platform_identity = 0x16;
    return retval;
  case 3:
    *platform_identity = 0x17;
    return retval;
  case 4:
    *platform_identity = 0x18;
    return retval;
  case 5:
    *platform_identity = 0x19;
    return retval;
  case 6:
    *platform_identity = 0x1a;
    return retval;
  case 7:
    *platform_identity = 0x1b;
    return retval;
  }
}

Applying the same concept and calling the Hal_Get_Platform_Identity we get 23 as result, which is 0x17. Ok, so nas_pch must be 3, whatever that is. It seems to be probing the PCH (Platform Controller Hub) to figure out how the hardware is designed internally, so it can check different sensors if it needs to. We don't need to go further on Probe_Nas_Pch, as we don't really care how it works - we want to get the fan speed, afterall.

Another function called is Hal_Get_Nas_Model, which simply checks the /etc/nas.conf file to figure out which model it is running on. It looks for Model, and then return a constant from there: in our case, it's 31. So let's take a second look at the code now that we know a few of its variables:

// Assuming fan_id as 0.
int Hal_Fan_Get_Speed(int fan_id,int *fan_speed)
{
  int retval;
  int platform_identity;
  int fan_count;
  
  // This is most likely a buffer, or a decompiling issue.
  int local_20;
  int local_1c [3];
  
  // Set the variables up.
  platform_identity = 0;
  fan_count = 0;
  local_20 = 0x3c;
  
  // Get fan count: it'll be 1, and retval will be 0.
  retval = Hal_Get_Platform_Fan_Count(&fan_count);
    if (-1 < retval) {
    retval = -0x16;

    // Fan ID is 0 and fan count is 1.
    if ((-1 < fan_id) && (fan_id <= fan_count)) {

      // Get platform identity: this will be 0x17 (23), and retval 0.
      retval = Hal_Get_Platform_Identity(&platform_identity);
      if (-1 < retval) {
        // Get the NAS model. Weirdly enough, this function requires an
        // argument, and the decompiled code does not have it. Go figure.
        // This will return 0 though.
        retval = Hal_Get_Nas_Model();
        if (retval < 0) {
          local_20 = 0x3c;
        }

        // Platform identity is 31, so we'll ignore the flow we won't follow.
        if (platform_identity == 1) {
          (...)
        }
        else {
          if (platform_identity == 2) {
            (...)
          }
          else {
            // 23 - 21 < 7 is true
            if (platform_identity - 0x15U < 7) {
              retval = It87_Get_Fan_Speed_Ex(fan_id,fan_speed,local_20);
              local_1c[0] = *fan_speed;
            }
            else {
              (...)
            }
          }
        }

        // local_1c[0] will hold the fan speed (as per line 46), so if the
        // fan speed is lower than 20 (decimal), set it to zero. This is
        // probably to avoid misreadings if the fan is stopped.
        if (local_1c[0] < 0x14) {
          *fan_speed = 0;
        }
      }
    }
  }
  return retval;
}

Awesome, so now we know it calls It87_Get_Fan_Speed_Ex. The string "IT87" yields some results on Google for hardware sensors, so it makes sense. It also gave me this code, which is very interesting by itself. Unfortunately, playing with lm-sensors was unsuccessful, so we gotta dig further.

I also tried linking a C code to call it, but I had a hard time. It took me a few hours of trial and error and eventually I decided to try pointing to the mangled name of the function, which, for some reason, worked!

#include <stdlib.h>
#include <stdio.h>

extern void _Z21It87_Get_Fan_Speed_ExiPii(int, int*, int);

void fan_speed() {
  int param1 = 0;
  int param2 = 0;
  int param3 = 0;
  _Z21It87_Get_Fan_Speed_ExiPii(param1, &param2, param3);
  printf("It87_Get_Fan_Speed_Ex: %d %d %d\n", param1, param2, param3);
}

int main() {
  fan_speed();
  return 0;
}

The code above gave me the 0 527 0 output, which is correct: the fan was running at 527 RPM. Nice!

Let's take a look at what is called and how. First of all, its arguments: fan id, fan speed (pointer), and the local_20 variable, which will be 0x3c still. Now let's take a look at the dissasembled code:

/* It87_Get_Fan_Speed_Ex(int, int*, int) */
ulong It87_Get_Fan_Speed_Ex(int fan_id,int *fan_Speed,int something)
{
  uint uVar1;
  uint uVar2;
  int iVar3;
  ulong uVar4;
  long lVar5;
  undefined8 *puVar6;
  undefined8 auStack88 [6];
  
  uVar4 = 0xffffffea;
  if ((uint)fan_id < 3) {
    lVar5 = 6;
    puVar6 = auStack88;
    while (lVar5 != 0) {
      lVar5 = lVar5 + -1;
      *puVar6 = 0;
      puVar6 = puVar6 + 1;
    }
    uVar4 = Hw_Semaphore_Init(auStack88,"/HW_SEMAPHORE_IT87XX",1);
    if (-1 < (int)uVar4) {
      uVar4 = Hw_Semaphore_Lock(auStack88);
      if (-1 < (int)uVar4) {
        uVar1 = FUN_00103720();
        if (-1 < (int)uVar1) {
          if (fan_id == 2) {
            if ((something == 0x38) || (something == 0x3b)) {
              uVar2 = FUN_00103540(0xf);
              iVar3 = FUN_00103540(0x1a);
              uVar2 = iVar3 << 8 | uVar2;
            }
            else {
              uVar2 = FUN_00103540(0xc);
              FUN_00103630(0xc,uVar2 | 0x10);
              uVar2 = FUN_00103540(0x80);
            }
          }
          else {
            uVar2 = FUN_00103540(fan_id + 0xd);
            iVar3 = FUN_00103540(fan_id + 0x18);
            uVar2 = iVar3 << 8 | uVar2;
          }
          if ((short)uVar2 == 0) {
            *fan_Speed = 0;
          }
          else {
            *fan_Speed = (int)(0x149970 / (long)(int)(uVar2 * 2));
          }
        }
        uVar4 = Hw_Semaphore_Unlock(auStack88);
        if (-1 < (int)uVar4) {
          uVar2 = Hw_Semaphore_Release(auStack88);
          uVar4 = (ulong)uVar2;
          if (-1 < (int)uVar2) {
            uVar4 = (ulong)uVar1;
          }
        }
      }
    }
  }
  return uVar4;
}

Ok, this is a more complex one. It begins by initializing a hardware semaphore called HW_SEMAPHORE_IT87XX, and then locks it. That's fine. Since our fan index is 0, the first condition will fail and we'll go to the else block, which is way shorter:

// We'll fall into this one.
else {
  uVar2 = FUN_00103540(fan_id + 0xd);
  iVar3 = FUN_00103540(fan_id + 0x18);
  uVar2 = iVar3 << 8 | uVar2;
}

The two calls use the same function, FUN_00103540, but pass different values as arguments: 0xD (13 decimal) and 0x18 (24 decimal). These values are unchanged as the fan_id is 0. At the end, it shifts the second one 8 bits to the left and applies a bitwise OR with the first one. So basically what happens is this:

// Assuming random values for the example.
uVar2 = 01001001
iVar3 = 01110010

result = 01110010 00000000 (left shift)
         01110010 01001001 (0x0 OR uVar2)

I believe this happens as it probably reads two registers, one for the most significant bits (iVar3), an another one for the least significant bits (uVar2). Makes sense, as this is not unusual when dealing with hardware on software. Sometimes you need to adapt the data you have on hardware to the formats on software. Cool! Let's take a look at the FUN_00103540 function then:

undefined FUN_00103540(undefined param_1)
{
  undefined uVar1;
  
  if (DAT_003079a4 != 0) {
    FUN_00103450();
  }
  ioperm((long)(DAT_003079a8 + 5),1,1);
  out((short)DAT_003079a8 + 5,param_1);
  ioperm((long)(DAT_003079a8 + 5),1,0);
  ioperm(0xeb,1,1);
  out(0xeb,0);
  ioperm(0xeb,1,0);
  ioperm((long)(DAT_003079a8 + 6),1,1);
  uVar1 = in((short)DAT_003079a8 + 6);
  ioperm((long)(DAT_003079a8 + 6),1,0);
  ioperm(0xeb,1,1);
  out(0xeb,0);
  ioperm(0xeb,1,0);
  return uVar1;
}

Ok, this is a messy one. I got stuck on this for many hours and still didn't really understand how it works, so I decided to fire up my previous sample code on GDB. This would allow me to go step by step on this and figure out exactly what is going on. Also, this was quite fun as it has been many years since I've debugged stuff on GDB!

Fun fact: if you're gonna debug a code that sets up a semaphore, make sure it releases it at the end. I'm rebooting the NAS now because I aborted the execution on GDB. (note: this happened twice 🤦‍♂)

Before we continue, let's make sure we patch the code that initializes and locks the semaphore out of the code. This is normally a very bad idea, but we gotta do this if we plan on crashing the program a lot. Let's not care about the dangers of it right now, though. We do this by replacing their bytes with NOP instructions (0x90 on Intel). This way a new execution of the test program won't if something goes wrong. Since this code is actually on a library, the easiest way of doing so is to use a modified version of the library itself, and point LD_LIBRARY_PATH to it. So, here's the diff, replacing the proper CALL instructions for NOPs:

# diff --suppress-common-lines -y original.dasm custom.dasm
/mnt/lib/libgeneraldrv.so:     file format elf64-x86-64       | ./libgeneraldrv.so:     file format elf64-x86-64
    388f:       e8 bc dd ff ff          callq  1650 <Hw_Semap |     388f:       90                      nop
                                                              >     3890:       90                      nop
                                                              >     3891:       90                      nop
                                                              >     3892:       90                      nop
                                                              >     3893:       90                      nop
    389b:       e8 d0 df ff ff          callq  1870 <Hw_Semap |     389b:       90                      nop
                                                              >     389c:       90                      nop
                                                              >     389d:       90                      nop
                                                              >     389e:       90                      nop
                                                              >     389f:       90                      nop
    38ed:       e8 ee dd ff ff          callq  16e0 <Hw_Semap |     38ed:       90                      nop
                                                              >     38ee:       90                      nop
                                                              >     38ef:       90                      nop
                                                              >     38f0:       90                      nop
                                                              >     38f1:       90                      nop
    38f9:       e8 92 dd ff ff          callq  1690 <Hw_Semap |     38f9:       90                      nop
                                                              >     38fa:       90                      nop
                                                              >     38fb:       90                      nop
                                                              >     38fc:       90                      nop
                                                              >     38fd:       90                      nop

A LD_LIBRARY_PATH=. before calling the test program later confirmed it still worked after simply removing those instructions, so we can simply use set env LD_LIBRARY_PATH=. on GDB to make sure we load the correct one there as well. Printing the disassembled code once we reach near that point confirmed the modified library has been loaded:

(gdb) x/30i $pc
=> 0x7ffff7bd7840 <_Z21It87_Get_Fan_Speed_ExiPii>:      mov    %rbp,-0x20(%rsp)
   0x7ffff7bd7845 <_Z21It87_Get_Fan_Speed_ExiPii+5>:    mov    %r12,-0x18(%rsp)
   0x7ffff7bd784a <_Z21It87_Get_Fan_Speed_ExiPii+10>:   mov    %edi,%ebp
   0x7ffff7bd784c <_Z21It87_Get_Fan_Speed_ExiPii+12>:   mov    %r13,-0x10(%rsp)
   0x7ffff7bd7851 <_Z21It87_Get_Fan_Speed_ExiPii+17>:   mov    %rbx,-0x28(%rsp)
   0x7ffff7bd7856 <_Z21It87_Get_Fan_Speed_ExiPii+22>:   mov    %rsi,%r12
   0x7ffff7bd7859 <_Z21It87_Get_Fan_Speed_ExiPii+25>:   mov    %r14,-0x8(%rsp)
   0x7ffff7bd785e <_Z21It87_Get_Fan_Speed_ExiPii+30>:   sub    $0x58,%rsp
   0x7ffff7bd7862 <_Z21It87_Get_Fan_Speed_ExiPii+34>:   cmp    $0x2,%edi
   0x7ffff7bd7865 <_Z21It87_Get_Fan_Speed_ExiPii+37>:   mov    %edx,%r13d
   0x7ffff7bd7868 <_Z21It87_Get_Fan_Speed_ExiPii+40>:   mov    $0xffffffea,%eax
   0x7ffff7bd786d <_Z21It87_Get_Fan_Speed_ExiPii+45>:   ja     0x7ffff7bd7904 <_Z21It87_Get_Fan_Speed_ExiPii+196>
   0x7ffff7bd7873 <_Z21It87_Get_Fan_Speed_ExiPii+51>:   xor    %eax,%eax
   0x7ffff7bd7875 <_Z21It87_Get_Fan_Speed_ExiPii+53>:   mov    $0x6,%ecx
   0x7ffff7bd787a <_Z21It87_Get_Fan_Speed_ExiPii+58>:   mov    %rsp,%rdi
   0x7ffff7bd787d <_Z21It87_Get_Fan_Speed_ExiPii+61>:   rep stos %rax,%es:(%rdi)
   0x7ffff7bd7880 <_Z21It87_Get_Fan_Speed_ExiPii+64>:   lea    0x2d8b(%rip),%rsi        # 0x7ffff7bda612
   0x7ffff7bd7887 <_Z21It87_Get_Fan_Speed_ExiPii+71>:   mov    $0x1,%edx
   0x7ffff7bd788c <_Z21It87_Get_Fan_Speed_ExiPii+76>:   mov    %rsp,%rdi
   0x7ffff7bd788f <_Z21It87_Get_Fan_Speed_ExiPii+79>:   nop
   0x7ffff7bd7890 <_Z21It87_Get_Fan_Speed_ExiPii+80>:   nop
   0x7ffff7bd7891 <_Z21It87_Get_Fan_Speed_ExiPii+81>:   nop
   0x7ffff7bd7892 <_Z21It87_Get_Fan_Speed_ExiPii+82>:   nop
   0x7ffff7bd7893 <_Z21It87_Get_Fan_Speed_ExiPii+83>:   nop
   0x7ffff7bd7894 <_Z21It87_Get_Fan_Speed_ExiPii+84>:   test   %eax,%eax
   0x7ffff7bd7896 <_Z21It87_Get_Fan_Speed_ExiPii+86>:   js     0x7ffff7bd7904 <_Z21It87_Get_Fan_Speed_ExiPii+196>
   0x7ffff7bd7898 <_Z21It87_Get_Fan_Speed_ExiPii+88>:   mov    %rsp,%rdi
   0x7ffff7bd789b <_Z21It87_Get_Fan_Speed_ExiPii+91>:   nop
   0x7ffff7bd789c <_Z21It87_Get_Fan_Speed_ExiPii+92>:   nop
   0x7ffff7bd789d <_Z21It87_Get_Fan_Speed_ExiPii+93>:   nop

We can then follow the code execution through GDB and Ghidra at the same time to figure out exactly what is going on. By doing so, I've come to realization that this is way deeper than I actually wanted. You see, the code will now do many I/O operations, most of them on address 0x290. A quick Google search revealed this address being used by sensors on Intel-based platforms (even more if you add "it87" to the search), which matches what we have here: a sensor!

However, this is where we stop - for now at least. You see, there's a very high chance that such I/O operations would be annoying/complex to reproduce on a high-level programming language. Imagine doing port enabling-disabling and "in"s and "out"s on Python, for example: what a mess. So we gotta stick with C on this one, which obviously requires a proper C code for doing such operations. Also, this explains why there are semaphores in place: there's a very high chance that these I/O operations could interfer with each other if ran in parallel, so the semaphore stops them from doing that. It makes perfect sense, at least from a backend and hardware perspective.

On the next few hours/days I'll try and take a look at how can I have fun with these calls. There's a very high chance I won't go into details of such I/O operations, mostly due to being a pain in the ass to figure out what each address is doing. As far as I understand, you basically have to go through the PCH manual to figure out the I/O port mapping and then find out exactly what each address does. But, based on my experience with hardware and sensors, it's very likely these are start/stop/get measurement calls - basically telling the sensor to do its job. Or maybe not, who knows! Nevertheless, this doesn't really matter much as we can now easily call these functions directly, including a very interesting one that is used for adjusting the fan speed.

This might get very interesting!