CTF中32位程序调用64位代码的逆向方法

栏目: 编程语言 · 发布时间: 6年前

内容简介:在CTF中,逆向的玩法越来越多变,曾经出现过32位程序调用64位代码的情况,一般的静态分析和动态调试方法都会失效,让人十分头大,今天将通过2个案例来学习如何应对这种情况。2个案例包括1个windows程序和1个linux ELF程序,正好覆盖了2个常见的平台,

CTF中32位程序调用64位代码的逆向方法

背景

在CTF中,逆向的玩法越来越多变,曾经出现过32位程序调用64位代码的情况,一般的静态分析和动态调试方法都会失效,让人十分头大,今天将通过2个案例来学习如何应对这种情况。

案例

2个案例包括1个windows程序和1个linux ELF程序,正好覆盖了2个常见的平台, 下载地址 (提取码:nxwx)

  1. father and son (ELF),来源于2018年护网杯CTF
  2. GWoC (Windows),来源于2018年CNCERT CTF

基础知识

在x64系统下的进程是有32位和64位两种工作模式,这两种工作模式的区别在于CS寄存器。32位模式时,CS = 0x23;64位模式时,CS = 0x33。;

这两种工作模式是可以进行切换的,一般会通过retf指令,一条retf指令等效于以下2条汇编指令

pop ip
pop cs

如果此时栈中有0x33,则会将0x33弹出到CS寄存器中,实现32位程序切换到64位代码的过程。 所以retf是识别32位程序调用64位代码的重要标志。

案例1:father and son

二进制文件father来自于一个流量包的内容(非本文焦点),是一个32位的ELF程序

$ file father
father: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=4351dc8fde1bd3404207e1540b84e3c577c81521, stripped

程序分析

核心代码如下

int sub_8048527()
{
  signed int retaddr; // [esp+2Ch] [ebp+4h]
  signed int retaddr_4; // [esp+30h] [ebp+8h]

  if ( mmap((void *)0x1337000, 0x3000u, 7, 50, 0, 0) != (void *)0x1337000 )
  {
    puts("sorry");
    exit(0);
  }
  if ( mmap((void *)0xDEAD000, 0x3000u, 7, 50, 0, 0) != (void *)0xDEAD000 )
  {
    puts("sorry");
    exit(0);
  }
  memcpy((void *)0xDEAD000, &unk_804A060, 0x834u);
  sub_80484EB(0xDEAD000, 0x834, 0x33);  // sub_80484EB(内容,长度,异或的值)
  retaddr = 0xDEAD000;
  retaddr_4 = 0x33;
  return MEMORY[0xDF7D000]();
}

用nmap开辟了两段RWX内存,并且将0x804A060的内容拷贝到其中一块RWX内存0xDEAD000处,并用sub_80484EB函数异或恢复代码。

最后的部分IDA没有识别出来,看汇编是用retf跳转到0xDEAD000处执行。

.text:08048629 C7 00 00 D0 EA 0D                       mov     dword ptr [eax], 0DEAD000h
.text:0804862F 8D 45 E4                                lea     eax, [ebp+var_1C]
.text:08048632 83 C0 24                                add     eax, 24h
.text:08048635 89 45 E0                                mov     [ebp+var_20], eax
.text:08048638 8B 45 E0                                mov     eax, [ebp+var_20]
.text:0804863B C7 00 33 00 00 00                       mov     dword ptr [eax], 33h
.text:08048641 C9                                      leave
.text:08048642 CB                                      retf

看到retf,又开到此时栈中有0x33,符合32位程序调用64位代码的模式。

执行分析

使用一般的逆向工具gdb,在0x08048642处设置断点

gdb ./father
pwndbg> b *0x08048642
Breakpoint 1 at 0x8048642
pwndbg> show architecture 
The target architecture is set automatically (currently i386)
pwndbg> r

断点触发后,用ni单步执行指令执行下一步,可以看到指令已经跳转到0xDEAD000空间,CS寄存器的值从0x23变为0x33,进入64位代码的空间。

CTF中32位程序调用64位代码的逆向方法

然而此时代码内容无法显示64位汇编

CTF中32位程序调用64位代码的逆向方法

此时继续用ni单步执行指令,就会看到汇编指令没有一条条执行,而是几步一跳的执行,这是因为gdb认为这段代码是32位而不是64位的,即使使用set architecture i386:x86-64 命令,也会提示错误。

CTF中32位程序调用64位代码的逆向方法

我也尝试过以下调试方法,均已失败告终。

  1. IDA+linux_server(IDA32位版本)进行调试,效果同gdb,无法识别64位汇编代码,可以单步执行,汇编指令也是几步一跳。
  2. IDA64+linux_server64(IDA64位版本),程序无法引导起来。

那么应该如何动态调试呢?

动态调试

为了可以正确执行64位指令,可以采用gdbserver+IDA64的调试方式。

gdbserver启动程序,并绑定到1234端口(冒号前不带ip使用本机ip)

gdbserver :1234 ./father

用IDA64打开程序,此时是无法使用F5查看伪代码的,但是可以看到IDA64识别了32位的程序,汇编能够正常显示。

CTF中32位程序调用64位代码的逆向方法

在0x8048642的retf处设置断点,设置好连接gdbserver的参数(如图)

CTF中32位程序调用64位代码的逆向方法

点击绿色三角形按钮启动调试,一次F9运行后,到达断点处。

CTF中32位程序调用64位代码的逆向方法

再按F7进入64位代码,此时EIP显示已经进入了0xDEAD000,但是汇编窗口没有提示。即使使用G跳转到地址0xDEAD000也提示出错。

这是因为IDA和gdbserver连接时,内存并没有及时刷新导致。可以打开Debugger菜单中的Manual memory regions菜单项,右键Insert新建一个内存区域(这个动作每启动一次调试都要重新做)。

CTF中32位程序调用64位代码的逆向方法

内存区域设置起始地址为0xDEAD000,结束地址默认即可,注意选择64-bit segment。

CTF中32位程序调用64位代码的逆向方法

然后用G指令跳转到内存0xDEAD000,此时显示的是二进制数据。

CTF中32位程序调用64位代码的逆向方法

按一下C识别为汇编指令,IDA调试器可以正确识别64位汇编,按F8单步执行也不会出现几步一跳的情况,可以正常调试啦。

CTF中32位程序调用64位代码的逆向方法

注意1:gdbserver在一次调试结束后,第二次可能连接不上,需要kill掉再启动。

注意2:有的ELF程序可能并不需要Manual memory regions中增加内存区域,可以通过IDA的Edit->Segments->Change Segment Attributes修改内存为64位代码

CTF中32位程序调用64位代码的逆向方法

静态分析

有了动态调试方法,还需要静态分析方法的配合,提高CTF中逆向的效率。

本案例采用了异或混淆,由于混淆不复杂,可以静态Dump出来异或恢复,也可以动态时再Dump出来。本文采用动态运行到retf指令时,利用脚本Dump出内存。

static main(void)
{
  auto fp, begin, end, dexbyte;
  fp = fopen("C:\father64.mem", "wb");
  begin = 0xDEAD000;
  end = 0xDEB0000;
  for ( dexbyte = begin; dexbyte < end; dexbyte ++ )
      fputc(Byte(dexbyte), fp);
}

在File菜单的Script Command菜单项中,选择IDC脚本,输入上述内容,点击Run按钮后就可以将0xDEAD000至0xDEB0000的内存导出到C盘的father64.mem文件

CTF中32位程序调用64位代码的逆向方法

将father64.mem拖入IDA64进行静态分析,因为缺少ELF头,IDA64会提问选择哪种格式,此处选择64-bit mode分析代码。

CTF中32位程序调用64位代码的逆向方法

此时代码基地址是0x0,可以用Edit->Segments->Rebase Segment重定义基地址,设置为0xDEAD000,这样动态调试时和静态调试时的汇编地址就一样了。

CTF中32位程序调用64位代码的逆向方法

然后可以愉快的用F5生成 C语言 代码了。

CTF中32位程序调用64位代码的逆向方法

逆向破解

由于本文侧重点在于如何识别和分析32位程序调用64位代码,因此案例的算法逆向篇幅部分会比较简略,有兴趣的朋友可以自行研究。

主流程sub_DEAD44B接收用户输入和输出结果,并且判断输入格式是否为hwbctf{…}。

__int64 __fastcall sub_DEAD44B(__int64 a1)
{
  int v1; // eax
  int v2; // eax
  char v4; // [rsp+0h] [rbp-70h]
  char v5; // [rsp+10h] [rbp-60h]
  char v6[16]; // [rsp+20h] [rbp-50h]
  char v7[19]; // [rsp+30h] [rbp-40h]
  char v8[13]; // [rsp+50h] [rbp-20h]
  int v9; // [rsp+6Ch] [rbp-4h]

  v8[0] = 123;
...
  v8[12] = 18;
  sub_DEAD011(0x12, v8, 13);                    // v[i] ^= 0x12  恢复成为input_code:
  sub_DEAD0D7(v8);                              // strlen
  sub_DEAD073();                                // write
  sub_DEAD09C(22, v7, 0);
  sub_DEAD05B();                                // read
  if ( sub_DEAD0D7(v7) > 18 )                   // 长度大于0x12
  {
    v9 = 0;
    v1 = sub_DEAD105(v7);                       // check input[:6] == 'hwbctf'
    v9 += v1;
    v9 += v7[6] != '{';                         // check {}
    v9 += v7[18] != '}';
    v2 = sub_DEAD16F(&v7[7]);                   // 解方程,解得1'm n0t 4n5
    v9 += v2;
    if ( v9 )
    {
      sub_DEAD011(0x89, &v4, 12);               // 此处有赋值,ida没有f5出来
      sub_DEAD0D7(&v4);
      sub_DEAD073();
    }
    else
    {
      sub_DEAD011(0xF1, &v5, 11);
      sub_DEAD0D7(&v5);
      sub_DEAD073();
    }
  }
  else
  {
    v6[0] = 105;
...
    v6[15] = 5;
    sub_DEAD011(5, v6, 16);                     // length error!!!
    sub_DEAD0D7(v6);
    sub_DEAD073();
  }
  return sub_DEAD08B();
}

而sub_DEAD16F函数则是有13个方程组判断输入的内容

__int64 __usercall sub_DEAD16F@<rax>(_BYTE *a1@<rdi>)
{
  int v1; // ST0C_4

  v1 = ((a1[4] ^ a1[2] ^ a1[6]) != 119)
     + ((a1[1] ^ *a1 ^ a1[3]) != 54)
     + (a1[10] + a1[3] != 85)
     + (a1[2] + a1[9] != 219)
     + (a1[4] + a1[5] != 158)
     + (a1[2] + a1[1] + a1[5] != 196)
     + (a1[8] + a1[7] + a1[9] != 194)
     + (a1[5] + a1[3] + a1[9] != 190)
     + (a1[6] + a1[2] + a1[8] != 277)
     + (a1[10] + a1[1] + a1[7] != 124);
  return (a1[5] != 48) + (a1[10] != 53) + ((a1[7] ^ a1[6] ^ a1[8]) != 96) + v1;
}

用Z3可以求解得flag为hwbctf{1’m n0t 4n5}

案例2: GWoC

GWoC是一个32位的Windows程序

CTF中32位程序调用64位代码的逆向方法

原题程序中有较多花指令和反调试部分,利用0x90来nop掉,附件提供的是一个Patch后的代码

CTF中32位程序调用64位代码的逆向方法

程序分析

将patch后的程序拖入IDA32位中,看到主流程如下

int __cdecl main(int argc, const char **argv, const char **envp)
{
  const char *v3; // ST14_4
  HANDLE v4; // eax
  HANDLE v5; // eax
  HANDLE v6; // eax
  HANDLE v7; // eax
  const char *v8; // eax
  const char *v9; // edx
  const char *v10; // edx
  const char *v11; // edx
  _DWORD *v13; // [esp+24h] [ebp-40h]
  _DWORD *v14; // [esp+28h] [ebp-3Ch]
  _DWORD *v15; // [esp+2Ch] [ebp-38h]
  _DWORD *lpParameter; // [esp+30h] [ebp-34h]
  BOOL Wow64Process; // [esp+3Ch] [ebp-28h]
  DWORD ThreadId; // [esp+40h] [ebp-24h]
  int v19; // [esp+44h] [ebp-20h]
  int v20; // [esp+48h] [ebp-1Ch]
  int v21; // [esp+4Ch] [ebp-18h]
  HANDLE Handles; // [esp+50h] [ebp-14h]
  HANDLE v23; // [esp+54h] [ebp-10h]
  HANDLE v24; // [esp+58h] [ebp-Ch]
  HANDLE v25; // [esp+5Ch] [ebp-8h]

  if ( argc < 2 ) //程序判断是否有命令行参数
  {
    sub_C725E0("Error missing argument !n");
    v3 = *argv;
    sub_C725E0("%s inputn");
    exit(0);
  }
  Wow64Process = 0;
  IsWow64Process((HANDLE)0xFFFFFFFF, &Wow64Process);
  if ( !Wow64Process ) //检测是否支持64位程序
  {
    sub_C725E0("System not supported ! Run me on 64bits Windows OSn");
    exit(0);
  }
  if ( strlen(argv[1]) != 32 )
    sub_C721C0();
  sub_C721E0();
  v4 = GetProcessHeap();
  lpParameter = HeapAlloc(v4, 8u, 0x18u);
  v5 = GetProcessHeap();
  v15 = HeapAlloc(v5, 8u, 0x18u);
  v6 = GetProcessHeap();
  v14 = HeapAlloc(v6, 8u, 0x18u);
  v7 = GetProcessHeap();
  v13 = HeapAlloc(v7, 8u, 0x18u);
  *lpParameter = 0xFAB; //初始化多线程参数1
  lpParameter[1] = 0;
  v8 = argv[1];
  lpParameter[2] = *((_DWORD *)v8 + 2);
  lpParameter[3] = *((_DWORD *)v8 + 3);
  Handles = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, lpParameter, 0, &ThreadId); //启动多线程1
  *v14 = 0xF0F0F0F0; //初始化多线程参数2
  v14[1] = 0xF0F0F0F0;
  v9 = argv[1];
  v14[2] = *((_DWORD *)v9 + 4);
  v14[3] = *((_DWORD *)v9 + 5);
  v23 = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, v14, 0, (LPDWORD)&v19);//启动多线程2
  *v13 = 0xF06B3430; //初始化多线程参数3
  v13[1] = 0x136D7374;
  v10 = argv[1];
  v13[2] = *(_DWORD *)v10;
  v13[3] = *((_DWORD *)v10 + 1);
  v24 = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, v13, 0, (LPDWORD)&v20);//启动多线程3
  *v15 = 0x43434343; //初始化多线程参数4
  v15[1] = 0x434343;
  v11 = argv[1];
  v15[2] = *((_DWORD *)v11 + 6);
  v15[3] = *((_DWORD *)v11 + 7);
  v25 = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, v15, 0, (LPDWORD)&v21);//启动多线程4
  WaitForMultipleObjects(4u, &Handles, 1, 0xFFFFFFFF); //线程通过
  if ( lpParameter[4] != 0x7E352B1F || lpParameter[5] != 0x9B04D2D3 ) //判断线程1结果
    sub_C721C0(); 
  if ( v15[4] != 0x4D95D40C || v15[5] != 0xE14496F7 ) //判断线程4结果
    sub_C721C0();
  if ( v14[4] != 0x2E4CB743 || v14[5] != 0xA51E28EE ) //判断线程3结果
    sub_C721C0();
  if ( v13[4] != 1434694267 || v13[5] != 1991371616 ) //判断线程2结果
    sub_C721C0();
  sub_C71320();
  return 0;
}

程序将32个字符的输入放入4个线程参数中,启动4个线程,每个线程都是调用同一个函数,只是参数不同。

.text:00C71330 ; DWORD __stdcall StartAddress(LPVOID lpThreadParameter)
.text:00C71330 StartAddress    proc far                ; DATA XREF: _main+191↓o
.text:00C71330                                         ; _main+1EC↓o ...
.text:00C71330
.text:00C71330 var_18          = dword ptr -18h
.text:00C71330 var_4           = dword ptr -4
.text:00C71330 lpThreadParameter= dword ptr  0Ch
.text:00C71330
.text:00C71330                 push    ebp
.text:00C71331                 mov     ebp, esp
.text:00C71333                 push    ecx
.text:00C71334                 push    ebx
.text:00C71335                 push    esi
.text:00C71336                 push    edi
.text:00C71337                 mov     [ebp+var_4], 0
.text:00C7133E                 mov     ecx, [ebp+8]
.text:00C71341                 push    33h
.text:00C71343                 call    $+5
.text:00C71348                 add     dword ptr [esp], 5
.text:00C7134C                 retf
.text:00C7134C StartAddress    endp ; sp-analysis failed
.text:00C7134C
.text:00C7134D                 call    loc_C72067
...
.text:00C72067                 dec     eax
.text:00C72068                 mov     [esp+8], ecx
.text:00C7206C                 dec     eax
.text:00C7206D                 sub     esp, 28h
.text:00C72070                 dec     eax
.text:00C72071                 mov     eax, [esp+30h]
.text:00C72075                 dec     eax
.text:00C72076                 mov     ecx, 97418529h

在这里又看到熟悉的push 33h和retf,就是进入64位代码的特征。进入的loc_C72067地址,无法正确识别64位汇编指令。

静态分析

因为这个案例中的代码可以静态dump出来,我们先进行静态分析。

使用案例1的Dump方法,拖入IDA64分析,可以恢复出代码,但会有一些内存引用的错误,这是因为缺少了上下文内存。

CTF中32位程序调用64位代码的逆向方法

虽然也可以分析,但是在这个案例中,可以尝试使用更优雅的方式。

在010Editor中,用PE模板打开exe文件,偏移大概是0x118处,修改标识32位的0x10b为64位的0x20b。

CTF中32位程序调用64位代码的逆向方法

然后放入IDA64中分析,Rebase Segment为0,再次看原来loc_C72067的地方(rebase后为0x2067),此时F5也可以识别出一些函数了,可以顺着分析sub_1C57和sub_1437等函数了。

signed __int64 __fastcall sub_2067(_QWORD *a1)
{
  signed __int64 v1; // rax
  unsigned __int64 v2; // rax
  signed __int64 result; // rax
  _QWORD *v4; // [rsp+30h] [rbp+8h]

  v4 = a1;
  v1 = *a1 ^ 0x1234567897418529i64;
  if ( v1 == 0xE2C4A68867B175D9i64 )
    a1[2] = sub_1C57(a1[1]);
  if ( *v4 == 0xFABi64 )
    v4[2] = sub_1437(v4[1]);
  v2 = *v4 % 0x11111111111111ui64;
  if ( v2 == 0x10101010101010i64 )
    v4[2] = sub_1C37(v4[1]);
  result = *v4 & 0x111000111000111i64;
  if ( result == 0x101000010000010i64 )
  {
    result = sub_1F77(v4[1]);
    v4[2] = result;
  }
  return result;
}

4个线程都是调用这个函数,但是由于输入参数的不同,会选取不同的函数调用。

例如之前 *lpParameter = 0xFAB 对应的是这里的 *v4 == 0xFABi64 判断 ,所以这部分输入调用的是sub_1437 函数,v4[1]就是实际输入串中第8个到第15个字符,即input[8:16] 。

进入分析sub_1437 ,发现是一个流式加密,根据F5的结果逆向比较复杂,还想结合动态运行结果进行逆向。

sbox[0] = ...
...
sbox[254] = 0xF9u;
sbox[255] = 0xF8u;
LOBYTE(v7) = 0;
memset(&v7 + 1, 0, sizeof(__int64));
for ( i = 0; i < 8; ++i )
    *(&v7 + i) = *(&v9 + i);
v3 = v7;
for ( j = 0; j < 256; ++j )
{
    v8 = sbox[(sbox[j % -8 + 248] + v3)];
    v1 = *(&v7 + (j + 1) % 0xFFFFFFF8) + v8;
    v3 = (v1 >> 7) | 2 * v1;
    *(&v7 + (j + 1) % -8) = v3;
}
return v7;

动态调试

在WIndows下,IDA32、IDA64和Ollydbg这些调试器在retf指令执行后都无法正常运行,在师傅的指点下,采用windbg作为动态调试工具。

用Windbg 64位打开目标程序File->Open Executable,注意输入命令行参数

CTF中32位程序调用64位代码的逆向方法

在View菜单打开Disassembly(汇编)、Registers(寄存器)、Memory(内存)和Command(命令)窗口,布局如下

CTF中32位程序调用64位代码的逆向方法

一开始我们要在retf处设置断点,怎么设置呢?IDA中,rebase segment为0后,可以看到retf的地址为0x134c,所以在windbg的Disassembly窗口输入GWoC+0x134c,确定也是retf,按F9设置断点。

CTF中32位程序调用64位代码的逆向方法

按F5执行到断点处,再按F8单步进入执行,此时CS寄存器可以看到已经变成0x33,进入64位代码块

CTF中32位程序调用64位代码的逆向方法

此时再在我们想调试的sub_1437 函数加入断点,在Disassembly窗口输入GWoC+0x1437,按F9加断点,然后F5运行到断点处,就能愉快的开始调试了。

CTF中32位程序调用64位代码的逆向方法

CTF中32位程序调用64位代码的逆向方法

IDA也有链接Windbg的功能,但是本文所采用的IDA 7.0版本并未能成功连上windbg进行调试,只能IDA用于静态分析,Windbg进行动态调试,两边结合逆向。

逆向破解

以输入12345678901234567890123456789012为例

  • 输入参数:0xfab
  • 输入内容:input[8:16] = “90123456”
  • 调用函数:sub_1437
  • 算法内容:流式加密,根据结果逆推即可
  • 逆向结果:F @AzOpFx
  • 输入参数:0xf0f0f0
  • 输入内容:input[16:24] = “78901234”
  • 调用函数:sub_1C57
  • 算法内容:低4位和高4位分开运算,多次位移和异或运算,可用暴力破解
  • 逆向结果:Cq!9x9zc
  • 输入参数:0x136D7374F06B3430
  • 输入内容:input[:8] = “12345678”
  • 调用函数:sub_1F77
  • 算法内容:低4位和高4位分开进行快速幂取模操作,就是RSA,分解因数解密RSA即可
  • 逆向结果:flag{RpC
  • 输入参数:0x43434343434343
  • 输入内容:input[24:] = “56789012”
  • 调用函数:sub_1C37
  • 算法内容:输入异或0x9C70A3C478EF826A,根据结果异或即可
  • 逆向结果:fVz5354}

所有字符串拼接在一起得到flag{RpCF @AzOpFxCq !9x9zcfVz5354}

小结

本文通过windows和 linux 的案例,整理了32位程序调用64位代码的识别方法、静态分析和动态调试技巧。

识别方法

  1. retf是切换32位和64位的关键指令。
  2. retf前有push 0x33(33h)类似的指令。
push    33h
add     dword ptr [esp], 5
retf

或者

mov     dword ptr [eax], 33h
leave
retf
  1. retf后CS寄存器从0x23变为0x33。
  2. 程序中可能有进行支持64位的检查,如GWoC。
  3. 当一块可执行的内存,调试时无法识别汇编或者几步一跳时,有可能在是执行64位的代码。
  4. 32位代码调用函数的方式和64位代码有差异,32位程序大多通过入栈方式传参,64位程序一般用寄存器传参。
  5. 32位和64位的syscall的含义和参数有所不同。

静态分析

  1. 修改PE/ELF头位64位,让IDA64识别其中64位的部分代码。
  2. 静态/动态dump出内存中的64位代码片段,拖入IDA64分析代码。
  3. 有时候可以通过IDA中Change Segment Attributes 设置为64位,进行汇编分析。
  4. 使用Rebase Segment对齐基地址方便进行静结合分析

动态调试

  1. Linux ELF程序可使用gdbserver和IDA64的组合进行调试。
  2. Windows程序使用Windbg进行动态调试,使用IDA64进行静态分析,动静结合逆向。

参考


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Building Social Web Applications

Building Social Web Applications

Gavin Bell / O'Reilly Media / 2009-10-1 / USD 34.99

Building a social web application that attracts and retains regular visitors, and gets them to interact, isn't easy to do. This book walks you through the tough questions you'll face if you're to crea......一起来看看 《Building Social Web Applications》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

随机密码生成器
随机密码生成器

多种字符组合密码

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具