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

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

内容简介:在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进行静态分析,动静结合逆向。

参考


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

查看所有标签

猜你喜欢:

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

Code Complete

Code Complete

Steve McConnell / Microsoft Press / 2004-6-19 / GBP 40.99

在线阅读本书 Widely considered one of the best practical guides to programming, Steve McConnells original CODE COMPLETE has been helping developers write better software for more than a decade. Now......一起来看看 《Code Complete》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

html转js在线工具
html转js在线工具

html转js在线工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试