ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信EnMicroMsg.db解密实战:从AES加密原理到聊天记录恢复

微信EnMicroMsg.db解密实战:从AES加密原理到聊天记录恢复 说实话折腾微信本地数据恢复这件事我的出发点特别实际朋友一台旧手机微信里存着和家里老人的大量聊天记录结果手机系统崩溃重启后微信闪退重装后聊天记录全空了。市面上找了一圈恢复软件要么收费高得离谱要么根本不认微信的加密库。最后没办法只能自己上手把微信本地数据库的解密和恢复流程完整跑了一遍。先说结论微信的聊天记录并不是以明文形式存在本地的而是存放在一个用 SQLCipher 加密的 SQLite 数据库文件里。在安卓设备上这个文件通常叫EnMicroMsg.db。只要这个底层数据库文件还在理论上就有机会通过解密把聊天记录拉回来。整个过程最核心的难点有两个一是理解 AES 加密的原理二是拿到正确的数据库密钥。这篇内容我会从加密原理开始讲再到密钥获取、解密操作、数据导出一条线全部走通。无论你是做手机数据恢复的从业者、搞移动端取证的安全工程师还是单纯想弄明白 AES 这类对称加密在实际 App 里是怎么落地的开发者这篇文章的思路和命令都可以直接参考。每一步我会尽量把原理讲到也会标明哪些参数能改、哪些不能改避免踩坑。1. 先搞清楚 EnMicroMsg.db 是什么1.1 微信本地数据存储结构微信在安卓设备上的数据和程序文件是分开的。程序本体在/data/app里而用户数据包括聊天记录、联系人、账号信息、缓存图片都在/data/data/com.tencent.mm/这个私有目录下。这个目录默认只有微信自己才能读写普通用户哪怕用文件管理器也看不到需要 root 权限或者从系统备份里提取。在这个私有目录里MicroMsg子目录是核心里面会看到一个 32 位字符的随机哈希文件夹例如MicroMsg/a1b2c3d4e5f6.../。这个哈希文件夹是按微信账号生成的不同账号对应不同目录。咱们要找回的聊天记录数据库EnMicroMsg.db就在这个哈希目录里。这个数据库是 SQLite 格式但被 SQLCipher 加密过。数据库里主要存放的是结构化的聊天数据表名称作用message聊天消息记录的主表rcontact联系人信息好友、群成员chatroom群聊相关数据imessage部分版本的消息辅助表appmessage应用类消息小程序、链接等如果只是换手机想迁移记录直接拷贝 EnMicroMsg.db 是没用的。因为换了设备密钥对不上解密不出来。微信自己的“聊天记录迁移”功能也是要在两端同时登录同一个账号的情况下把包含解密密钥的会话上下文一起传过去才能让新手机打开这个数据库。1.2 文件还在为什么还要“解密”很多人会有疑问我的手机也没坏微信聊天记录明明还能看到为什么还要解密这里要区分“应用层读取”和“文件层读取”。微信 App 在启动时会从自己的配置文件中读取数据库密钥并在内存中完成 SQLCipher 的初始化然后通过 SQLite API 读取数据。这个过程是自动的用户感知不到。但一旦微信 App 本身损坏、版本不匹配、账号登录状态失效或者你需要绕过 App 直接从数据库文件里提取某些数据就必须自己完成“解密”这步。还有一个实际场景是微信已经卸载了但手机里还有之前备份出来的 EnMicroMsg.db 文件。很多人会尝试用 SQLite 工具直接打开结果发现文件打不开或者打开后是乱码。原因就是 SQLCipher 对整个数据库文件做了页级加密没有密钥就完全无法读取。所以解密是恢复数据的前置条件。注意本文所有操作都基于一个前提——你处理的是自己拥有所有权的设备或者是在合法授权范围内进行的数据取证。用这些技术去读取他人的聊天记录既不合规也没有技术价值。2. AES 加密原理微信本地数据库的这把锁2.1 AES 是什么它保护的是什么AESAdvanced Encryption Standard高级加密标准是目前全世界使用最广泛的对称加密算法。所谓对称加密就是加密和解密用的是同一把密钥。你把数据用密钥加密成密文对方拿到同一把密钥就能还原成明文。AES 本身是一个分组加密算法意思是它把原始数据切成固定大小的块每个块 128 位也就是 16 字节来加密。如果明文长度不是 16 字节的整数倍就需要填充。微信用的加密库是 SQLCipher底层走的是 AES-256 加密密钥长度是 256 位也就是 32 字节。这里有一个很多人容易混淆的点AES 加密“保护”的是什么对于数据库来说它不是把某一条聊天记录单独加密而是把整个数据库文件的每一个页都加密。SQLite 数据库在物理上是由一页一页组成的每页大小通常为 4096 字节4KB。如果数据库文件被加密那这些页的内容看起来就是一堆随机字节工具无法识别表结构也就无法直接读出数据。这就是为什么没有密钥时用任何十六进制编辑器打开 EnMicroMsg.db看到的都是乱码。2.2 密钥与 IV为什么同样的明文会得到不同的密文这是今天的重点之一对应很多人搜的“aes加密iv(初始向量)是什么”。AES 加密有几种工作模式最常用的是 ECB 和 CBC。SQLCipher 使用的是CBC 模式Cipher Block Chaining密码分组链接。ECB 模式有个明显的安全问题同样的明文块会生成同样的密文块这在数据库这种有大量重复数据的场景下很容易泄露信息。CBC 模式就是为了解决这个问题而出现的。CBC 的核心思想是在加密每一个明文块之前先把当前明文块和前一个密文块做异或运算然后再交给 AES 加密。第一个块没有“前一个密文块”怎么办就用一个初始向量 IV 来代替。IV也就是初始向量本质就是一个长度和 AES 分组大小相同的随机数或固定数据它作为第一个块的“前置密文”。用一个好理解的生活类比如果 AES 加密是一台“盖章机”密钥就是“章”的形状。ECB 模式是只要内容相同盖出来的章就一模一样看多了就能看出规律CBC 模式则是每盖一个章之前先往纸上撒一把盐IV 和前面密文的混合即使两张纸上的文字完全相同盖出来的效果也不一样这让偷窥者难以从密文中推断出任何规律。数据库加密里 IV 还有一个重要作用因为数据库的多个页内容可能是相同的比如空页如果不用 IV 或者 IV 固定那么密文也会相同攻击者就能知道这些页的明文确实相同从而推断出数据库的写入模式。SQLCipher 的一个设计细节就是每个数据库页都会使用不同的 IV确保相同的明文页也会产生不同的密文页。提示IV 不是秘密。在标准的 AES-CBC 加解密流程里IV 通常和密文一起存储或传输。真正需要保密的是密钥。如果你看到某个教程里把 IV 也当成“密码”来保护说明作者对加密原理的理解是有偏差的。2.3 微信用的不是“裸 AES”而是 SQLCipher微信之所以这么难恢复不是因为直接用 AES 把文件整体加密一遍而是使用了 SQLCipher 这个 SQLite 的加密扩展。SQLCipher 是 SQLite 的完整加密版本。它在 SQLite 原有功能的基础上增加了对数据库文件的透明加密。它的工作流程大致是初始化数据库时用户提供密码Passphrase。SQLCipher 使用 PBKDF2-HMAC-SHA1 或 PBKDF2-HMAC-SHA256 算法对这个密码进行多次迭代生成一个 256 位的加密密钥。每当 SQLite 需要向磁盘写入一个页时SQLCipher 会用这个密钥以 AES-256-CBC 模式加密这个页再写入磁盘。读取时则反向解密。每个页在 CBC 模式下使用不同的 IV而每个页的 IV 是根据页号等信息生成的确定性随机数这样既能保证安全性也能保证数据随机读写的可重复性。这样一套组合下来就得到了一个“看起来像是随机数据、但 SQLite 操作起来完全透明”的加密数据库。微信选 SQLCipher 而不是自己写一套加密逻辑原因也简单SQLite 是成熟稳定的嵌入式数据库SQLCipher 在保持 API 完全兼容的基础上加上加密能力改动小、性能可控而且密钥派生、页加密这些安全设计都有现成的经验可循。3. 密钥获取IMEI、UIN 与 MD5 计算3.1 解密的关键数据库密码从哪里来SQLCipher 加密的数据库必须用密码来解密。微信没有把这个密码直接告诉用户而是根据设备的硬件标识和用户账号信息动态生成。在老版本安卓微信里这个生成规则非常经典数据库密码 md5(IMEI UIN) 结果的 32 位十六进制字符串中取前 7 位小写。这个规则在微信 6.x 和 7.0 的很多版本里都验证有效。后来微信为了兼容没有 IMEI 的设备比如部分平板、模拟器或者 Android 10 之后系统限制应用读取 IMEI就调整了生成规则。所以实际动手前先确认一下自己拿到的是哪个版本的微信、哪个时期备份的数据库。这里有两部分输入IMEI国际移动设备识别码相当于手机的身份证号。在旧手机上可以拨号盘输入*#06#查看到。如果手机无法开机可以从包装盒、购机发票、或者系统备份信息里找。注意 IMEI 是 15 位数字有时候会出现双卡双待有两个 IMEI 的情况微信取的是主卡槽对应的那一个。UIN微信用户的唯一标识编号。看到这个词很多人会懵它不是微信号也不是手机号而是微信服务器内部分配给每个账号的一个整数 ID数值通常是负数比如-1234567890。这个值在微信的本地配置文件中可以直接找到。3.2 从 SharedPreferences 里提取 UIN老版本微信的账号信息保存在以下路径/data/data/com.tencent.mm/shared_prefs/system_config_prefs.xml /data/data/com.tencent.mm/shared_prefs/com.tencent.mm_preferences.xml用文本编辑器打开后在string标签或int字段里搜索uin或default_uin能看到类似这样的内容int namedefault_uin value-1234567890 /这里-1234567890就是你要找的 UIN。注意保留负号。有些教程里让你只取数字部分那是错的负数符号必须保留因为它是 MD5 输入字符串的一部分丢了就算不出正确密码。如果文件里找不到uin也可以在MicroMsg目录下的CompatibleInfo.cfg或systemInfo.cfg里找找看。但最常见、最稳定的位置还是system_config_prefs.xml。3.3 计算数据库密码的完整过程有了 IMEI 和 UIN接下来就是一步 MD5 计算。这里用一个示例说明过程实际值请替换成你自己设备上的信息假设备份文件中记录的 IMEI 为123456789012345UIN 为-1234567890。第一步拼接字符串123456789012345-1234567890注意中间没有任何分隔符就是 IMEI 加上 UIN 直接拼在一起。第二步对这个字符串做 MD5 哈希得到 32 位十六进制结果。比如a1b2c3d4e5f60718293a4b5c6d7e8f90第三步取前 7 位a1b2c3d这 7 位就是数据库密码。在 Linux 或 macOS 上一行命令就能算出来echo -n 123456789012345-1234567890 | md5sumWindows 上可以用 PowerShell[System.BitConverter]::ToString([System.Security.Cryptography.MD5]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes(123456789012345-1234567890))).Replace(-, ).ToLower().Substring(0, 7)得到前 7 位后记录备用。整个过程中的任何一位字符错了后面解密就会失败。这是我踩坑最多的环节尤其是 UIN 的负号还有 IMEI 和终端显示大小写不一致的问题都要仔细核对。注意如果你的微信版本较新直接使用上面规则发现密码不对问题大概率出在微信改了密钥生成规则。常见的新规则是使用md5(android_id uin)或者直接把 uin 拼接在mm字符串后。遇到这种情况先确认微信版本再去网上搜索对应版本的密钥生成逻辑思路是一样的只是输入参数变了。3.4 新版本微信的差异与应对思路现在很多人的微信已经是 8.0 甚至更高的版本而很多数据恢复教程还停留在 7.0 时代。新版微信在本地加密上做了几个调整不再依赖 IMEI改为使用 Android ID、设备序列号等系统标识。部分版本的 EnMicroMsg.db 文件名不再固定可能在哈希文件夹下出现多个类似EnMicroMsg.db的数据库文件。数据库密钥不一定能从 SharedPreferences 里直接找到有些版本把密钥放在本地 SQLite 数据库en_dev_info.db里需要先解密这个库才能拿到主库密码。如果是这种情况还有一个比较实用的笨办法先用微信登录一次账号让微信自动在设备上生成一份当前版本的数据目录。但要注意新登录生成的数据库和旧备份的数据库可能使用不同的加密参数不能直接套用。实测下来如果旧版本微信还没升级最好先用旧版本登录一次让微信把数据库迁移到新版本再备份这样数据库内容完整保留后续恢复难度也会低一些。4. 解密 EnMicroMsg.db 的完整实操流程4.1 第一步提取数据库文件解密的前提是先拿到 EnMicroMsg.db 文件。根据设备状态不同有三种常见路径路径一手机已 root用 MT管理器、ES 文件浏览器等工具直接进入/data/data/com.tencent.mm/MicroMsg/哈希文件夹/把 EnMicroMsg.db 复制出来。同时建议把同目录下的EnMicroMsg.db-journal、EnMicroMsg.db-wal、EnMicroMsg.db-shm一起复制出来。这几个文件是 SQLite 的日志和预写日志里面可能包含尚未合并到主库的最新数据。路径二手机未 root但有完整系统备份如果是通过手机厂商的备份工具、钛备份等生成的备份包可以先把备份包解包然后在apps/com.tencent.mm/或data/data/com.tencent.mm/路径下找 EnMicroMsg.db。市面上的手机助手类工具导出备份后目录结构基本类似可以直接用文件搜索功能定位。路径三手机无法开机但有闪存芯片读取条件这种属于硬件级数据恢复范畴需要拆机、用编程器或专门设备读取闪存镜像再从镜像文件中按文件系统结构提取数据库文件。这个操作对设备有损坏风险只建议找专业机构操作。经验之谈复制数据库文件前如果手机还能开机先不要使用微信最好开启飞行模式。这样能避免微信在后台写入数据覆盖掉一些未落盘的记录。复制完成后对原文件做一个 MD5 校验确保副本和源文件一致。4.2 第二步安装 sqlcipher 命令行工具解密 EnMicroMsg.db 最常用的工具是 SQLCipher 的命令行版本。在 Linux 或 macOS 上可以直接安装# macOS brew install sqlcipher # Ubuntu / Debian sudo apt-get install sqlcipherWindows 用户可以从 SQLCipher 官网下载预编译的二进制包或者使用数据库图形工具DB Browser for SQLite它内置了 SQLCipher 支持在打开数据库时可以直接输入密码。安装完成后先用sqlcipher --version确认安装成功。注意不同版本的 sqlcipher 默认加密参数可能不同后面可能会踩坑。4.3 第三步用命令行完成数据库解密这里我给出一个实测可用的完整命令。执行时把your_password替换成前面计算出的 7 位密码sqlcipher EnMicroMsg.db # 进入 sqlcipher 交互环境后依次执行 PRAGMA key your_password; PRAGMA cipher_migrate; .tables如果密码正确.tables会列出所有表名。这一步是验证密码是否正确的关键。如果密码错误会看到file is not a database或者SqliteManager Error: 26: file is not a database之类的报错。验证密码正确后执行导出命令把加密库转换成明文库ATTACH DATABASE decrypted.db AS plaintext KEY ; SELECT sqlcipher_export(plaintext); DETACH DATABASE plaintext;执行完成后当前目录下会生成一个decrypted.db这就是去掉了 SQLCipher 加密的普通 SQLite 数据库。可以用任意 SQLite 工具直接打开。整个过程也可以写成一行命令方便在脚本里调用sqlcipher EnMicroMsg.db PRAGMA keyyour_password; PRAGMA cipher_migrate; ATTACH DATABASE decrypted.db AS plaintext KEY ; SELECT sqlcipher_export(plaintext); DETACH DATABASE plaintext;这里有个非常关键的细节提示如果cipher_migrate执行失败或者解密后表能列出来但数据读不出来很可能是微信使用的 SQLCipher 版本和当前工具版本不一致。微信老版本使用的 SQLCipher 默认迭代参数是 64000 次 PBKDF2-HMAC-SHA1而新版本默认是 256000 次。遇到这种情况手动指定参数即可比如PRAGMA cipher_compatibility 3; PRAGMA kdf_iter 64000; PRAGMA cipher_hmac_algorithm HMAC_SHA1; PRAGMA cipher_kdf_algorithm PBKDF2_HMAC_SHA1;这几条命令要在PRAGMA key之前执行。参数配置对不上即使密码正确也可能无法读取。4.4 第四步用 Python 脚本自动化解密过程如果数据库文件很多或者需要批量处理用命令行交互式操作就太慢了。可以直接写 Python 脚本用pysqlcipher3模块完成解密。这里是一个完整的脚本示例import hashlib import os def generate_key(imei, uin): 根据 IMEI 和 UIN 生成微信数据库密码 raw f{imei}{uin} md5_str hashlib.md5(raw.encode()).hexdigest() return md5_str[:7] def decrypt_db(encrypted_path, output_path, password): 使用 sqlcipher 解密数据库 import sqlite3 # 使用 sqlcipher3 连接加密数据库需要先安装 pysqlcipher3 from pysqlcipher3 import dbapi2 as sqlcipher conn sqlcipher.connect(encrypted_path) try: conn.execute(fPRAGMA key{password}) conn.execute(PRAGMA cipher_migrate) # 导出明文库 conn.execute(fATTACH DATABASE {output_path} AS plaintext KEY ) conn.execute(SELECT sqlcipher_export(plaintext)) conn.execute(DETACH DATABASE plaintext) print(f解密成功已保存到 {output_path}) finally: conn.close() if __name__ __main__: # 替换成真实设备信息 IMEI 123456789012345 UIN -1234567890 password generate_key(IMEI, UIN) print(f计算出的数据库密码: {password}) decrypt_db( encrypted_pathEnMicroMsg.db, output_pathdecrypted.db, passwordpassword )如果你不想安装pysqlcipher3也可以直接用subprocess调用 sqlcipher 命令行工具本质上是把上面的命令封装成自动化流程效果是一样的。我个人的建议是优先用命令行工具因为它更稳定版本匹配问题也容易控制。4.5 第五步从解密后的库中导出聊天记录解密成普通 SQLite 数据库之后最常做的就是从message表里导出聊天记录。先看一下表结构SELECT sql FROM sqlite_master WHERE typetable AND namemessage;常见字段包括字段名含义msgId数据库内部自增 IDtype消息类型1 是文本、3 是图片、34 是语音、49 是文件/链接等isSend0 表示接收1 表示发送createTime消息时间戳毫秒talker对话对象标识content消息内容文本imgPath图片/文件的本地路径导出文本消息到 CSV用一行 SQL 就能完成SELECT datetime(createTime/1000, unixepoch, localtime) as 时间, case isSend when 0 then 接收 else 发送 end as 方向, case type when 1 then 文本 when 3 then 图片 when 34 then 语音 else 其他 end as 类型, talker as 对话对象, content as 内容 FROM message ORDER BY createTime;在 sqlite3 命令行工具里加上.mode csv和.output就能导出结果sqlite3 decrypted.db .headers on .mode csv .output messages.csv SELECT ... FROM message ORDER BY createTime; .output stdout如果想导出成 HTML 或者更友好的阅读格式也可以写个简单的 Python 脚本把内容读出来再拼成 HTML 文件。这一步不是必须的但能把最终结果以更直观的方式呈现出来后续查找、打印、分享也方便。4.6 关于图片和语音的补充说明解密数据库只是拿到了聊天记录的文本信息。微信的图片、语音等多媒体文件一般存放在/data/data/com.tencent.mm/MicroMsg/哈希文件夹/下的image2、voice2、video、sdcard等目录里文件名通常是.dat或.amr、.mp4等格式。特别是图片很多版本的微信会把图片文件做异或加密处理存成.dat格式的文件。社区里常说的“微信dat文件查看器”就是用来恢复这种.dat文件的。如果你需要恢复图片在数据库message表里查imgPath字段找到对应的文件路径再结合异或分析工具还原。但这一步耗时较长且成功率因版本而异优先保证文字记录完整才更实际。5. 常见问题与排查技巧5.1 解密过程中最常见的 5 个报错我把实操中遇到的高频报错整理成一张表方便排查报错信息原因解决方案file is not a database密码错误或者文件本身不是有效的 SQLCipher 数据库检查 IMEI、UIN 拼接是否正确尤其注意 UIN 的负号SqliteManager Error: 26SQLCipher 参数版本不匹配尝试在PRAGMA key前指定cipher_compatibility3或kdf_iter64000database disk image is malformed备份时数据库文件不完整或 WAL 文件未合并恢复时尽量连带-wal、-journal文件一起复制unsupported file encoding文件被二次加密或实际类型不对先查看文件头和文件大小确认是 SQLCipher 数据库而非普通图片no such table: message密码正确但解密出的库是空库或非聊天记录库可能是提取到了其他数据库文件检查路径是否在正确的哈希文件夹下5.2 数据库文件头损坏怎么修复有时候从备份里提出来的 EnMicroMsg.db 文件头几个字节被损坏用 sqlcipher 打开会直接报错。SQLCipher 数据库的文件头通常是一段随机数据但如果文件头完全丢失或损坏SQLCipher 会无法识别文件格式。如果你的文件头损坏并不是整段丢失而只是前 16 字节被覆盖还有机会修复。原理是 SQLCipher 的数据库文件头和普通 SQLite 一样也以 16 字节的SQLite format 3\000开头只是这个头在加密后看起来是乱的。但很好的一点是SQLite 的页大小信息存储在文件头的第 16 到 17 字节SQLCipher 加密后这些偏移位置是明文保留的。可以先用十六进制编辑器打开文件确认偏移位置是否有异常。修复思路参考用备份文件或 WAL 文件对比确认正常情况下的文件头格式。如果仅是前 16 字节被清空尝试手动补全为普通 SQLite 文件头再从备份库中提取盐值信息配合密码解密。如果文件头完全损坏且没有备份基本无法通过文件头修复来恢复只能考虑通过扫描未分配空间寻找数据页残留。这种修复本身运气成分不小不要抱太高期望。更好的做法是在备份完成后第一时间对数据库文件做哈希校验并保存在安全位置。这样即使副本损坏也还有原始文件可对照。5.3 关于“恢复”的边界与合法使用最后必须多说一句。微信本地数据解密是一门实打实的技术但它也是一把双刃剑。解密 EnMicroMsg.db 这条路本质上是在利用微信本地存储机制本身的设计缺口。如果拿着这套方法去读取别人的手机数据属于明确的越权行为我在实操中反复提醒自己保持边界。合理的适用场景包括自己手机数据损坏后的恢复。获得用户明确授权的取证工作。对自有设备进行安全研究、数据合规审计。另外解密后的数据库完全是明文状态里面包含大量个人隐私处理完数据后记得彻底删除临时明文文件不要留在硬盘里。5.4 关于 WAL 文件与已删除记录的恢复SQLite 在默认的 journal 模式之外还有一种 WALWrite-Ahead Logging预写日志模式。微信数据库部分版本启用了 WAL 模式这种情况下最新写入的数据可能还保存在EnMicroMsg.db-wal文件里没有合并到主库。如果你只拷贝了主库可能丢失最后几条消息。恢复 WAL 数据的方法很直接在解密时把-wal和-shm文件放在和主库相同的目录下再执行解密命令。SQLCipher 会自动识别并合并 WAL 里的内容。关于已删除记录的恢复要泼一盆冷水SQLite 在删除记录时默认不会立刻覆写数据页而是做标记。理论上删除时间不久、并且数据库所在闪存区域没有被新数据覆盖数据可能残留在数据库文件内部。但由于微信数据库频繁读写而且启用了 WAL 模式旧数据页被清理的概率很高。实际能找回已删除记录的概率很低不要被商家夸大的宣传误导。如果确实需要做深度恢复建议直接做闪存镜像级的数据恢复。写在最后的一点个人体会微信本地数据解密这件事聊到底能够坚持走通的人并不多因为每一步都有“放弃”的理由密码算不对、版本对不上、工具版本不兼容、文件头损坏……任何一个环节卡住都足以让人心态崩掉。但只要你理解了自己在做什么——知道 AES 是怎么回事知道 SQLCipher 怎么把密码变成密钥知道微信把这些组件怎么组合起来的你就会发现今天折腾的这套流程明天遇到另一个 App、另一种加密数据库思路依然是通的。技术从来不只是某个软件的操作方法而是一套通用的思维方式。最后再分享一个小技巧如果你只是临时想找回某段聊天记录与其大费周章解密整库不如先在微信 App 里试试“聊天记录迁移与备份”功能。如果微信还能正常打开直接迁移到另一台手机往往是最稳妥的方案。数据库明文导出这条路更多是微信不可用之后的“终极手段”但掌握它绝对能在关键时刻帮你翻盘。
返回列表