帮闲电子商务_绿兔子源码_破解软件_网站源码_原创软件_游戏影视娱乐 - LVTZ.COM

 找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
查看: 10363|回复: 4

[系统底层] 修改电脑机器码Hwid Spoofer

  [复制链接]
  • TA的每日心情
    慵懒
    2024-10-31 18:37
  • 签到天数: 7 天

    [LV.3]偶尔看看II

    439

    主题

    491

    帖子

    9363

    积分

    管理员

    Rank: 9Rank: 9Rank: 9

    优秀版主突出贡献灌水之王宣传达人推广达人热心会员活跃会员最佳新人荣誉管理论坛元老

    精华
    80
    威望
    500
    听众
    0
    萝卜
    6972
    注册时间
    2020-2-4
    在线时间
    134 小时
    发表于 2021-2-9 17:41:49 | 显示全部楼层 |阅读模式
    今天我就来和大家讲讲游戏攻防之Hwid Spoofer


    1.什么是Hwid Spoofer呢?
    我们逐个来进行解析,Hwid的完整单词也就是hardware id,也就是中文的硬件id的意思
    而Spoofer也就是欺骗器的意思了,结合起来就是硬件id的欺骗器


    2.Hwid Spoofer的作用是什么?
    作用还是蛮多的哈,但是主要还是用在和反作弊的对抗上面

    我们都知道,一些反作弊利用电脑的Hwid去跟踪监视那些作弊者或者开发者,防止他们继续在游戏中作弊。
    反作弊会获取电脑的Hwid信息后,发送到服务器,然后进行哈希比对,发现这电脑的Hwid作弊太多次了的话,就直接封号处理
    而使用了Hwid Spoofer之后,我们电脑的硬件信息全部变了,比如说CPU序列号、显卡序列号、硬盘序列号、网卡Mac之类的全部改变了
    这样子反作弊就以为当前电脑是安全的,我们就可以继续在游戏中作弊

    3.Hwid Spoofer如何工作的?
    Hwid Spoofer可以分为内核层模式的和用户层模式,很多情况下需要两者结合才能真正安全
    不同反作弊去获取电脑Hwid信息的方式是不一样的,比如BE和EAC,它们有相同之处也有不同之处
    它们有的通过DeviceIoControl函数获取Hwid
    它们有的通过RegOpenKey函数获取Hwid
    它们有的通过CreateFile函数获取Hwid
    .......
    了解了它们如何获取Hwid,我们就能通过Hook去操作,也能通过修改内存数据去操作,亦或者通过直接修改真正的硬件数据去操作......



    硬盘序列号可以当Hwid
    网卡Mac可以当Hwid
    注册表里面的一些Guid也可以当Hwid
    游戏缓存文件也可以当Hwid
    .......
    所以说,要应用层+内核层才能得到好的效果
    内核层工作 : 修改硬盘序列号,修改网卡Mac,修改显卡Guid,修改主板序列号,修改CPU序列号....
    应用层工作 : 修改注册表,删除跟踪缓存文件.....


    ---------------------------------------------------------------------------------------------------------------我是一条无情的分割线


    我们今天将的就是内核层的Hwid Spoofer实现
    其实说简单也简单,说不简单也不简单
    简单在于只是修改Irp派遣函数和定位到指定内存后修改字节数据
    不简单在于考虑兼容性,Windows内核里面的一些实现是随系统的更新而发生变化,两个版本的Win10或许不同

    我们今天就针对一下内容进行交流 :
    1.硬盘序列号
    2.显卡Guid
    3.主板序列号
    4.网卡Mac

    硬盘序列号效果 :



    清空硬盘序列号效果图 :



    显卡序列号效果 :



    主板序列号效果 :




    网卡Mac效果 :




    清空网卡Mac效果图 :



    先说怎么样获取硬盘序列号吧

    硬盘序列号的获取主要是用函数CreateFile传入PhysicalDriveX打开物理硬盘对象
    然后用DeviceIoControl函数传入IOCTL_STORAGE_QUERY_PROPERTY或者SMART_RCV_DRIVE_DATA来得到序列号
    不懂的可以去参考 : https://blog.csdn.net/abcd19892012/article/details/12970317
    在CMD里面输入 wmic diskdrive get serialnumber 也可以得到当前的硬盘序列号


    我们第一步就是替换派遣函数
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    // 开始执行hook操作?
    bool start_hook()
    {
            // 替换派遣函数,相当于对函数进行了hook
            // 当然也可以进行真正的hook操作,pg应该不会检查这些地方

            // hook这里主要是为了修改硬盘的一些guid
            g_original_partmgr_control = n_util::add_irp_hook(L"\\Driver\\partmgr", my_partmgr_handle_control);

            // hook这里主要是为了修改硬盘的一些序列号
            g_original_disk_control = n_util::add_irp_hook(L"\\Driver\\disk", my_disk_handle_control);

            // hook这里主要是为了修改磁盘的一些volume
            g_original_mountmgr_control = n_util::add_irp_hook(L"\\Driver\\mountmgr", my_mountmgr_handle_control);

            return g_original_partmgr_control && g_original_disk_control && g_original_mountmgr_control;
    }






    可以看到,add_irp_hook函数的实现也是很简单
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    // 替换派遣函数实现hook操作?
    PDRIVER_DISPATCH add_irp_hook(const wchar_t* name, PDRIVER_DISPATCH new_func)
    {
            // 初始化一个unicode string
            UNICODE_STRING str;
            RtlInitUnicodeString(&str, name);

            // 根据名称得到object指针
            PDRIVER_OBJECT driver_object = 0;
            NTSTATUS status = ObReferenceObjectByName(&str, OBJ_CASE_INSENSITIVE, 0, 0, *IoDriverObjectType, KernelMode, 0, (void**)&driver_object);
            if (!NT_SUCCESS(status)) return 0;

            // 这里就是修改派遣函数 实现hook操作
            PDRIVER_DISPATCH old_func = driver_object->MajorFunction[IRP_MJ_DEVICE_CONTROL];
            driver_object->MajorFunction[IRP_MJ_DEVICE_CONTROL] = new_func;
            n_log::printf("%ws hook %llx -> %llx \n", name, old_func, new_func);

            // 解除引用防止蓝屏
            ObDereferenceObject(driver_object);

            // 返回原始的派遣函数地址
            return old_func;
    }






    Ok,回来,我们来看my_disk_handle_control函数的实现
    根据用户层调用我们可以知道,我们只需要关注IOCTL_STORAGE_QUERY_PROPERTY和SMART_RCV_DRIVE_DATA消息
    我另外还处理了IOCTL_ATA_PASS_THROUGH,不要也是可以的
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    // 我们的处理函数
    NTSTATUS my_disk_handle_control(PDEVICE_OBJECT device, PIRP irp)
    {
            PIO_STACK_LOCATION ioc = IoGetCurrentIrpStackLocation(irp);
            const unsigned long code = ioc->arameters.DeviceIoControl.IoControlCode;

            if (code == IOCTL_STORAGE_QUERY_PROPERTY)
            {
                    if (StorageDeviceProperty == ((PSTORAGE_PROPERTY_QUERY)irp->AssociatedIrp.SystemBuffer)->ropertyId)
                            n_util::change_ioc(ioc, irp, my_storage_query_ioc);
            }

            else if (code == IOCTL_ATA_PASS_THROUGH)
                    n_util::change_ioc(ioc, irp, my_ata_pass_ioc);

            else if (code == SMART_RCV_DRIVE_DATA)
                    n_util::change_ioc(ioc, irp, my_smart_data_ioc);

            return g_original_disk_control(device, irp);
    }







    可以看到,每一个消息后面的调用了一个change_ioc函数
    前两个参数一样,只是后面的一个参数不一样,最后一个参数就是不同消息的不同处理例程
    我们主要是要修改掉原始的完成例程为我们自己的完成例程
    我们先看看change_ioc具体实现吧
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    28

    29

    30

    // 这个函数主要是辅助hook操作的
    bool change_ioc(PIO_STACK_LOCATION ioc, PIRP irp, PIO_COMPLETION_ROUTINE routine)
    {
            // 先申请我们的一块内存空间用来保存我们后面需要的数据
            PIOC_REQUEST request = (PIOC_REQUEST)ExAllocatePool(NonPagedPool, sizeof(IOC_REQUEST));
            if (request == 0) return false;

            // 保存缓冲区,后面这个缓冲区里面的数据就是要返回用户层的数据,我们要修改掉
            request->Buffer = irp->AssociatedIrp.SystemBuffer;

            // 保存缓冲区的大小
            request->BufferLength = ioc->arameters.DeviceIoControl.OutputBufferLength;

            // 保存原始的irp上下文
            request->OldContext = ioc->Context;

            // 保存原始的完成例程函数
            request->OldRoutine = ioc->CompletionRoutine;

            // 修改控制位以达到我们想要的效果
            ioc->Control = SL_INVOKE_ON_SUCCESS;

            // 修改irp上下文为我们申请的内存
            ioc->Context = request;

            // 修改为我们自定义的完成例程函数
            ioc->CompletionRoutine = routine;

            return true;
    }






    Ok,我们来看看my_storage_query_ioc完成例程的实现代码
    结构体我们都知道了,直接开始解析就行,解析到序列号了就修改掉,然后再让它返回到用户层给那些应用程序
    其实另外那个消息的完成例程和这个代码差不多,也就是结构体不同而已
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    28

    29

    30

    31

    32

    // IOCTL_STORAGE_QUERY_PROPERTY消息的完成例程处理
    NTSTATUS my_storage_query_ioc(PDEVICE_OBJECT device, PIRP irp, PVOID context)
    {
            // 上下文,其实这个就是change_ioc函数里面申请的那个内存了
            if (context)
            {
                    // 拿到我们前面保存好的数据
                    n_util::IOC_REQUEST request = *(n_util:IOC_REQUEST)context;
                    ExFreePool(context);

                    // 判断数据是否是获取硬盘序列号的
                    if (request.BufferLength >= sizeof(STORAGE_DEVICE_DESCRIPTOR))
                    {
                            // 这里就是根据结构获取到序列号的偏移
                            PSTORAGE_DEVICE_DESCRIPTOR desc = (PSTORAGE_DEVICE_DESCRIPTOR)request.Buffer;
                            ULONG offset = desc->SerialNumberOffset;
                            
                            // 偏移有效的话,定位到地方后开始随机化序列号
                            if (offset && offset < request.BufferLength)
                            {
                                    char* serial = (char*)desc + offset;
                                    n_util::random_string(serial, 0);
                            }
                    }

                    // 调用原始的完成例程
                    if (request.OldRoutine && irp->StackCount > 1)
                            return request.OldRoutine(device, irp, request.OldContext);
            }

            return STATUS_SUCCESS;
    }






    我们再来看看random_string函数的实现吧,其实也是很简单的
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    // 随机化字符串
    char* random_string(char* str, int size)
    {
            // 取到字符串长度
            if (size == 0) size = (int)strlen(str);
            if (size == 0) return 0;

            const int len = 63;
            const char char_maps[len] = "QWERTYUIOPASDFGHJKLZXCVBNMzxcvbnmasdfghjklqwertyuiop0123456789";

            // 开始随机字符串
            unsigned long seed = KeQueryTimeIncrement();
            for (int i = 0; i < size; i++)
            {
                    unsigned long index = RtlRandomEx(&seed) % len;
                    str = char_maps[index];
            }

            return str;
    }






    my_smart_data_ioc函数的代码类似,只是结构体不同而已
    也就是序列号的位置不同而已,所以也是定位到后直接随机化字符串
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    // SMART_RCV_DRIVE_DATA消息的完成例程处理
    NTSTATUS my_smart_data_ioc(PDEVICE_OBJECT device, PIRP irp, PVOID context)
    {
            if (context)
            {
                    n_util::IOC_REQUEST request = *(n_util:IOC_REQUEST)context;
                    ExFreePool(context);

                    if (request.BufferLength >= sizeof(SENDCMDOUTPARAMS))
                    {
                            char* serial = ((PIDSECTOR)((PSENDCMDOUTPARAMS)request.Buffer)->bBuffer)->sSerialNumber;
                            n_util::random_string(serial, 0);
                    }

                    if (request.OldRoutine && irp->StackCount > 1)
                            return request.OldRoutine(device, irp, request.OldContext);
            }

            return STATUS_SUCCESS;
    }






    简单吧?那除了修改派遣函数的方法之外,那还有没有其它方法呢?当然有了
    那就是禁用硬盘的smart,那么什么是smart呢?
    以下就是百度百科的解释 :
    S.M.A.R.T.,全称为“Self-Monitoring Analysis and Reporting Technology”,即“自我监测、分析及报告技术”。是一种自动的硬盘状态检测与预警系统和规范。通过在硬盘硬件内的检测指令对硬盘的硬件如磁头、盘片、马达、电路的运行情况进行监控、记录并与厂商所设定的预设安全值进行比较,若监控情况将或已超出预设安全值的安全范围,就可以通过主机的监控硬件或软件自动向用户做出警告并进行轻微的自动修复,以提前保障硬盘数据的安全。除一些出厂时间极早的硬盘外,现在大部分硬盘均配备该项技术。

    我们可以看一下图片 :



    禁用硬盘smart效果 :



    我们可以看到,这里居然也有硬盘的序列号,这里我们也要修改哈!

    所以我们要在内核把这个smart给禁用掉
    那怎么干呢?
    在disk.sys里面有一个微软官方没有发布的函数,叫做DiskEnableDisableFailurePrediction
    通过这个函数可以对指定的硬盘设备禁用smart,这简直太棒了不是?
    但是这个函数没有发布,所以不同的Win10上面的,这个函数在不同的位置,这就需要我们根据sig去找了
    我先教大家怎么找到自己系统里面的sig吧


    首先我们要查找到disk.sys驱动程序,因为是系统原本的驱动,所以一般放在C:\Windows\System32\drivers里面
    找到它后,就把它复制一份到桌面来进行操作


    接下来我们把disk.sys拖进64位的IDA里面进行分析,查找到DiskEnableDisableFailurePrediction函数的地方



    我们查找到这个函数了,那么怎么提权sig呢?
    我不知道你们大佬是怎么提取了,我其实用比较笨的方法提取的
    我再把disk.sys拖进CFF Explorer_CN程序里面



    我们先要再IDA里面得到虚拟地址,我这里就是0x1C000D990



    然后在CFF Explorer_CN程序里计算得到文件偏移地址,我这里就是0x0000B590


    得到反汇编代码和机器码,主要是反汇编和机器码对应,好计算得到sig



    好,我们可以尝试提取48 89 5c 24 10 48 89 74 24 18 57 48 81 ec 90 00 00 00当sig,去验证一下
    查找出一个地址而已,而且地址正确,那我们就可以用这个sig





    至此,我们准备工作完成了,可以开始上代码了
    [C++] [color=rgb(51, 102, 153) !important]纯文本查看 [color=rgb(51, 102, 153) !important]复制代码
    [backcolor=rgb(27, 36, 38) !important][color=white !important]
    [color=white !important]?

    01

    02

    03

    04

    05

    06

    07

    08

    09

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    28

    29

    30

    31

    32

    33

    34

    35

    36

    37

    38

    39

    40

    41

    42

    43

    44

    45

    46

    47

    48

    49

    50

    51

    52

    53

    54

    // 禁用全部硬盘的smart
    bool disable_smart()
    {
            // 先取到disk.sys的地址和大小
            DWORD64 address = 0;
            DWORD32 size = 0;
            if (n_util::get_module_base_address("disk.sys", address, size) == false) return false;
            n_log::printf("disk address : %llx \t size : %x \n", address, size);

            // 根据sig查找DiskEnableDisableFailurePrediction函数的地址
            DiskEnableDisableFailurePrediction func = (DiskEnableDisableFailurePrediction)n_util::find_pattern_image(address,
                    "\x48\x89\x5c\x24\x00\x48\x89\x74\x24\x00\x57\x48\x81\xec\x00\x00\x00\x00\x48\x8b\x05\x00\x00\x00\x00\x48\x33\xc4\x48\x89\x84\x24\x00\x00\x00\x00\x48\x8b\x59\x60\x48\x8b\xf1\x40\x8a\xfa\x8b\x4b\x10",
                    "xxxx?xxxx?xxxx????xxx????xxxxxxx????xxxxxxxxxxxxx"); //DiskEnableDisableFailurePrediction
            if (func == 0) return false;
            n_log::printf("DiskEnableDisableFailurePrediction address : %llx \n", func);

            // 初始化设备名称
            UNICODE_STRING driver_disk;
            RtlInitUnicodeString(&driver_disk, L"\\Driver\\Disk");

            // 根据名称获取object
            PDRIVER_OBJECT driver_object = nullptr;
            NTSTATUS status = ObReferenceObjectByName(&driver_disk, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, nullptr, 0, *IoDriverObjectType, KernelMode, nullptr, reinterpret_cast<VOID*>(&driver_object));
            if (!NT_SUCCESS(status)) return false;
            n_log::printf("disk object address : %llx \n", driver_object);

            // 保存硬盘设备的缓冲区,这里设置100是比较安全的了,难道一台电脑能装100多个硬盘??
            PDEVICE_OBJECT device_object_list[100]{ 0 };
            RtlZeroMemory(device_object_list, sizeof(device_object_list));

            // 获取电脑的全部硬盘设备
            ULONG number_of_device_objects = 0;
            status = IoEnumerateDeviceObjectList(driver_object, device_object_list, sizeof(device_object_list), &number_of_device_objects);
            if (!NT_SUCCESS(status))
            {
                    ObDereferenceObject(driver_object);
                    return false;
            }
            n_log::printf("number of device objects is : %d \n", number_of_device_objects);

            // 对每一个硬盘设备调用DiskEnableDisableFailurePrediction函数,以禁用smart
            for (ULONG i = 0; i < number_of_device_objects; ++i)
            {
                    PDEVICE_OBJECT device_object = device_object_list;
                    status = func(device_object->DeviceExtension, false);
                    if (NT_SUCCESS(status)) n_log::printf("DiskEnableDisableFailurePrediction success \n");

                    ObDereferenceObject(device_object);
            }

            ObDereferenceObject(driver_object);

            return true;
    }






    后面继续说

    ---------------------------------------------------------------------------------------我是一条无情的分割线


    CPU序列号篇 :
    这里说一下你们说的CPU序列号的问题
    我为什么处理CPU的序列号呢?因为CPU的序列号是读取不到的!!!

    我想,大家已经弄错了一个概念,CPUID 和 CPU序列号!
    简单的CPUID是由Type,Model level,Family level和Stepping level组成的
    想详细了解的话,请去www.intel.com
    所以说CPUID是有重复的!!!你们可以去https://bbs.csdn.net/topics/280064435看看
    所以说,反作弊厂家都获取不到CPU的序列号!!!!



    ------------------------------------------------------------------------------------------我是一条无情的分割线


    既然那么多小伙伴想要成品,我就写了一个小demo
    前面说了像兼容那么多Win10版本是很麻烦的一件事
    所以我只针对我当前的系统版本写了一个
    我的版本是Win10(1909),其它版本的Win10很大可能会蓝屏,Win7就不用想了!!!!!!!!!!!!!
    所以Win10(1909)的小伙伴可以尝试一下哈

    既然你们这么想要成品,那我就写一个兼容性强一点的成品出来哈
    今天 2021.2.7 完成一部分功能后就开始放出来 连代码也放出来给你们研究一下哈



    查毒链接 : https://habo.qq.com/file/showdetail?pk=ADcGY11oB2EIOls%2FU2c%3D


    下载连接 : https://wwx.lanzoui.com/isx2Flbb20f


    ------------------------------------------------------------------------------------------------------------------我是一条无情的分割线
    2021.2.9
    这个帖子缓缓再更新,因为又有其它任务了哈,你们不是想要成品嘛?我在另外一个帖子发布了成品工具
    传送门 : https://www.52pojie.cn/thread-1368539-1-1.html
    喜欢的话可以去下载试试看哈
    产品图片



    回复

    使用道具 举报

    该用户从未签到

    280

    主题

    704

    帖子

    2221

    积分

    超凡大师

    Rank: 6Rank: 6

    精华
    0
    威望
    0
    听众
    0
    萝卜
    1517
    注册时间
    2020-2-9
    在线时间
    0 小时
    发表于 2021-2-9 17:41:49 | 显示全部楼层
    发发呆,回回帖,工作结束~
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    411

    帖子

    1310

    积分

    超凡大师

    Rank: 6Rank: 6

    精华
    0
    威望
    0
    听众
    0
    萝卜
    899
    注册时间
    2020-2-9
    在线时间
    0 小时
    发表于 2021-2-9 17:42:07 | 显示全部楼层
    不错,顶一个!
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    396

    帖子

    1181

    积分

    超凡大师

    Rank: 6Rank: 6

    精华
    0
    威望
    0
    听众
    0
    萝卜
    785
    注册时间
    2020-2-9
    在线时间
    0 小时
    发表于 2021-2-10 09:28:44 | 显示全部楼层
    向楼主学习
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    375

    帖子

    1121

    积分

    超凡大师

    Rank: 6Rank: 6

    精华
    0
    威望
    0
    听众
    0
    萝卜
    746
    注册时间
    2020-2-9
    在线时间
    0 小时
    发表于 2021-2-10 10:49:06 | 显示全部楼层
    发发呆,回回帖,工作结束~
    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    QQ|Archiver|手机版|小黑屋|帮闲电子商务LVTZ.COM |冀公网安备(冀ICP备17031353号-2)

    GMT+8, 2024-12-25 22:38 , Processed in 0.144487 second(s), 28 queries .

    Powered by Discuz! X3.4

    Copyright © 2001-2020, Tencent Cloud.

    快速回复 返回顶部 返回列表