深入iOS系统底层之映像文件操作API介绍

栏目: IOS · 发布时间: 5年前

内容简介:mach-o文件和进程的映像(image)iOS系统生成的可执行程序或者动态库文件的存储布局格式被称之为mach-o格式。文件中存放着程序的代码和数据,而程序运行时系统会为其建立一个进程,以及分配虚拟内存空间。同时会把程序文件中的内容加载到虚拟内存地址空间中去,这种加载的方法一般采用内存映射文件的技术来实现。所谓的映像可以理解为将一个程序文件的内容加载到进程虚拟内存中的内容,也就是说所以说程序文件和程序被加载后在内存中映像之间并不是一一对应的。程序文件和映像之间的关系就如程序和进程之间的关系是一样的。在程序

绿树阴浓夏日长,楼台倒影入池塘。--《唐高骈·山亭夏日》

mach-o文件和进程的映像(image)

iOS系统生成的可执行程序或者动态库文件的存储布局格式被称之为mach-o格式。文件中存放着程序的代码和数据,而程序运行时系统会为其建立一个进程,以及分配虚拟内存空间。同时会把程序文件中的内容加载到虚拟内存地址空间中去,这种加载的方法一般采用内存映射文件的技术来实现。所谓的映像可以理解为将一个程序文件的内容加载到进程虚拟内存中的内容,也就是说 进程的映像就是程序磁盘文件在内存中的一个副本 。 一般来说一个进程中映像的内容和内存布局结构会和程序文件的内容以及存储布局结构一致,映像的首地址是一个struct mach_header的结构体指针。映像中内容的排列布局和程序文件都是以段(Segment)为单位进行排列的。但是有一些情况映像的内存布局和内容可能会和程序文件的内存布局和内容不一致:

  1. 映像中的数据段部分,因为数据段部分大多是可以被读写访问的,也就是说可以在运行时被修改,或者某些信息会进行rebase处理。因此数据段不能被进程之间共享,而是每个进程单独维护一份。当然为了效率和性能系统会采用一种称之为Copy on write的技术来实现单独副本的拷贝的。通常只有不可变的代码段部分才会是内存和文件中的内容保持一致,并且多进程共享。一个很常见的例子就是进程中加载的动态库和框架中的代码段部分通常都是所有进程共享。

  2. 即使是代码段也有可能映像中的内容和程序文件中的内容不一致。有一些映像中的某些段的内容会是系统中缓存的段,而不是程序文件对应的段。一个很有代表性的例子就是CoreLocation这个库,当这个库被加载时你就会发现其映像中的有一些代码段的内容其实是系统缓存的内容而不是程序文件中的内容。

所以说程序文件和程序被加载后在内存中映像之间并不是一一对应的。程序文件和映像之间的关系就如程序和进程之间的关系是一样的。在程序运行后对其在进程中所有的mach-o数据结构的访问都是基于映像而不是基于程序文件的。

Slide机制

构建一个程序时为了方便计算和处理会为这个程序设定一个默认在内存中加载的基地址。这样在程序中所有涉及到地址存储的代码中的地址变量都是以这个基地址为标准的。比如我们在代码中有变量保存一个函数的地址或者在rumtime中的OC类的方法结构体:struct method_t中的imp保存的函数的地址等等。正常情况下如果我们的程序加载时也是按照程序中指定的基地址加载到虚拟内存中对应的地址时则一切都正常而且也不需要做任何的改变。但实际情况则不同:

  1. 任何一个库或者可执行程序在构建时都会指定一个加载的基地址,但是却无法保证这个基地址的唯一性。和无法保证程序映像的地址区间不产生重叠。因此有可能出现多个库加载到内存时的重叠覆盖的情况。

  2. iOS系统为保证的应用安全采用了一种称之为**ASLR(Address space layout randomization)**的技术。这种技术会使得每个程序或者库每次运行加载到内存中时的基地址都不是固定而是随机的,这种机制会增加黑客的破解难度。

上面的两种情况表明一个程序或者库加载到内存时的真实的基地址和程序构建时指定的基地址是不一样的。系统会为可执行程序和每个库选择不重叠的区域进行加载。但是这样就会出现在程序中所有以构建时基地址为标准的那些地址指针出现访问异常,因为这些地址值并不是真实在内存中的地址值。

为了解决这个问题系统会在构建的程序或库中添加一个特殊的load command命令:LC_DYLD_INFO或者LC_DYLD_INFO_ONLY。这部分信息用来记录所有需要进行地址调整的位置。这样当程序被加载到内存时,加载器就会将需要调整的地址分别进行调整处理,以便转化为真实的内存地址。这个过程称之为基地址重定向(rebase)。

假设程序构建时指定的基地址为A,程序中某处保存的一个函数指针地址为x,而程序被加载到内存时的真实基地址为B。也就是说真实的基地址和构建时的基地址的偏移差就是B-A。我们称这个偏移差值为Slide值。因此真实的地址x被调整后应该是: x + (B - A)了。

一个程序在构建时的基地址值可以在程序的第一个名为__TEXT的代码段描述结构体struct segment_command中的vmaddr数据成员中获取,而程序被加载后的得到的映像的mach-o头部结构体struct mach_header指针则是映像被加载的真实的基地址,因此:

映像的Slide值 = 映像的mach_header结构体指针 -  映像的第一个__TEXT代码段描述结构体struct segmeng_command中的vmaddr数据成员的值。

当然系统也提供了接口API来获取可执行程序或者库的映像的Slide值。这个将会在下面介绍。

段(Segment)和节(Section)

mach-o文件由诸多的load command组成,每个load command所代表的是一种数据类型。比如有的load command是用来存放程序代码和全局变量数据,有的load command是用来存放符号表,有的load command是用来存放代码签名信息等。每种load command都是结构体struct load_command的扩展结构体。其中的cmd字段用来描述这种load command的类型。

类型为LC_SEGMENT或者为LC_SEGMENT_64的load command被称之为段(Segment)。一个可执行程序中的代码和全局变量数据都保存在段中。描述段的信息是一个struct segment_command结构体。一个程序中可以存在着很多的段,每个段有一个唯一的段名(segment name)。比如一个可执行程序中所有的代码都保存在名字为:__TEXT的代码段中,而所有的数据都保存在名字为:__DATA的数据段中。段以页为边界进行对齐。

每个段则由多个节(Section)组成。节是内容分类的最小管理单元。每个节的描述信息是一个称之为:struct section的结构体。每个节有一个唯一的名称用来标识这个节。比如代码段中有一个名为:__text的节用来保存程序中用户编写的源代码对应的机器指令,而一个名为:__stub_helper的节则保存所有调用的外部函数的桩代码。下面的一张图展示的就是程序中的段和节的结构布局:

深入iOS系统底层之映像文件操作API介绍

进程映像(Image)操作API

对映像进行操作的API都在 中声明。你可以import这个头文件来使用里面定义的函数。下面我会分别介绍这些函数。

1.获取当前进程中加载的映像的数量

//函数返回当前进程中加载的映像的数量
uint32_t  _dyld_image_count(void)

2.获取某个映像的mach-o头部信息结构体指针

const struct mach_header*   _dyld_get_image_header(uint32_t image_index)

函数的入参为映像在进程当中的索引号,函数返回的值是一个映像的mach-o头部信息struct mach_header结构体指针,如果是64位系统则返回的是struct mach_header_64结构体指针。你可以通过这个函数返回的映像的头部结构体来遍历和访问映像中的所有信息和数据。

一个映像的头部信息结构体指针其实就是映像在内存中加载的基地址。

一般情况下索引为0的映像是dyld库的映像,而索引为1的映像就是当前进程的可执行程序映像。

系统还提供一个没有在头文件中声明的函数:

const struct mach_header* _NSGetMachExecuteHeader()

这个函数返回当前进程的可执行程序映像的头部信息结构体指针。因为这个函数没有在某个具体的头文件中被声明,所以当你要使用这个函数时需要在源代码文件的开头进行声明处理:

 extern const struct mach_header* _NSGetMachExecuteHeader();

3.获取进程中某个映像加载的Slide值

intptr_t   _dyld_get_image_vmaddr_slide(uint32_t image_index)

函数的入参为映像在进程当中的索引号,函数的返回值是映像加载的Slide值。关于Slide值的介绍已经在上面有详细说明。在mach-o格式程序中的结构体描述信息中凡是涉及到指针字段都应该加上这个值才是真实的内存地址。

4.获取进程中某个映像的名称

const char*  _dyld_get_image_name(uint32_t image_index)

函数的入参为映像在进程当中的索引号,函数的返回值是映像对应库的全路径名称,返回的字符串我们不能修改也不必去销毁它。

5.注册映像加载和卸载的回调通知函数

void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))
void _dyld_register_func_for_remove_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))

如果你通过函数_dyld_register_func_for_add_image注册了一个映像被加载时的回调函数时,那么每当后续一个新的映像被加载但未初始化前就会调用注册的回调函数,回调函数的两个入参分别表示加载的映像的头结构和对应的Slide值。如果在调用_dyld_register_func_for_add_image时系统已经加载了某些映像,则会分别对这些加载完毕的每个映像调用注册的回调函数。

如果你通过函数_dyld_register_func_for_remove_image注册了一个映像被卸载时的回调函数时,那么每当一个映像被卸载前都会调用注册的回调函数,回调函数的两个入参分别表示卸载的映像的头结构和对应的Slide值。

这两个函数的作用通常用来做程序加载映像的监控以及一些统计处理。

6.获取某个库链接时和运行时的版本号

//获取库运行时的版本号
int32_t NSVersionOfRunTimeLibrary(const char* libraryName)
//获取库链接时的版本号
int32_t NSVersionOfLinkTimeLibrary(const char* libraryName)

我们在XCODE工程中链接一些系统动态库时,有时候会选择某个具体版本的动态库,但是有些操作系统可能不一定会提供对应版本的动态库,这样就会导致程序运行时加载的动态库版本和链接时指定的动态库的版本不一致。还有一种场景就是工程中并没有链接对应的动态库,但是因为其他库会链接对应的动态库,就会出现虽然没有直接链接对应的动态库但是还是会加载对应的动态库的情况。

因此系统提供了这两个API可以获取某个动态库链接和加载运行时的版本号。这两个函数的入参都是动态库的名称,这个名称是不带路径和扩展名以及不带lib前缀的库名称。函数返回库对应的版本号,如果库不存在或者没有被加载或者没有被链接则返回-1。比如下面的代码:

   //这里的名称c++其实是指的libc++.dylib这个库。
    uint32_t v1 =  NSVersionOfRunTimeLibrary("c++");
    uint32_t v2 =  NSVersionOfLinkTimeLibrary("c++");

如果我们的程序并没有显示的链接libc++.dylib则后者函数会返回-1。而前者则一般都会返回一个对应的libc++的版本号。

这两个函数的主要用来做一些库分析和运行监测等功能,比如可以检测某个库是否是一个在运行时被加载而不是显示链接进来的动态库。

7.获取当前进程可执行程序的路径文件名

int _NSGetExecutablePath(char* buf, uint32_t* bufsize)

函数的入参buf和bufsize指明保存可执行文件路径名的缓存和缓存的尺寸,其中的bufsize是要指明缓存的尺寸,并且会输出可执行文件路径名称的真实尺寸。如果函数调用返回正确则返回0,否则返回-1。就比如下面的例子:

char buf[256];
uint32_t bufsize = sizeof(buf)/sizeof(char);
_NSGetExecutablePath(buf, &bufsize);

8.注册当前线程结束时的回调函数

void _tlv_atexit(void (*termFunc)(void* objAddr), void* objAddr)

复制代码有时候我们想监控线程的结束事件,那么就可以用这个函数来实现。这个函数用来监控当前线程的结束,当线程结束或者终止时就会调用注册的回调函数,_tlv_atexit函数有两个参数:第一个是一个回调函数指针,第二个是一个扩展参数,作为回调函数的入参来使用。

不明白为什么这个函数会放在 中声明,完全不搭界!

段(Segment)和节(Section)操作API

对段和节进行操作的API都在import 中声明。你可以import这个头文件来使用里面定义的函数。当然如果你了解mach-o的文件格式的话可以不用这些API,而是直接根据映像的头部结构体struct mach_header来遍历和访问这些段和节。不过既然系统已经提供相关的API,那么还是优先考虑用它们最合适了。下面我会分别介绍这些函数。

段和节操作的API在系统的libmacho.dylib库中实现,这个库暂时还没有开源出来。

1. 获取进程中映像的某段中某个节的非Slide的数据指针和尺寸

//获取进程中可执行程序映像的某个段中某个节的数据指针和尺寸。
 char *getsectdata(const char *segname, const char *sectname, unsigned long *size) 
 
//获取进程加载的库的segname段和sectname节的数据指针和尺寸。
 char *getsectdatafromFramework(const char *FrameworkName, const char *segname, const char *sectname, unsigned long *size);

这两个函数返回进程中可执行程序映像或者某个加载的动态库中的某个段中某个节的数据指针和尺寸。这两个函数其实就是返回对应的节描述信息结构struct section中的addr和size两个数据成员的值。需要注意的是返回的地址值是没有加上Slide值的指针,因此当我们要在进程中访问真实的地址时需要加上对应的Slide值,下面就是一个实例代码:

//一般索引为1的都是可执行文件映像
intptr_t  slide = _dyld_get_image_vmaddr_slide(1);
unsigned long size = 0;
char *paddr = getsectdata("__TEXT", "__text", &size);
char *prealaddr = paddr + slide;  //这才是真实要访问的地址。

getsectdata函数的代码实现如下:

//假设是64位的系统
char *getsectdata(const char *segname, const char *sectname, unsigned long *size)
{
    const struct mach_header_64 *mhp =  _NSGetMachExecuteHeader();
    //这个函数会在下面介绍到。
    return  getsectdatafromheader_64(mhp, segname, sectname, size);
}

个人不建议用这个函数而是用下面会介绍到的getsectiondata函数更合适。

2.获取段和节的边界信息

//获取当前进程可执行程序映像的最后一个段的数据后面的开始地址。 
unsigned long get_end(void);
//获取当前进程可执行程序映像的第一个__TEXT段的__text节的数据后面的开始地址。
 unsigned long get_etext(void);
//获取获取当前进程可执行程序映像的第一个_DATA段的__data节的数据后面的开始地址
 unsigned long get_edata(void);

这几个函数主要用来获取指定段和节的结束位置,以及用来确定某个地址是否在指定的边界内。需要注意的是这几个函数返回的边界值是并未加Slide值的边界值。下面是这几个函数的内部实现:

unsigned long get_end()
{
   unsigned long end = 0;
   const struct mach_header_64 *mhp =  _NSGetMachExecuteHeader();
   struct segment_command_64 *psegcmd = mhp + 1;
   for (int i = 0; i < mhp->ncmds; i++)
   {
       if (psegcmd->cmd != LC_SEGMENT_64)
            break;
       end = psegcmd->vmaddr + psegcmd->vmsize;
       psegcmd += 1;
   }
   return end;
}

unsigned long get_etext()
{
   const struct section_64 *sec = getsectbyname("__TEXT","__text");
   return psection->addr + psection->size;
}

unsigned long get_edata()
{
   const struct section_64 *sec = getsectbyname("__DATA","__data");
   return psection->addr + psection->size;
}

3.获取进程中可执行程序映像的段描述信息

//获取进程中可执行程序映像的指定段名的段描述信息
const struct segment_command *getsegbyname(const char *segname)
//上面函数的64位版本
const struct segment_command_64 *getsegbyname(const char *segname)

这两个函数返回进程中可执行程序映像的某个段的段描述信息。段描述信息是一个struct segment_command或者struct segment_command_64结构体。

比如下面代码返回进程中可执行程序映像代码段__TEXT的段信息。

const struct segment_command_64 *psegment = getsegbyname("__TEXT");

4.获取进程中可执行程序映像的某个段中某个节的描述信息

//获取进程中可执行程序映像的某个段中某个节的描述信息。
const struct section *getsectbyname(const char *segname,  const char *sectname)
//上面对应函数的64位系统版本
const struct section_64 *getsectbyname(const char *segname, const char *sectname)

这两个函数分别返回32位系统和64位系统中的进程中可执行程序映像的segname段中的sectname节的描述信息。节的描述信息是一个struct section或者struct section_64的结构体。比如下面的代码返回代码段__TEXT中的代码节__text的描述信息:

  struct section_64 *psection = getsectbyname("__TEXT","__text");

5.获取进程中映像的段的数据

//获取指定映像的指定段的数据。
uint8_t *getsegmentdata(const struct mach_header *mhp, const char *segname, unsigned long *size)

//上面函数的64位版本
uint8_t *getsegmentdata(const struct mach_header_64 *mhp, const char *segname, unsigned long *size)

复制代码函数返回进程内指定映像mhp中的段segname中内容的地址指针,而整个段的尺寸则返回到size所指的指针当中。这个函数的内部实现就是返回段描述信息结构struct segment_command中的vmaddr数据成员的值加上映像mhp的slide值。而size中返回的就是段描述信息结构中的vmsize数据成员。

因为在前面讲过因为映像加载时的slide值的缘故,所以映像中的各种mach-o结构体中涉及到地址的数据成员的值都需要加上slide值才能得到映像在内存中的真实加载地址。

进程中每个映像中的第一个__TEXT段的数据的地址其实就是这个映像的mach_header头结构的地址。这是一个比较特殊的情况。

下面的代码演示的是获取进程中第0个索引位置映像的__DATA段的数据。

struct mach_header_64 *mhp = _dyld_get_image_header(0);
unsigned long size = 0;
uint8_t *pdata = getsegmentdata(mhp,  "__DATA", &size);

6.获取进程映像的某段中某节的数据

//获取进程映像中的某段中某节的数据地址和尺寸。
uint8_t *getsectiondata(const struct mach_header *mhp, const char *segname, const char *sectname, unsigned long *size)
//上面函数的64位版本
uint8_t *getsectiondata(const struct mach_header_64 *mhp, const char *segname, const char *sectname, unsigned long *size)

函数返回进程内指定映像mhp中的段segname中sectname节中内容的地址指针,而整个节的尺寸则返回到size所指的指针当中。这个函数的内部实现就是返回节描述信息结构struct section中的addr数据成员的值加上映像mhp的slide值。而size中返回的就是段描述信息结构中的size数据成员的值。

因为在前面讲过因为映像加载时的slide值的缘故,所以映像中的各种mach-o结构体中涉及到地址的数据成员的值都需要加上slide值才能得到映像在内存中的真实加载地址。

下面的例子获取进程中第0个映像的"__TEXT"段中的"__text"节的数据地址指针和尺寸:

struct mach_header_64 *mhp = _dyld_get_image_header(0);
unsigned long size = 0;
uint8_t *pdata = getsectiondata(mhp,  "__TEXT", "__text", &size);

7.获取mach-O文件中的某个段中某个节的描述信息

//获取指定mach-o文件中的某个段中某个节中的描述信息
const struct section *getsectbynamefromheader(const struct mach_header *mhp, const char *segname, const char *sectname)

//获取指定mach-o文件中的某个段中某个节中的描述信息。fSwap传NXByteOrder枚举值。
const struct section *getsectbynamefromheaderwithswap(struct mach_header *mhp, const char *segname, const char *sectname, int fSwap)

//上面对应函数的64位系统版本
const struct section_64 *getsectbynamefromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname)

//上面对应函数的64位系统版本
const struct section *getsectbynamefromheaderwithswap_64(struct mach_header_64 *mhp, const char *segname, const char *sectname, int fSwap)

这一系列函数分别返回32位系统和64位系统的mach-o文件的节的描述信息。每个函数都有segname和sectname分别指明要获取的段名和节名。参数mhp则表明mach-o文件的头部结构指针。对于有一些系统或者mach-o文件中的数值采用big-endian来编码,因此对于这些采用big-endian编码的结构来说就需要传递fSwap来确定是否交换这些编码。

这一系列函数中的mhp结构不局限于进程中的映像的头部结构,针对mach-o文件的头部结构也适用,如果你不了解映像和文件的区别则请看文章中的开头的介绍。

因为不管是进程中的映像的Section的排列以及mach-o文件中的Section的排列都是一致的,因此其实上述的getsectbyname的实现就是借助本节提供的函数实现的,其实现的代码如下:

const struct section_64 *getsectbyname(
    const char *segname,
    const char *sectname)
{
   const struct mach_header_64 *mhp =  _NSGetMachExecuteHeader();
   return getsectbynamefromheader_64(mhp, segname, sectname);
}

8.获取mach-o文件中的某段中的某个节的数据指针和尺寸

//获取指定mach-o文件中的某个段中的某个节的数据指针和尺寸
char *getsectdatafromheader(const struct mach_header *mhp, const char *segname, const char *sectname, uint32_t *size)

//64位系统函数
char *getsectdatafromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname, uint64_t *size)

这两个函数返回32位系统或者64位系统中的某个mach-o文件中的某个段中某个节的数据指针和尺寸。这两个函数其实就是返回对应的节描述信息结构struct section中的addr值和size值。因为这两个函数是针对mach-o文件的,但是也可以用在对应的库映像中,当应用在库映像中时就要记得对返回的结果加上对应的slide值才是真实的节数据所对应的地址!

一个非常有用的DEMO

iOS系统提供了所谓方法交换(method swizzling)的黑魔法机制。它可以在运行时替换掉某个类的某个方法的默认实现。然而技术有两面性,对于越狱系统来说,恶意开发人员可以通过动态库注入并利用方法交换的技巧来改变程序运行的原有逻辑,从而可以跨过一些常规检测而谋取非法利益。

凡事有攻就有守,通过本文中介绍的API函数就可以在一定程度上检测某个类中的某个方法是否被非法HOOK。以可执行程序中的某个类的实例方法为例。可执行程序中定义的类的实例方法的实现地址总是在可执行程序映像的地址区间范围内,即使是这个方法被可执行程序中的其他方法HOOK了,这个HOOK的方法地址仍然是在可执行程序的映像地址区间范围内,我们仍然认为这是一个合法的HOOK。如果可执行程序中的类的实例方法被恶意攻击者通过动态库注入并以方法交换的形式来HOOK原有方法的实现时,因为HOOK的方法地址是在恶意注入的动态库映像的地址区间范围内,所以我们就可以通过检测这个类的实例方法的实现地址是否在可执行程序的映像的地址区间范围内来判断这个方法是否被恶意HOOK了。下面就是这种检测的具体实现代码,建议检测的代码用C函数来实现而不是用OC类的方法来实现,否则这个检测逻辑也有可能被HOOK。

//Author by 欧阳大哥
#import 
<mach-o dyld="" h="">
 
#import 
 <mach-o getsect="" h="">
  

BOOL checkMethodBeHooked(Class class, SEL selector)
{
    //你也可以借助runtime中的C函数来获取方法的实现地址
    IMP imp = [class instanceMethodForSelector:selector];
    if (imp == NULL)
         return NO;
         
    //计算出可执行程序的slide值。
    intptr_t pmh = (intptr_t)_NSGetMachExecuteHeader();
    intptr_t slide = 0;
#ifdef __LP64__
    const struct segment_command_64 *psegment = getsegbyname("__TEXT");
#else 
    const struct segment_command *psegment = getsegbyname("__TEXT");
#endif
    intptr_t slide = pmh - psegment->vmaddr
    
    unsigned long startpos = (unsigned long) pmh;
    unsigned long endpos = get_end() + slide;
    unsigned long imppos = (unsigned long)imp;
    
    return (imppos < startpos) || (imppos > endpos);
}
 </mach-o>
</mach-o>

作者:欧阳大哥2013

链接:https://juejin.im/post/5c6a976ae51d451eb711a90f


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

查看所有标签

猜你喜欢:

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

数据结构与问题求解

数据结构与问题求解

韦斯 / 清华大学出版社 / 2011-8 / 89.50元

《数据结构与问题求解(Java语言版)(第4版)》是专为计算机科学专业的两个学期课程而设计的,从介绍什么足数据结构开始,继而对高级数据结构与算法进行分析。《数据结构与问题求解(Java语言版)(第4版)》以独特的方式,清晰地将每种数据结构的接口与其实现分离开来,即将如何使用数据结构与如何对数据结构编程相分离。《数据结构与问题求解(Java语言版)(第4版)》从抽象思维和问题求解的角度出发,为数据结......一起来看看 《数据结构与问题求解》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

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

URL 编码/解码

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器