ARTICLE DETAIL

资讯详情

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

SQL Server安全加固:禁用sa账户的完整操作指南与避坑要点

SQL Server安全加固:禁用sa账户的完整操作指南与避坑要点 前几天帮一个客户处理数据库服务器频繁告警打开SQL Server错误日志一看几千条登录失败记录登录名清一色都是sa。这些年只要服务器开了外网访问或者内网里有主机扫描SQL Server的sa弱口令爆破几乎是每天都会遇到的常规动作。其实应对这种攻击的手段非常多但最省心、最有效的第一道闸门恰恰是最多人忽略的一个操作直接禁用掉sa账户。这话听起来简单实际操作里涉及的细节、坑和前置条件非常多。我见过不少同事上来就执行ALTER LOGIN [sa] DISABLE;结果发现SQL Agent作业跑不了了数据库owner变成无效账户更尴尬的是Windows账号居然没有系统管理员权限最后只能费劲地用单用户模式把sa启回来。这篇文章就把这些前置检查、操作步骤、连带加固和应急恢复整个链路完整写一遍照着做基本不会翻车。1. sa账户为什么是SQL Server的第一攻击目标1.1 sa账户的本质绕过Windows认证的超级管理员入口要理解为什么所有人盯着sa不放先要清楚这个账户在SQL Server里的定位。sa是SQL Server安装时默认创建的超级管理员登录名属于sysadmin固定服务器角色这意味着只要拿到sa的密码就相当于拿到了整个数据库实例的完全控制权读所有数据库、执行系统存储过程、修改服务器配置、调用xp_cmdshell甚至访问操作系统命令全都畅通无阻。更关键的是sa走的是SQL Server身份验证不是Windows身份验证。Windows身份验证要求用户先通过操作系统的域账户或本地账户认证攻击者就算想撞库也还得先摸清Windows账户体系。而SQL Server身份验证完全独立只要知道登录名和密码从任何一台能通过网络连接到1433端口的机器上都能直接发起登录尝试不需要操作系统层面的任何权限。这等于攻击者少过一扇门只需要撞开一个密码就行。而且sa这个登录名在99%的实例上都是存在的名字固定不变连枚举用户名这一步都省了。攻击者拿到一个开放了1433端口的IP第一反应就是用sa加一个弱密码字典开始爆破密码正确率高不高取决于运气但尝试成本极低。这就是sa成为第一攻击目标的核心原因——它是一个固定存在、固定名字、权限极大、走纯口令认证的超级入口。1.2 爆破sa的典型攻击路径与观测特征爆破sa的流量特征其实非常明显。以我处理过的案例来说最常见的路径是攻击者先扫描全网段开放TCP 1433端口的主机然后针对每一台存活主机尝试登录名sa和一批弱密码。密码字典里基本都包含admin、123456、sa、sa123、password、!QAZ2wsx之类的组合如果服务器密码设得简单运气成分占很大比重地就会被打穿。在SQL Server这边每次登录失败都会写入错误日志错误号是18456。用这条语句就能快速查看最近登录失败的情况EXEC xp_readerrorlog 0, 1, NLogin failed, Nsa, N08/01/2025, N09/01/2025;如果环境里开了默认审计也可以用sys.dm_exec_sessions看有没有异常活动但更直接的是看错误日志里有没有大量来自同一IP的Login failed for user sa记录。真实被爆破的实例错误日志往往是几千行、几万行连续刷屏时间间隔都在几秒以内这种节奏不可能是人为输错密码造成的。还有一个容易忽略的观察点SQL Server默认开启了sa登录但很多系统安装完以后从来没有为sa设置过强密码甚至有的直接在连接字符串里硬编码了sa的弱密码。这相当于把超级管理员的钥匙挂在门口爆破只是时间问题。前面提到的客户就是这样检查密码之后发现竟然是sa123这种状态下不被打穿反而奇怪。1.3 禁用sa是先堵门而非终极方案这里要澄清一个观点禁用sa只能堵住一个固定的登录入口它解决的是针对固定超级账号的爆破风险并不能替代密码策略、网络隔离、审计、最小权限这些纵深防御措施。但为什么大家还是把这个操作放在安全加固清单的最前面因为它的投入产出比极高。一条T-SQL命令执行完攻击者针对这个实例的sa爆破路径就完全失效了即使密码字典里正好有这个密码也会因为账户被禁用而登录失败。这个操作不依赖补丁版本、不增加硬件成本、不需要改动应用代码前提是应用原本就没用sa是安全加固里少有的一行命令买断一个攻击面的动作。所以门要堵但堵完不等于完事后续的其他加固同样不能省。2. 动手禁用前先把这些后路留好2.1 先确认Windows管理员的sysadmin角色还在这是整个操作里最容易翻车的一步。禁用sa必须保证另一个具备sysadmin权限的登录名还能正常工作否则一旦sa被禁用又没有一个可用的Windows账号能进来就只能通过单用户模式或DAC专用管理员连接去恢复了非常被动。建议在禁用前先执行下面这条语句列出实例上所有具备sysadmin角色的服务器登录名SELECT sp.name AS login_name, sp.type_desc, sp.is_disabled FROM sys.server_role_members rm INNER JOIN sys.server_principals AS sp ON rm.member_principal_id sp.principal_id WHERE rm.role_principal_id ( SELECT principal_id FROM sys.server_principals WHERE name sysadmin );注意检查两点一是确认存在至少一个Windows登录比如[DOMAIN\admin]或本机的[MACHINE\Administrator]拥有sysadmin角色二是这个Windows登录的is_disabled为0。很多生产服务器安装时只用了默认配置Windows管理员组里确实有映射但如果在域环境里这台机器的管理员账号被禁用或者组策略限制交互式登录也会导致后续进不去。所以一定先在SSMS里断开所有SQL账号连接只保留Windows身份验证连接试一次确认能正常打开实例再往下走。如果查询结果里只有一个sa那就需要先用Windows身份验证登录并手动创建一个新的sysadmin登录CREATE LOGIN [DOMAIN\admin_user] FROM WINDOWS; EXEC sp_addsrvrolemember DOMAIN\admin_user, sysadmin;创建完再重新检查一遍确认新登录可用再做后续的禁用操作。2.2 为应用系统创建专用账号并改好连接字符串禁用sa前最大的现实问题不是操作本身而是应用系统。老系统最喜欢干的事就是把连接字符串写成User IDsa;Password...一旦sa被禁用业务系统立刻大面积报错。所以禁用前最好的做法是先创建一个应用专用账号赋予它业务所需的最小权限然后把连接字符串切换过来。创建应用账号的标准流程是服务器级别创建登录名数据库级别创建用户名再分配角色。以ERP系统账号为例USE [master]; GO CREATE LOGIN [app_erp] WITH PASSWORD N这里放强密码, CHECK_POLICY ON, CHECK_EXPIRATION ON, DEFAULT_DATABASE [YourDB]; GO USE [YourDB]; GO CREATE USER [app_erp] FOR LOGIN [app_erp]; GO ALTER ROLE db_datareader ADD MEMBER [app_erp]; ALTER ROLE db_datawriter ADD MEMBER [app_erp]; GO连接字符串的调整也要同步进行。原来可能是Server10.0.0.5,1433;DatabaseYourDB;User Idsa;Passwordold_password;切换后是Server10.0.0.5,1433;DatabaseYourDB;User Idapp_erp;Password新的强密码;EncryptTrue;这里有一个细节很多人会忽略CHECK_POLICYON意味着这个账号受Windows密码策略约束有密码复杂度要求和过期时间。如果应用账号是给程序用的最好按行业惯例设置CHECK_EXPIRATIONON的同时在密码里做好轮换计划否则密码过期当天应用就会连不上。当然如果不想处理密码过期可以把CHECK_EXPIRATION设为OFF但密码复杂度建议保留不要为了省事把两个策略都关掉。2.3 排查作业、链接服务器、维护计划里的sa引用这一步容易被当成多余但实际操作中坑最多的就是这里。SQL Server Agent里的作业步骤、维护计划、链接服务器、SSIS包都可能写死了sa账号。先查Agent作业里的命令文本USE msdb; GO SELECT j.name AS job_name, s.step_name, s.command FROM dbo.sysjobs j INNER JOIN dbo.sysjobsteps s ON j.job_id s.job_id WHERE s.command LIKE %sa%;查询结果里凡是命令里有sa字样的步骤都要人工确认。常见的是sqlcmd -U sa -P ...、EXEC xp_cmdshell sqlcmd -U sa ...这些命令在sa被禁用后全部会失败而且运行结果往往是作业失败告警排查起来比较隐蔽。链接服务器也要查SELECT srv.name AS linked_server_name, ll.local_principal_id, sp.name AS local_login_name FROM sys.servers srv LEFT JOIN sys.linked_logins ll ON srv.server_id ll.server_id LEFT JOIN sys.server_principals sp ON ll.local_principal_id sp.principal_id WHERE sp.name sa;有sa引用的话右键链接服务器属性把使用此安全上下文建立连接里的本地登录名改成新建的应用账号注意远端登录名和密码也要同步改成远端实例能识别的凭据。如果有维护计划指向sa同样要一一改掉。这些前置工作做完才算是真正清空了sa的依赖禁用之后不会有业务反扑。3. 禁用sa账户的几种方式与操作细节3.1 图形界面方式SSMS里的三步操作SSMS操作是最直观的方式适合单实例、不频繁变更的环境。流程是连接到实例后在左侧对象资源管理器中展开安全性→登录名找到sa右键选择属性在常规页面确认登录名是sa然后切到状态页面把登录这一项从已启用改成已禁用点击确定即可。这个操作执行完sa登录名会被标记为禁用后续任何使用sa登录的连接请求都会收到18456错误。图形界面的好处是所见即所得不容易输错命令但缺点是如果一次要管几十台服务器一台台点下来效率太低而且很容易漏掉某台。所以在我自己的运维习惯里图形界面只用来做单台确认批处理统一使用T-SQL。另外需要注意图形界面方式禁用sa之后一定要到对象资源管理器里刷新一下登录名的状态确认sa前面的图标变成了一个向下的箭头这才是禁用的可视标志。3.2 T-SQL方式一行命令禁用注意执行上下文T-SQL方式推荐在生产环境推广因为可重复、可审计、可批量执行。禁用sa的语句非常简单ALTER LOGIN [sa] DISABLE;执行完后通过下面这条语句验证状态SELECT name, is_disabled, type_desc FROM sys.sql_logins WHERE name sa;结果里is_disabled为1表示禁用成功。有一点必须提醒执行ALTER LOGIN [sa] DISABLE时需要有ALTER ANY LOGIN权限通常sysadmin或者securityadmin角色成员才能执行。另外这条语句在执行时不会影响当前的sa会话也就是说如果你正用sa登录着执行完这条命令后当前会话还是能继续用只是新的sa连接会被拒绝。这个行为在安全应急的时候有用可以在不中断现有连接的情况下快速封堵新的登录但也要注意如果当前会话是sa开的后续操作要赶紧切到其他管理员账号避免操作到一半被审计发现没有可用管理员。如果是批量管理多台服务器可以用Invoke-Sqlcmd配合脚本循环执行$servers (SERVER01, SERVER02, SERVER03) foreach ($server in $servers) { Invoke-Sqlcmd -ServerInstance $server -Query ALTER LOGIN [sa] DISABLE; Invoke-Sqlcmd -ServerInstance $server -Query SELECT name, is_disabled FROM sys.sql_logins WHERE name sa; }批量执行时脚本里务必加上日志记录明确哪些实例执行成功、哪些失败避免漏掉某一台。3.3 更彻底的方式切换为Windows身份验证模式如果说禁用sa是关掉一个账号改成Windows身份验证模式就是把SQL Server身份验证这条链路整个关掉。在这个模式下所有纯SQL账号登录请求都会失败包括sa、包括新建的SQL登录名只剩Windows认证路径可用。切换方式有两种。图形界面在服务器属性→安全性→服务器身份验证里把SQL Server和Windows身份验证模式改成Windows身份验证模式改完需要重启SQL Server服务生效。注册表方式是这样USE [master]; GO EXEC xp_instance_regwrite NHKEY_LOCAL_MACHINE, NSoftware\Microsoft\MSSQLServer\MSSQLServer, NLoginMode, REG_DWORD, 1; GO注册表LoginMode的值1表示Windows身份验证模式2表示混合模式。改完必须重启SQL Server服务才能生效Restart-Service -Name MSSQLSERVER -Force这种方式的优点是一劳永逸连爆破的入口都直接消失非常推荐在纯内网、Windows域环境、且应用全部使用Windows身份验证的场合用。缺点也很明显一旦有应用或工具必须使用SQL账号登录这个模式会直接导致连接失败落地时对环境的判断要求很高。我的建议是如果你的组织已经有AD域、应用的连接字符串里都用了集成安全Integrated SecurityTrue优先级最高的方案是Windows身份验证模式加禁用sa双管齐下如果还有一堆老旧系统必须要SQL账号就先禁用sa、保留混合模式等应用逐渐改造完再切。3.4 验证禁用效果与常见误判禁用或者切换模式后验证不是简单用sa连一次就行要做三层检查。第一层状态检查。执行前面提到的sys.sql_logins查询确认sa的is_disabled 1。第二层实际连接测试。新建一个查询窗口使用SQL Server身份验证输入sa和原密码连接预期结果是收到如下错误用户 sa 登录失败。原因: 基于帐户的登录已禁用。看到这个错误才说明禁用在网络层和控制层都生效了。第三层检查错误日志确认攻击路径已经中断。用EXEC xp_readerrorlog 0, 1, NLogin failed, Nsa;如果错误日志里还在持续出现大量sa登录失败记录这些记录现在都应该是已禁用的提示而不是密码错误。密码错误说明攻击者在尝试撞库禁用状态说明即使密码对了也进不来这是两个完全不同的安全级别。看到日志里的提示从密码不匹配变成已禁用就可以放心了。有一点容易误判的是切换Windows身份验证模式后刚才说的xp_readerrorlog里仍然可能出现很多sa登录失败记录这并不是sa被启用了而是SQL Server在网络层接收到SQL账号登录请求后发现服务器处于Windows身份验证模式于是拒绝该请求并记录日志。看到读日志有大量记录不用慌先确认当前身份验证模式是还是不是预期状态。4. 禁用sa之后的连带加固这几项建议一起做掉4.1 修改默认1433端口并启用防火墙策略禁用sa相当于堵住了登录口的钥匙孔但门牌号没换攻击者照样能找到这扇门。默认情况下SQL Server监听1433端口全网扫描器最喜欢扫这个端口。把端口改掉能显著降低被扫描命中的概率。在SQL Server配置管理器里改端口的路径是SQL Server网络配置→MSSQLSERVER的协议→右键TCP/IP→属性→IP地址→拉到最底部IPAll→TCP端口改为自定义端口比如14333。改完后需要重启SQL Server服务。如果是命令行方式可以用PowerShell改注册表$port 14333 Set-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\MSSQL15.MSSQLSERVER\MSSQLServer\SuperSocketNetLib\Tcp\IPAll -Name TcpPort -Value $port Restart-Service -Name MSSQLSERVER注意注册表路径中的MSSQL15.MSSQLSERVER对应SQL Server 2019不同版本的实例名不一样如果是命名实例或者更新版本路径要相应调整。改端口的副作用是所有应用连接字符串里的端口都要同步修改否则应用连不上。如果应用比较多建议先在测试环境把连接字符串配置成统一读取配置中心的方式降低后续变更成本。防火墙层面也要同步放行新端口默认1433在绝大多数企业防火墙策略里都是重点关注对象换成一个不常见的端口可以减少非常多的扫描流量。4.2 开启登录审计让误连有据可查禁用sa之后很多运维会误以为登录安全问题就结束了其实这时正是观察攻击行为的好时机。默认SQL Server只把登录失败事件记到错误日志里信息非常粗粒度。更规范的做法是启用SQL Server Audit专门记录失败登录的源IP和攻击频率。创建审计的方式如下USE [master]; GO CREATE SERVER AUDIT [Audit_FailedLogin] TO FILE (FILEPATH ND:\SQLAudit\, MAXSIZE 100 MB) WITH (ON_FAILURE CONTINUE); GO CREATE SERVER AUDIT SPECIFICATION [AuditSpec_FailedLogin] FOR SERVER AUDIT [Audit_FailedLogin] ADD (FAILED_LOGIN_GROUP); GO ALTER SERVER AUDIT SPECIFICATION [AuditSpec_FailedLogin] WITH (STATE ON); GO ALTER SERVER AUDIT [Audit_FailedLogin] WITH (STATE ON); GO之后可以在审计日志里看到每次登录失败的客户端IP。结合Windows防火墙日志或安全设备SIEM就能把爆破源IP揪出来加黑名单封禁。这一步在纯靠数据库层面保护的时代可能有点重但今天的安全管理体系下登录审计已经是基线要求了建议宁可提前配置也不要等出了事再来补。4.3 强化密码策略与锁定阈值这里有一个经常被忽略的细节ALTER LOGIN [sa] DISABLE只禁用账户并不会修改sa的密码。如果哪天因为应急需要重新启用sa它使用的还是之前那个弱密码风险依然存在。所以在最终禁用之前最稳妥的做法是先把sa密码改成一个高强度的随机密码再执行禁用。这样即使未来被重新启用攻击者也猜不到原密码。改密码并启用策略ALTER LOGIN [sa] WITH PASSWORD N这里是高强度随机密码, CHECK_POLICY ON, CHECK_EXPIRATION OFF; GO ALTER LOGIN [sa] DISABLE; GO密码长度建议20位以上包含大小写字母、数字、特殊字符。之前我见过有人改完密码之后把这段脚本直接放在共享文档里这跟没改区别不大密码一定要放到企业密码管理器里做权限控制。关于账户锁定阈值SQL Server本身没有独立的锁定时长设置它依赖Windows组策略里的账户锁定策略。如果服务器在域环境里SQL账号的密码策略由域策略统一控制但自动锁定需要针对SQL Server账号单独确认。非域环境或独立服务器上默认的锁定阈值选项往往是不锁定这种状态下即使被爆破也不会计数。建议在本地安全策略里设置账户锁定阈值为5次重置账户锁定计数器为30分钟这样即使sa被启用也能触发锁定保护。注意这个策略对SQL Server登录名的生效条件是该登录启用了CHECK_POLICY ON所以前面那条ALTER LOGIN语句里这个参数必须保留。4.4 清理数据库owner和固定服务器角色的多余成员禁用sa后很多数据库的owner如果还指向sa日常运维里会遇到各种奇怪的问题比如数据库属性打不开、某些功能不可用、ALTER AUTHORIZATION操作报错。更关键的是如果owner是sa它在数据库里的权限映射并不会因为sa被禁用而消失只是在需要以owner身份执行上下文时会出现异常。稳妥的做法是提前把数据库owner迁移到应用账号或其他管理账号USE [YourDB]; GO EXEC sp_changedbowner app_erp; GO或者使用ALTER AUTHORIZATION的方式ALTER AUTHORIZATION ON DATABASE::[YourDB] TO [app_erp]; GO这一条操作对每个数据库都要执行一遍如果数据库多可以写个动态SQL循环处理USE [master]; GO DECLARE dbname sysname; DECLARE db_cursor CURSOR FOR SELECT name FROM sys.databases WHERE state 0 AND name NOT IN (master, tempdb, model, msdb); OPEN db_cursor; FETCH NEXT FROM db_cursor INTO dbname; WHILE FETCH_STATUS 0 BEGIN DECLARE sql nvarchar(max) ALTER AUTHORIZATION ON DATABASE::[ dbname ] TO [app_erp];; EXEC sp_executesql sql; FETCH NEXT FROM db_cursor INTO dbname; END CLOSE db_cursor; DEALLOCATE db_cursor;固定服务器角色的成员也要做一次清查。重点检查sysadmin、securityadmin、serveradmin、setupadmin这几个高权限角色里有没有不认识的登录名把多余的删除。SELECT r.name AS role_name, m.name AS member_name FROM sys.server_role_members rm INNER JOIN sys.server_principals r ON rm.role_principal_id r.principal_id INNER JOIN sys.server_principals m ON rm.member_principal_id m.principal_id WHERE r.name IN (sysadmin, securityadmin, serveradmin, setupadmin) ORDER BY r.name;原则上生产实例的sysadmin成员越少越好每多一个成员就是多一个被攻击的入口。5. 禁用sa后运维中真实会遇到的问题5.1 应用连不上的排查顺序禁用sa后最常见的告警就是应用突然报无法登录尤其在那些没有提前切换应用账号的环境里。如果遇到这种情况先不要慌按照下面这个顺序排查能把定位时间压到最短。第一确认连接字符串用的是不是sa。很多应用运维根本不知道代码里写的什么账号需要开发配合查配置。这一步占80%的问题原因。第二确认是不是数据库实例名或端口写错了。如果应用连的是命名实例禁用sa这个动作本身不会影响实例名但如果你顺手改了端口连接字符串里的端口没同步更新也会报错而且报错信息和账号被禁用的信息长得一摸一样。第三确认错误日志信息。用EXEC xp_readerrorlog 0, 1, NLogin failed, N;看最新几个条目里的登录名是什么。如果显示Login failed for user sa就是应用还在用sa如果显示Login failed for user app_erp那是应用账号密码或权限问题再去查应用账号的状态和映射。5.2 临时需要sa时的安全启用流程有些老旧系统短期改不掉或者某个安全评估场景需要临时开启sa排查问题。上线临时启用时需要遵守一个原则能不开就不开必须开就开最短时间用完立刻关。安全启用流程建议按这个步骤先在密码管理器里领取一个一次性的高强度sa密码。执行启用ALTER LOGIN [sa] WITH PASSWORD N一次性高强度密码; ALTER LOGIN [sa] ENABLE;作业、脚本或人工操作完成立刻重新禁用ALTER LOGIN [sa] DISABLE;刷新密码管理器里的一次性密码防止泄漏后被继续使用。我见过有人临时启用之后忘了禁用结果下个月安全检查发现sa又是启用状态而且密码还是一个月前那个神秘密码。强烈建议在SQL Agent里建一个哨兵作业每天自动检查sa的is_disabled状态如果发现被启用就告警有条件甚至可以写作业自动重新禁用IF EXISTS ( SELECT 1 FROM sys.sql_logins WHERE name sa AND is_disabled 0 ) BEGIN ALTER LOGIN [sa] DISABLE; RAISERROR(sa account was enabled and has been auto-disabled, 16, 1); END这个作业一天跑几次能有效防止人为疏漏。5.3 别忽视其他超级权限账号禁用sa后有一类账号特别容易被漏掉那些名字不起眼、但实际拥有sysadmin权限的应用账号或历史遗留账号。比如曾经为了某个集成需求创建的通用SQL账号密码早就写在N个人电脑的txt文件里再比如从SQL Server 2000时代迁移过来的老账号权限一直没有清理。这些账号的权限与sa等价攻击者一旦通过它们打进内部禁用sa的成果就形同虚设。建议定期做一次账号权限全量审计输出所有登录名、权限角色、启用状态对照业务确认每个账号的负责人和使用场景。SELECT sp.name AS login_name, sp.type_desc, sp.is_disabled, r.name AS server_role FROM sys.server_principals sp LEFT JOIN sys.server_role_members rm ON sp.principal_id rm.member_principal_id LEFT JOIN sys.server_principals r ON rm.role_principal_id r.principal_id WHERE sp.type IN (S, U, G) ORDER BY sp.name;对不确定用途的账号遵循先停用观察再删除清理的原则先ALTER LOGIN [xxx] DISABLE观察一个业务周期没有异常反馈再DROP LOGIN。5.4 给数据库课程设计和大作业场景的一个提醒很多学生在做数据库课程设计或毕业设计时SQL Server是直接装在本地电脑上的习惯性用sa加简单密码连接甚至写完代码直接把sa密码提交到Git仓库。如果只是学习环境可能觉得无关紧要但一旦项目里用了真实数据或者电脑连了校园网sa弱口令的风险同样存在。个人开发环境建议也养成禁用一个、新建专用登录的习惯哪怕只是本地练习这个习惯能帮你避掉日后工作里因为权限滥用导致的安全事故。我自己从第一台SQL Server服务器上线那天起就把sa禁用写进了标准部署脚本。这几年下来错误日志里几乎再也看不到针对sa的爆破记录了该连接的应用也早就换成了专用权限的账号。禁用sa这件事本质上不是把一个账号关掉那么简单而是倒逼整个数据库实例的账号体系从默认超级权限走向最小权限专人专用这个转变无论对单台服务器还是整个企业的数据库资产来说都是最值得做的一笔安全投资。最后提醒一句如果你所在的环境还依赖混合模式认证禁用sa之前务必保证应用账号、作业、链接服务器这些依赖项都切换完毕否则断连只是时间问题。
返回列表