前阵子在一台已授权测试环境的内网机器上做安全复盘,MySQL root 权限已经拿到,下一步要往系统权限走。同组的同事问我:UDF 和启动项提权,你先试哪个?我当时的回答是:看环境,不是看名气。这两个名字在 MySQL 安全讨论里几乎总是成对出现,但它们的适用条件、稳定程度、暴露风险其实差别很大。这篇文章就围绕 mysql 数据库的 UDF 提权和启动项提权展开,把我自己踩过的坑、常用的判断思路、落地细节一起写出来,希望能给做渗透测试、安全运维的朋友一些参考。
先说清楚:这篇文章讨论的是授权测试环境里的技术复盘,不是鼓励任何人在未授权系统上乱试。提权这条路,方向对了是测试,方向错了是事故。下面内容里我不会只给命令,还会把每条命令为什么这么用、什么条件下会失败、失败后怎么排查都讲透。
1. 先说清楚:为什么UDF和启动项提权总被摆在一起
1.1 两件事的本质都围绕“扩大命令执行边界”
MySQL 提权这件事,本质上不是“破解密码”,而是“把数据库权限翻译成操作系统权限”。UDF 的思路是让 MySQL 进程内多出一个能调用系统命令的函数,等于给数据库装了一条通到 shell 的管道;启动项提权的思路则是利用数据库能写文件的能力,把一个可执行脚本或命令塞进系统开机自启的位置,等系统重启之后自动执行。
这两条路之所以经常被放在一起讨论,是因为它们对前置条件的要求高度重叠:你至少得有一个能写文件的数据库账号。更准确地说,是得拿到 FILE 权限或者 root 权限。MySQL 的权限体系里,写文件能力被 secure_file_priv、plugin_dir、目录写权限这几道闸门卡得很死,所以能不能成,很大程度上不取决于攻击技巧,而取决于对方配置得有多随意。
我见过不少刚入门的朋友一上来就搜“UDF 提权 exploit”,下载一个现成的 so/dll 文件往插件目录一丢,然后创建函数失败,就开始怀疑工具不对。其实多半是没搞明白 MySQL 对插件文件版本的强依赖。这个坑我后面会专门展开。
1.2 提权不是万能钥匙:先看前提条件
UDF 翻译过来是“用户自定义函数”,本意是让懂 C/C++ 的开发者扩展 MySQL 功能,它本身不是漏洞。能被用来提权,是因为 MySQL 进程的运行身份太高,且允许普通用户往插件目录写文件。反过来想,如果 MySQL 是以低权限账号运行的,就算你成功创建了函数,能执行的命令也受系统账号限制,很多目录写不进去,提权效果会大打折扣。
启动项提权同样依赖“进程有没有写目标目录的权限”。Windows 下常见目标是当前用户的 Startup 文件夹或注册表 Run 键,Linux 下常见目标是 /etc/rc.local、/etc/init.d、cron、systemd 服务。如果 MySQL 是 Windows 服务且运行在 SYSTEM 身份,那写入 HKLM...\Run 或者 ProgramData 下的 Startup 目录基本畅通无阻;如果只是普通用户身份,那就只能影响当前用户登录时的自启项,真正拿到系统权限还得再走一步。
所以我的经验是:别把 UDF 和启动项当成两个并列的“工具”,而是当成两条受环境制约的路径。动手之前先回答三个问题:当前数据库账号具备什么权限?MySQL 进程以什么身份运行?系统允许数据库写到哪些目录?这三个答案出来,路径基本就定了。
2. UDF提权的完整链路与那些让我踩过坑的细节
2.1 前置条件:插件目录、权限、secure-file-priv,一个都不能少
UDF 提权的常规链路是:把一个编译好的动态库文件(Linux 下是 .so,Windows 下是 .dll)放进 MySQL 插件目录,然后通过 CREATE FUNCTION 注册成自定义函数,调用它来执行系统命令。听起来简单,但每一步都有硬性条件。
插件目录由 plugin_dir 变量控制,默认一般在 MySQL 安装目录的 lib/plugin 下。你可以用下面这条命令确认:
SHOW VARIABLES LIKE 'plugin_dir';如果插件目录本身对 MySQL 进程不可写,后面全白搭。Linux 下这个目录经常属于 mysql 用户,而很多部署方式习惯用 root 启动 MySQL,这时候反而是写文件最容易的。Windows 下插件目录通常在 Program Files\MySQL 下,默认权限对服务账号通常可写,但具体还得看安装方式。
另一个关键变量是 secure_file_priv。它管的是 SELECT ... INTO OUTFILE 和 LOAD_FILE 这类文件读写操作。这个值有三种可能:NULL 表示完全禁止文件导入导出,空字符串表示不限制,指定路径表示只能读写该目录。很多配置文件里声明了 secure_file_priv 却忘了赋值,或者赋了 NULL,都会导致“能查函数表却写不进任何文件”的怪象。
SHOW VARIABLES LIKE 'secure_file_priv';我遇到过一个最典型的场景:对方 MySQL 5.7 跑在 Windows 上,root 密码被爆破成功,secure_file_priv 配置为空字符串,插件目录可写。这种情况下 UDF 基本是首选方案,因为 sys_exec 这类函数一旦注册成功,执行命令的权限直接继承 MySQL 服务账号,如果是 SYSTEM,那一次性就到顶了。
2.2 手工落地一个UDF的完整操作过程
我平时做授权测试时,不会一上来就传网上下载的现成二进制,因为版本和平台不匹配的坑实在太常见。更稳的做法是自己编译,或者从可信项目下载源码后在本地编译好再传。下面以 Linux 上编译 lib_mysqludf_sys 为例。
首先准备编译环境,需要 gcc 和 MySQL 客户端开发头文件。不同发行版包名不一样,Debian/Ubuntu 下一般是 libmysqlclient-dev,CentOS/RHEL 下是 mysql-devel。编译命令大致长这样:
gcc -shared -fPIC -o lib_mysqludf_sys.so lib_mysqludf_sys.c -I/usr/include/mysql编译完得到一个 .so 文件。接下来把它传到目标机。传输方式很多,最经典的是用数据库自身能力实现:先建一张表,把 .so 文件的十六进制内容读进去,再通过 SELECT ... INTO DUMPFILE 写到插件目录。但这一步有前提,就是 secure_file_priv 允许,否则只能走其他上传渠道。
我有一次图省事,直接用了 mysql 客户端自带的 LOAD_FILE 去读 /tmp 下的 .so,结果怎么读都是 NULL。排查了半天发现是 secure_file_priv 被配成了 /var/lib/mysql-files,而我读的文件在 /tmp 下,直接被拦了。后来把文件挪到允许目录下才成功。那次之后我对“先查变量再动手”这条原则执行得非常彻底。
文件进入插件目录后,注册函数:
CREATE FUNCTION sys_exec RETURNS STRING SONAME 'lib_mysqludf_sys.so';然后就可以通过 select sys_exec('whoami') 来执行命令。要注意,函数返回的是状态码,不是命令输出,想要回显需要先把结果写到文件里再读。比如:
SELECT sys_exec('id > /tmp/udf_test.txt');之后用 LOAD_FILE('/tmp/udf_test.txt') 或直接通过其他方式读取。这一套流程下来,命令执行能力就有了。但如果你在写文件时碰到权限不足,别第一时间怀疑 UDF 本身,先检查目标目录是不是 MySQL 进程有权限写。
2.3 编译失败与版本错位:我亲历的三个典型坑
先说版本错位。MySQL 的 UDF 动态库对 MySQL 主版本比较敏感,5.5、5.6、5.7、8.0 的接口定义有差异。网上流行的老 UDF 工具很多是基于 MySQL 5.x 写的,拿到 8.0 上一加载就报错,日志里会出现 undefined symbol 或者 invalid ELF header 之类。我一度以为是自己编译环境缺依赖,后来发现是用了老项目源码,头文件接口对不上。解决办法是找与目标 MySQL 版本匹配的源码重新编译,或者用高版本兼容写法。
第二个坑是文件完整性。在传输 .so 文件时,如果走了不稳定的中间链路,或者用 SELECT ... INTO OUTFILE 写二进制文件时被文本模式转换干扰,文件已经损坏,但创建函数时 MySQL 不一定立刻报错,往往到调用时才崩。这也是为什么建议用 INTO DUMPFILE 而不是 INTO OUTFILE 写二进制文件,前者不会做转义和换行处理。我见过有人用 INTO OUTFILE 写 .so,文件头被加了东西导致加载失败,排查了很久才发现是这问题。
第三个坑跟权限有关。有时候插件目录对 MySQL 用户不可写,你会直接在写文件阶段失败。这时候很多人会立刻放弃 UDF,其实还可以看看 MySQL 是不是以 root 身份启动的,如果 /etc/my.cnf 里 user=root,那 MySQL 写文件的能力会大很多,这本身就是运维配置失误。但这种“配置失误”不应该被当成常规依赖项,因为现在很多发行版默认使用 mysql 用户跑服务,能成功属于小概率事件。
3. 启动项提权:从“开机自启”到命令落地的三种玩法
3.1 Windows侧:启动文件夹、注册表、计划任务的差异
Windows 下启动项提权,核心是找“当前账号能写、系统启动时会自动执行”的位置。最常用的是启动目录和注册表 Run 键。
当前用户的启动目录一般在这个路径:
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup所有用户的启动目录在:
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp如果 MySQL 以 SYSTEM 身份运行,那这两个目录都能写。写个批处理文件进去,系统启动或用户登录时就会执行。注册表方面,常用的是:
HKCU\Software\Microsoft\Windows\CurrentVersion\Run HKLM\Software\Microsoft\Windows\CurrentVersion\Run用 SQL 往注册表写值不如直接写文件方便,但如果你有 UDF 的 sys_exec,可以用命令去操作注册表。计划任务是更隐蔽的选择,schtasks 命令可以创建开机触发或定时触发的任务,同样能实现自启。
我自己的判断是:在 Windows 目标机上,如果 MySQL 是服务方式运行且 SYSTEM 身份,注册表 Run 键比启动目录更稳,因为不需要等用户登录。但启动目录胜在简单直观,写入后几乎不需要调试。计划任务虽然灵活,但创建和清理的痕迹相对多。三者之间没有绝对优劣,看环境判断。
3.2 Linux侧:rc.local、systemd、cron各自的使用边界
Linux 下启动项提权的经典目标是 /etc/rc.local。这个文件在 systemd 时代依然存在,但前提是 rc-local.service 被启用,很多最小化安装默认不开。所以写入 rc.local 后最好确认一下服务状态,否则重启后不生效。
systemd 的话,可以新建一个 service 文件放到 /etc/systemd/system/ 下,然后执行 systemctl enable 把它设为开机自启。比 rc.local 的优势是可控性强,失败会报日志,缺点是文件结构复杂,容易被运维发现异常。
cron 是最常被钻空子的位置。@reboot 定时规则可以在系统启动后立刻跑一次,而且 crontab 配置分散在不同目录,比如 /var/spool/cron/ 或 /etc/cron.d/,平时不太容易被注意到。MySQL 写文件能力强的话,可以直接把一段脚本写进 cron 目录。但要注意,cron 文件的格式和权限要求很严格,权限不对会被静默忽略,我吃过这个亏。
Linux 下还有一个思路是写 SSH 公钥到 authorized_keys,严格说这不算启动项,但持久化效果类似。这类做法依赖文件权限配置不当,比如 mysql 用户对某用户家目录有写权限,属于另一种典型提权路径。公众号和书籍里经常把 SSH key 和启动项放一起讲,原因就是它们都利用了“能写文件”这个能力。
3.3 为什么启动项提权“成功率”反而比UDF更高
单看成功率,启动项提权确实比 UDF 更稳,前提是 MySQL 进程对目标目录有写权限。原因有几个:UDF 受 MySQL 版本、插件接口、文件格式三重约束,而启动项只要求“把一段文本/脚本写到指定位置”,不存在编译兼容问题;UDF 一旦加载失败可能导致 MySQL 进程崩溃,影响面大,启动项最多就是不生效,不会直接影响数据库服务。
举个例子,我在一次测试里遇到 MySQL 5.7、root 权限、secure_file_priv 为空,但插件目录权限被安全加固锁死,写 .so 进去直接 permission denied。这种情况下 UDF 路线直接断掉,但我发现 MySQL 进程是 root 启动的,于是往 /etc/cron.d/ 写了一个反弹执行的任务,重启后顺利拿到系统命令执行能力。那次之后,我在判断路径时把“我能写哪”放在“我能执行什么”前面。
不过启动项也有自己的短板:一是需要系统重启或用户登录才生效,时效性差;二是写入的痕迹相对明显,如果目标环境有文件完整性监控,很容易暴露;三是如果启动项脚本一次性执行完不清理,会一直驻留,容易造成生产事故。所以它适合做持久化,不适合做“一击必杀”的临时提权。
4. 动手前,先做一轮信息收集和条件判断
4.1 环境判断:我平时会先确认的四张“底牌”
很多朋友问我要“通用提权程序”,实话讲,没有。每条路径都是环境条件推出来的。我自己的流程是先确认四件事,全部确认完再选路线。
第一,当前数据库账号权限。查询当前用户和权限可以用:
SELECT user, host FROM mysql.user WHERE user = CURRENT_USER(); SHOW GRANTS FOR CURRENT_USER();重点是确认有没有 FILE 权限、有没有 root 权限、能不能创建函数。如果只有 SELECT 权限,那 UDF 基本没戏,启动项也大概率没戏。
第二,MySQL 进程身份。在系统层执行 ps/pstree 看进程归属,或者看配置文件里的 user 字段。Windows 下可以看服务属性里的“登录为”一栏。进程是 root 或 SYSTEM,和进程是 mysql/nobody,提权后的价值完全两回事。
第三,secure_file_priv 和 plugin_dir 的现值。这两条 SQL 前面提过,建议写进自己的信息收集清单里。如果 secure_file_priv 是 NULL 但你有 shell,可以尝试直接操作目录;如果没有 shell,UDF 的文件写入就断在这一步。
第四,可以写哪些目录。这个要结合系统权限来看。拿到 FILE 权限后,SELECT ... INTO DUMPFILE 能写的位置受 secure_file_priv 限制,但如果配合系统命令执行能力,实际可写范围取决于 MySQL 进程的系统身份。我常用的验证方式是用 UDF 或 SQL 尝试在 /tmp 写一个文件,看是否成功。
4.2 从现象反推可行路径
信息收集完之后,最重要的是组合判断。我列几种常见情况:
| 情况 | 可用路径 | 核心风险 |
|---|---|---|
| 有 root,secure_file_priv 为空,插件目录可写 | UDF 优先 | 版本兼容 |
| 有 root,secure_file_priv 为 NULL,进程是 SYSTEM/root | 启动项/计划任务优先 | 时效性差 |
| 有 FILE 权限,进程是普通用户 | 先看普通用户能不能写启动目录 | 提权价值有限 |
| 只有查询权限 | 两条路都走不了,只能找其他漏洞 | 别硬试 |
从现象反推有一个好处:不会在一条死路上浪费时间。比如你已经确认 secure_file_priv 是 NULL,那就不必纠结为什么写不了 .so,直接把力气挪到启动项或其他路径上。我见过最可惜的情况是,目标机明明可以用启动项拿到系统权限,测试人员却在 UDF 上卡了一天。
这里补充一个细节:MySQL 8.0 之后对 UDF 的限制更严格,而且 plugin_dir 的默认权限也变了,很多老教程直接失效。如果你扫描发现对方是 8.x,别直接套 5.x 的 UDF 打法,先关注版本再说。
5. 攻防视角下的检测、清理与加固清单
5.1 让UDF失效的几类常见截断手段
其实不光是测试人员,运维更需要关心 UDF 有没有被塞进来。检测的思路很简单:查 mysql.func 表,看有没有不该存在的自定义函数。
SELECT * FROM mysql.func;正常业务系统里,这张表基本是空的。如果看到 sys_exec、sys_eval、sys_get 这类函数,基本可以判定被植入了。另外,插件目录里如果出现陌生的 .so/.dll 文件,也值得高度警惕。
清理过程要彻底:先删除函数,再删除文件。删除函数用 DROP FUNCTION sys_exec;删完之后把插件目录里的 .so 文件也清掉,否则下次还能重新创建函数。这里有个小坑:DROPFUNCTION 之后如果文件还在,遇到二次植入时攻击者只需要重新 CREATE FUNCTION 即可,等于门还开着。
加固方面,secure_file_priv 是重点。把这个变量显式设置成指定目录,比如 /var/lib/mysql-files,并确保该目录只允许 MySQL 写入自己需要的文件,就能挡掉一大半文件写入路径。插件目录的权限也要收紧,确保 mysql 系统账号对目录只有读权限,除非确实需要在线安装插件。
5.2 启动项区域的审计与清理
启动项提权的痕迹多半分散在操作系统的各个自启位置。Windows 下可以用系统自带的 autoruns 类工具,也可以手动检查启动目录和注册表 Run 键。Linux 下重点检查 /etc/rc.local、/etc/cron.d、/var/spool/cron、systemd 服务目录,尤其是创建时间异常的文件。
我自己在 Linux 上排查时会先看一遍 crontab:
crontab -l ls -la /etc/cron.d/再看 rc.local 的内容,确认没有可疑命令。systemd 的话不一定要逐个看 service 文件,可以先看最近被 enable 的服务列表,再对可疑项展开检查。关键是建立一条基线:什么服务是本来就该有的,什么文件是数据库创建的,心里要有数。
清理启动项后一定要把原始配置恢复,否则可能导致系统无法正常启动。这一点在真实业务环境里尤其重要,测试归测试,不能把别人生产系统弄瘫。
5.3 写给我的同行:你要有的技术底线
技术本身是中性的,但使用技术的人得有边界。UDF 和启动项提权这类内容,测试环境和生产环境完全是两回事。没有书面授权的系统,碰都不要碰;有授权的测试,也要提前明确测试范围、时间窗口、风险等级。拿到权限之后,优先做验证和记录,而不是顺手留后门。真正专业的安全人员,是用最短路径证明风险存在,然后帮助对方把洞补上。
我个人现在的习惯是:每次做完这类测试,都会写一份包含“路径判断、具体操作、影响范围、修复建议”的复盘文档。这样既方便自己下次参考,也能直接交付给运维作为加固依据。技术能力会随着项目变强,但职业口碑取决于你能不能把每一次操作控制在约定范围内。
最后分享一个我个人很受益的小习惯:所有 SQL 和命令的记录,按时间线保存下来,标注当时的判断依据。这个习惯帮我复盘了很多次“当时为什么选了这条路”的决策过程。提权这条路,资料很多,但真正值钱的是你在一台具体机器上的完整判断链。希望这篇内容能帮你少踩几个我踩过的坑。