作者:TheSeven

0x00 引子

昨天頭老師扔給我一個php的加密擴展,讓我看看能不能解密,我從垃圾桶里拖出我14年版的ida打開簡單瞅了一眼,php擴展庫的加載流程不是特別熟悉,遂想放棄剛正面用一些雜項手段來搞,看了眼被加密的php文件,文件頭部是個標志,google了一發,發現某個解密站點在15年曾經支持過這種加密:

但是目前該站已經無法解密這個方法了。

然而神奇的是,盡管知道了這種方法叫做 AtStar/VoiceStar ,但是用了各種方法都沒在網上搜到其他任何關于這種加密的信息。。。。于是,再次老老實實打開ida,正面剛。

0x01 定位入口及整體流程

一般來說,php代碼加密有三種形式:

  • 第一種類似代碼混淆,這種方法可以不依賴擴展庫,例如phpjiami,一般這種加密需要將解碼程序也打包進代碼中,也就是我們說的殼,然后殼會被代碼混淆,原本的代碼會被加密,最終由殼進行解密后執行。這種不依賴擴展庫的加密方法有個非常簡單的破解方法,因為原代碼執行前一定會去調用底層的zend_compile_string函數,而且這種加密方法是可以直接運行的,所以我們在運行時把zend_compile_string hook住就可以得到源碼了。

  • 第二種是使用擴展庫,如果使用php擴展庫那么可以玩兒的加密手段就更多了,比如hook住zend_compile_*的一系列函數,在編譯流程上動手腳,并且一般我們在線上環境中(比如一個shell上)得到的so庫,可能在本地運行不起來,這個時候可能就比較難通過動態調試的方法拿到源碼。但是一般來說,只要我們拿到了這個加密庫最終輸出到zend虛擬機的數據,不管是源碼還是opcode,我們一般都能做到代碼還原,因為他最終逃不過zend engine(ze)。

  • 還有一種方法是第二種方法的子集,比如 Swoole Compile ,牛逼之處在于他部分脫離了zend虛擬機,對opcode做了混淆,這就比較像是vmp,所以破解難度就會變的很大。

但是比較幸運,這次遇到的 voicestar 是第二種方法里比較簡單的一種加密方式。

查了下php擴展開發的手冊,在載入擴展庫時,會首先調用get_module來獲得模塊接口,ida里看下發現返回了一個入口指針,但是我沒找到賦值在哪,然后我就把so里的所有function按照起始位置排序了下,然后就看到了get_module附近定義的幾個函數,特別是 zm_startup_php_voicezm_shutdown_php_voice 這倆函數從函數名上看上去比較像是入口:

查看zm_startup_php_voice

__int64 zm_startup_php_voice()
{
  *((_DWORD *)&compiler_globals + 135) |= 1u;
  lr = gl((char *)&ll);
  if ( lr )
    php_error_docref0(0LL, 2LL, "No License: %d");
  org_compile_file = (int (__fastcall *)(_QWORD, _QWORD))zend_compile_file;
  zend_compile_file = pcompile_file;
  return 0LL;
}

很明顯,其關鍵部分是把 zend_compile_file 放到 org_compile_file 這個指針中,然后用 pcompile_file hook 住 zend_compile_file

zm_shutdown_php_voice 主要就是把 zend_compile_file 還原。

__int64 zm_shutdown_php_voice()
{
  *((_DWORD *)&compiler_globals + 135) |= 1u;
  zend_compile_file = org_compile_file;
  return 0LL;
}

0x02 分析 & 解碼

zend_compile_file 是ze中負責將讀入的源代碼文件編譯為opcode然后執行的函數,那么解密過程應該就是在pcompile_file這個hook函數中了。

int __fastcall pcompile_file(__int64 a1, unsigned int a2)
{
  __int64 v2; // rbx@1
  unsigned int v3; // er13@1
  FILE *v4; // rax@3
  FILE *v5; // rbp@3
  bool v6; // zf@4
  __int64 v7; // rdi@4
  signed __int64 v8; // rcx@4
  char *v9; // rsi@4
  int v10; // er12@9
  int v11; // eax@10
  FILE *v12; // rax@12
  __int64 v13; // rdi@12
  __int64 v14; // rax@12
  int result; // eax@12
  __int64 v16; // rax@14
  const char *v17; // rax@15
  __int64 v18; // [sp+0h] [bp-58h]@1
  __int64 v19; // [sp+8h] [bp-50h]@1
  __int64 v20; // [sp+10h] [bp-48h]@1
  __int64 v21; // [sp+18h] [bp-40h]@1
  char ptr; // [sp+20h] [bp-38h]@4

  v2 = a1;
  v3 = a2;
  v18 = 0LL;
  v19 = 0LL;
  v20 = 0LL;
  v21 = 0LL;
  if ( (unsigned __int8)zend_is_executing() && (LODWORD(v16) = get_active_function_name(), v16) )
  {
    LODWORD(v17) = get_active_function_name();
    strncpy((char *)&v18, v17, 0x1EuLL);
    if ( !(_BYTE)v18 )
      goto LABEL_3;
  }
  else if ( !(_BYTE)v18 )
  {
    goto LABEL_3;
  }
  if ( !strcasecmp((const char *)&v18, "show_source") )
    return 0;
  if ( !strcasecmp((const char *)&v18, "highlight_file") )
    return 0;
LABEL_3:
  v4 = fopen(*(const char **)(a1 + 8), "r");
  v5 = v4;
  if ( !v4 )
    return org_compile_file(v2, v3);
  fread(&ptr, 8uLL, 1uLL, v4);
  v7 = (__int64)"\tATSTAR\t";
  v8 = 8LL;
  v9 = &ptr;
  do
  {
    if ( !v8 )
      break;
    v6 = *v9++ == *(_BYTE *)v7++;
    --v8;
  }
  while ( v6 );
  if ( !v6 )
  {
    fclose(v5);
    return org_compile_file(v2, v3);
  }
  if ( lr )
  {
    php_error_docref0(0LL, 2LL, "No License:");
    result = 0;
  }
  else
  {
    v10 = cle(&ll, v9);
    if ( v10 )
    {
      php_error_docref0(0LL, 2LL, "No License: %d");
      printf("No License:%d\n", (unsigned int)v10, v18);
      result = 0;
    }
    else
    {
      v11 = *(_DWORD *)v2;
      if ( *(_DWORD *)v2 == 2 )
      {
        fclose(*(FILE **)(v2 + 24));
        v11 = *(_DWORD *)v2;
      }
      if ( v11 == 1 )
        close(*(_DWORD *)(v2 + 24));
      v12 = ext_fopen(v5);
      v13 = *(_QWORD *)(v2 + 8);
      *(_QWORD *)(v2 + 24) = v12;
      *(_DWORD *)v2 = 2;
      LODWORD(v14) = expand_filepath(v13, 0LL);
      *(_QWORD *)(v2 + 16) = v14;
      result = org_compile_file(v2, v3);
    }
  }
  return result;
}

簡單看一下邏輯,首先是判斷 show_sourcehighlight_file 倆函數有沒有被禁用,然后判斷當前解析的文件有沒有”\tATSTAR\t” 這個文件頭,如果有,則進入到解密流程,解密流程前面一段都是在判斷有沒有授權,我們直接跳到83行之后的這個最后的分支上去。

zend_compile_file 定義如下:

extern ZEND_API zend_op_array *(*zend_compile_file)(zend_file_handle *file_handle, int type);

其第一個參數是zend_file_handle結構,其偏移量24的位置放的是打開的源碼文件指針,此分支代碼的主要邏輯就是關閉原來打開的代碼文件,然后使用ext_fopen函數重新打開該php代碼文件,然后替換掉 zend_file_handle結構體中原來的文件句柄。然后調用 zend_compile_file(hook之前的函數,現在為org_compile_file)。那么很顯然,當函數調用到zend_compile_file的時候,v2里裝的應該就是還原后的代碼了,那么還原操作應該是在ext_fopen函數內,跟進去看。

FILE *__fastcall ext_fopen(FILE *stream)
{
  int v1; // eax@1
  signed int v2; // ebx@1
  unsigned int v3; // er13@1
  void *v4; // rbp@1
  void *v5; // rcx@2
  __int64 v6; // rax@3
  const void *v7; // r12@4
  FILE *v8; // rbx@4
  __int64 v10; // [sp+0h] [bp-C8h]@1
  __int64 v11; // [sp+30h] [bp-98h]@1
  int v12; // [sp+9Ch] [bp-2Ch]@4

  v1 = fileno(stream);
  __fxstat(1, v1, (struct stat *)&v10);
  v2 = v11 - 8;
  v3 = v11 - 8;
  v4 = malloc((signed int)v11 - 8);
  fread(v4, v2, 1uLL, stream);
  fclose(stream);
  if ( v2 > 0 )
  {
    v5 = v4;
    do
    {
      v6 = (signed int)((((_BYTE)v2 + ((unsigned int)(v2 >> 31) >> 28)) & 0xF) - ((unsigned int)(v2 >> 31) >> 28));
      *(_BYTE *)v5 = ~(*(_BYTE *)v5 ^ (d[v6] + p[2 * v6] + 5));
      v5 = (char *)v5 + 1;
      --v2;
    }
    while ( v2 );
  }
  v7 = zdecode((__int64)v4, v3, (__int64)&v12);
  v8 = tmpfile();
  fwrite(v7, v12, 1uLL, v8);
  free(v4);
  free((void *)v7);
  rewind(v8);
  return v8;
}

該部分代碼的關鍵部分應該是在 22-33行,這段代碼明顯是在做一個對稱加密(易得,證略~~2333終于可以說這句話了),雖然沒看到密鑰,但是我們發現這里面dp這倆盒子是放在data段的:

然后這個對稱加(解)密做完之后,解密后的內容又進入到了 zdecode 這個函數中,跟進去看一眼, zdecode其實就是zcodecom又封裝了一層,直接看zcodecom

void *__fastcall zcodecom(int a1, __int64 a2, int a3, __int64 a4)
{
  int v4; // er13@1
  __int64 v5; // r12@1
  int v6; // er14@3
  void *v7; // r13@3
  int v8; // eax@4
  int v10; // ST08_4@21

  v4 = a3;
  v5 = a4;
  *((_QWORD *)&z + 8) = 0LL;
  *((_QWORD *)&z + 9) = 0LL;
  *((_QWORD *)&z + 10) = 0LL;
  z = 0LL;
  *((_DWORD *)&z + 2) = 0;
  if ( a1 )
    inflateInit_(&z, "1.2.8", 112LL);
  else
    deflateInit_(&z, 1LL, "1.2.8", 112LL);
  z = a2;
  *((_DWORD *)&z + 2) = v4;
  *((_DWORD *)&z + 8) = 100000;
  v6 = 0;
  *((_QWORD *)&z + 3) = &outbuf;
  v7 = malloc(0x186A0uLL);
  while ( 1 )
  {
    if ( a1 )
    {
      v8 = inflate(&z, 0LL);
      if ( v8 == 1 )
      {
LABEL_9:
        if ( 100000 == *((_DWORD *)&z + 8) )
        {
          if ( a1 )
            goto LABEL_11;
LABEL_16:
          deflateEnd(&z);
        }
        else
        {
          v10 = 100000 - *((_DWORD *)&z + 8);
          v7 = realloc(v7, v6 + 100000);
          memcpy((char *)v7 + v6, &outbuf, v10);
          v6 += v10;
          if ( !a1 )
            goto LABEL_16;
LABEL_11:
          inflateEnd((__int64)&z);
        }
        *(_DWORD *)v5 = v6;
        return v7;
      }
    }
    else
    {
      v8 = deflate(&z, 4LL);
      if ( v8 == 1 )
        goto LABEL_9;
    }
    if ( v8 )
      break;
    if ( !*((_DWORD *)&z + 8) )
    {
      v7 = realloc(v7, v6 + 100000);
      memcpy((char *)v7 + v6, &outbuf, 0x186A0uLL);
      *((_QWORD *)&z + 3) = &outbuf;
      *((_DWORD *)&z + 8) = 100000;
      v6 += 100000;
    }
  }
  if ( a1 )
    inflateEnd((__int64)&z);
  else
    deflateEnd(&z);
  *(_DWORD *)v5 = 0;
  return v7;
}

發現這個函數有點復雜,看了半天沒看懂在干啥,然后我就把代碼復制下來本地編譯了一發,但是這段代碼調了半晚上都沒跑起來,我想了一下,感覺問題應該是出在 zoutbuf 這倆全局變量上,這段代碼中對z上的偏移頻繁操作,z應該是某個比較復雜的結構體,并且inflateinflateEnd等等這幾個函數在編譯的時候必須link zlib,gcc 1.c -o a -lz -g3 ,我猜測 z 應該是zlib中某個結構體,我就去查了zlib的手冊,看個Example(為啥這個zlib的logo看上去這么像某lv文。。。。):

https://www.zlib.net/zlib_how.html

此處的z應該是 z_stream結構,這個過程應該就是使用zlib解壓。我按照zlib手冊中的結構和宏試圖復原zcodecom函數,但是每當執行到這句解壓代碼時v8 = inflate(&z, 0LL); 總會返回一個0xfffffffb,查手冊發現是Z_BUF_ERROR,沒轍,c語言太菜搞不定,只能想另外的方法。

此時我已經基本確定這里是一個解壓流程,盡管不知道有沒有啥另外的操作,索性把數據導出來用python解壓下。

此時我已經把從pcompile_file到解壓流程前的代碼都調通了,編譯好之后在zdecode前下個斷點,文件長度存在v3中,此處直接print出來,為0x4f7,查看下v4的地址,然后直接從該地址dump binary memory 0x4f7個字節:

file 瞅一眼:

root@penGun:/tmp# file aaa
aaa: zlib compressed data

感覺沒啥毛病,上python直接解:

Python 2.7.12 (default, Nov 19 2016, 06:48:10)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from zlib import *
>>> data = open('aaa','rb').read()
>>> data = decompress(data)
>>> file = open('ddd.php','wb')
>>> file.write(data)
>>> file.close()

搞定:

0x03 關于抄ida中c代碼

注意導入ida的定義頭文件,因為某些類型比較蛋疼,其實手動typedef也是可以的:

typedef unsigned long long __int64;
typedef unsigned long _DWORD;
typedef unsigned long long _QWORD;
typedef unsigned short _WORD;
typedef unsigned char _BYTE;
typedef int bool;

直接include plugins目錄下的defs.h就好啦。

還要注意某些結構體ida不能識別,所以可能f5出來的代碼存在這種形式:

__int64 v10; // [sp+0h] [bp-C8h]@1
__int64 v11; // [sp+30h] [bp-98h]@1
int v12; // [sp+9Ch] [bp-2Ch]@4

這幾個變量應該本在一個結構體中的,但是這樣看下面的代碼似乎他們沒經過賦值就使用了,此時可以重新寫一個結構體,也可以手動布局下堆棧,把變量放在指定的偏移上,其實這兩種方法原理完全一樣,就是操作起來可能形式不同。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/478/