-
-
[原创] 2025-9月Solar应急响应月赛WP!
-
发表于: 2025-10-15 16:46 394
-
9月月赛WP
特洛伊挖矿木马事件排查
背景:
你是一名初级安全工程师,运维团队报告,公司的一台核心开发服务器(Ubuntu 22.04 LTS)出现CPU使用率异常飙高告警及安全设备检出外联挖矿事件。现在,你需要登录该服务器,排查并处置这一安全事件,并最终找出问题的根源
账号:root
密码:P@ssw0rd
本环境取自于某次勒索事件处理完毕的后期安全运营排查过程中的案例
1.提交挖矿文件的绝对路径
提交挖矿文件的绝对路径,最终以flag{/xxx/xxx}格式提交
通过描述和挖矿特征,先执行top命令来获取CPU占用情况,然后再根据情况查找对应文件

值得注意的是在执行top命令后竟然没有结果,一般情况下会有多种原因,常见于如下两种
1.top命令被篡改
2.top命令被劫持
通过执行alias命令得到top命令被"起别名",最终输出空,在实战中这里也是一个注意点

执行unalias top删除top的别名
再次执行top后得到正常结果,且通过CPU使用率看到PID为312的进程存在高使用率的情况

执行lsof -p 312获取此进程的相关文件列表
根据结果看到挖矿文件在/tmp目录下

可以通过strings kworkerds看一下挖矿文件大概包含哪些东西

上图中疑似HTTP的登录请求,在后面有一串乱码,可以尝试使用IDA进一步逆向,会得知是异或加密
所以本题的flag为:flag{/tmp/kworkerds}

2.提交挖矿文件的外联IP与端口
提交挖矿文件的外联的IP与端口,最终以flag{ip:port}格式提交
挖矿除了高占用服务器资源外,还会对矿池进行外联行为,这里提出两个思路
1.使用tcpdump抓取网卡流量,但是缺点可能在纷繁的流量中很难定位到恶意IP
2.使用ss命令定位指定文件对外连接的情况,但是前提应该知道这个文件名
执行ss -tupan|grep "kworkerds"命令

在执行后没反应大概率是此脚本有"心跳机制",不会持续外联,所以可以多次执行命令尝试抓取

最终定位到外连地址:104.21.6.99:10235
所以最终flag为:flag{104.21.6.99:10235}

3.守护进程脚本的绝对路径
停止挖矿进程并尝试删除挖矿程序,根据异常判断,提交守护进程脚本的绝对路径,最终以flag{/xxx/xxx/xxx/xxx}提交
正常情况下定位到挖矿文件路径和进程后执行kill -9 pid和rm -rf file
但是当我执行了如上命令后,发现提示无权限删除以及清除相关进程后再次"重生"

这说明本机可能存在命令被感染的情况,这里推荐使用busybox工具清除挖矿程序和进程
1 2 | busybox kill -9 22616busybox rm -rf /tmp/kworkerds |

此时不再出现无权限问题,稍等片刻后再次验证(暂时不操作)


但是经过等待一分钟后再次验证发现,进程和文件又存活了,于是猜测是否有权限维持的情况,比如计划任务
1 2 | crontab -lcat /etc/crontab |


发现没有任何东西,不要气馁,在/etc/还有cron.xxx的目录,如下

图中分别意为:每分钟、每天、每小时,当把文件存放到里面以后,会按照规律进行启动,类似于Windows中的计划任务
依次查看文件,在/etc/cron.d/0guardian文件发现可疑

此计划任务指的是每次触发后都执行/usr/bin/.0guardian,去查看对应文件规则

此脚本意为每次执行都会触发一次stat命令,这里就比较疑惑了,看似这个脚本很正常,但是从命名和之前遇到的rm命令异常现象和kill后重生,说明stat是否会如同他们一样,被感染了?这也符合特洛伊木马的感染特征
不妨大胆假设:恶意程序或攻击者->感染程序->埋藏脚本(看似正常)->植入计划任务->释放挖矿,即使使用busybox删除且不再操作本机命令以后,计划任务也可以通过看似正常的脚本去触发被感染的系统命令造成挖矿的"重生"

最终flag:flag{/usr/bin/.0guardian}
实战中有时会存在线索中断的情况,从而推导问题,然后再后面补全和验证推到和假设的结论真实性
4.异常处理
根据出现的异常及守护进程脚本,继续排查,以人为本,使用环境内浏览器访问:12cK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3y4Z5j5i4c8Q4x3X3g2A6L8Y4c8W2M7X3&6S2L8q4)9J5k6r3c8W2N6W2)9J5k6h3&6W2N6q4)9K6b7e0R3H3z5o6p5`. 获取可疑网址,最终以flag{44eK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4N6%4N6#2)9J5k6h3g2^5j5h3#2H3L8r3g2Q4x3X3g2U0L8$3#2Q4y4@1c8Q4c8e0k6Q4b7e0m8Q4b7V1y4Q4c8e0g2Q4b7V1y4Q4z5p5k6Q4c8e0k6Q4z5p5k6Q4z5e0m8Q4c8e0c8Q4b7V1q4Q4b7e0b7`.
并不一定每次攻击事件都是漏洞攻击导致,也可能是钓鱼或者下载了非官方的程序导致释放了恶意文件,所以这里我们复现了"以人为本"的排查线

通过调查,十三年说自己并未动过那台机器

继续调查,小李暂时排除嫌疑,小张说执行了一个脚本,去审计一下此脚本是否存在危害

脚本只是正常启停而已,并未不存在危害

最终追问小张得到了一个非官方下载"superlog"的网站



访问此网站看看有无异常

看似没有审核问题,下载此程序逆向看看,先行使用strings确认是否存在可疑字符串

如上图所示的标记位置,这不就是我们看到的守护进程脚本吗,具体分析需使用IDA来看运行逻辑,后面再分析

最终flag:flag{d2aK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4N6%4N6#2)9J5k6i4y4#2M7r3g2J5L8r3!0Y4i4K6u0V1M7s2u0G2i4K6u0W2j5$3!0E0i4K6N6p5
5.分析病毒文件
分析病毒文件,提交其感染的所有程序,最终以flag{md5(/usr/bin/whoai,/usr/bin/ls,/usr/bin/top)}进行提交,顺序需以病毒文件中为准
这里需要使用IDA进行逆向分析,来了解此程序的运行逻辑
因为是Linux桌面版,所以使用smb://nas.qsnctf.com进行访问


在输入了账号和密码之后点击connect,并前往home目录

然后将下载的setup复制到nas中,准备在物理机中下载

同理,在Windows上的一个目录中打开cmd输入sftp -P 2222 注册手机号@nas.qsnctf.com
并前往home目录下,getsetp文件到本地

使用die查看到程序没有加壳,而且是C语言编写的

使用IDA打开,找到main,这是C语言的入口点,双击main后,会跳转到对应视图并高亮

第八行先获取自身的文件名


如上图所示,14行的sub_1670为感染程序的函数,unk_4040为挖矿程序(kworkerds)
sub_1670视角:

其中的24行取出了off_3CA8(指定文件列表),给到V4,然后V5取出第一个,比如ls程序,然后递归循环进行感染
双击off_3CA8,下图证明所有指定的感染文件

再往下看,第71行到74行分别为
1 2 3 4 | 1. write(v11, &unk_7900, v3): 写入内嵌的 wrapper_elf。2. write(v12, v10, v15): 写入刚才从原始文件中读到内存里的内容。3. write(v12, a1, n): 写入内嵌的矿工程序。4. write(v12, v17, 0x18uLL): 写入最后包含元数据的 ChimeraMeta 结构体 (24字节)。 |

总体来说,此逻辑不是寄生,而是将需要感染的文件先提取出来,然后连带恶意挖矿程序及判断逻辑一并写入到新文件
sub_18F0视角:
双击进去第二个函数,看运行逻辑


很明显,这是创建的守护进程和计划任务,因为前面逻辑看到了,stat也被感染了
释放挖矿程序
看30行,v4会拿到前面的unk_4040取出其中的代码,写入到/tmp/kworkerds

看最后41行的remove会根据前面获取到的文件名进行自删除

分析完毕,所以最终本题的flag为:flag{md5(/bin/ls,/bin/ps,/bin/cat,/bin/rm,/bin/ss,/usr/bin/stat,/usr/bin/top,/usr/bin/wget,/usr/bin/curl,/usr/bin/vi,/usr/bin/sudo)}
1 | flag{dac48e98a53b81b0218e2156e364f7ba} |

6.修复系统并恢复文件完整性
修复系统并恢复文件完整性:已知所有程序被感染,当前系统属于断网状态,所以作者贴心的在/deb_final目录下存放了对应程序的deb包,请尝试恢复所有程序,恢复完毕后在/var/flag/1文件获取flag
在感染的系统中一般不建议联网修复,以本次环境为例,apt在下载程序的时候会调用wget,这样还是会触发感染程序,且在环境内属于断网状态,所以根据描述前往/deb_final进行更新

更新命令为:dpkg -i --force-overwrite *.deb

在重新安装对应的包后,此时所有的被感染命令将恢复成功

获取到flag:flag{e510c5fca680b1b4bd5c9d8d6b3f4bdc}

7.最终清理
删除挖矿程序、删除计划任务及守护进程及清除相关进程,等待片刻在/var/flag/2获取flag
这时候可以测试之前遇到的rm -rf /tmp/kworkerds出现的权限不足的问题和是否会存在重生的问题
删除挖矿文件rm -rf /tmp/k*

停止挖矿进程:kill -9 305

删除守护进程脚本和计划任务:rm -rf /usr/bin/.0guardian``rm -rf /etc/cron.d/0guardian

思路复盘
最终确定:
1.小王在9.22日访问super-log非官方网站下载setup安装包
2.执行setup安装包后,感染指定的常用可执行命令并释放挖矿,且释放计划任务和守护进程
计划任务和守护进程的目的是为了防止用户使用busybox工具删除程序后,不在执行其它命令,而每分钟执行一次计划任务,调用stat可以造成"不死进程"
3.setup执行完毕后自删除,防止溯源逆向,且由于多重机制,沙箱无法给出结果以及告警
HAPPY
题目文件结构

FLAG.ENC为加密后的文件,HAPPY.EXE为16位加密器(这就是文件名为啥要大写,后文用小写代替).

die扫描为dos可执行文件.

ida打开,都给注释了...一看就知道这是打开文件获取句柄.

清晰明了,加密器的超级精简版...


魔术字符串和程序中对应上了.7明显就是字符串长度...两只手数一下.


ax为返回读取到的大小,写入0x1023e.然后判断大小模2的值,填充0直到与2bytes对齐.

模2结果写入文件魔术数字后面.表示是否经过对齐(填充0).

写入成功后,调用int1a读取时间,取低word,作为随机数.

明显从文件内容中取出1个word,加密后写回,计数加2.这个计算看似很复杂,其实只要稍微了解下nand门,就能发现,这个就是xor运算.具体解析如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 | ;使用nand门实现: ax xor dimov cx, axand cx, dinot cx ;NAND(A,B)mov dx, axand dx, cxnot dx ;NAND(A,NAND(A,B))mov ax, dxmov dx, diand dx, cxnot dx ;NAND(B,NAND(A,B))and ax, dxnot ax ;NAND(NAND(A,NAND(A,B)),NAND(B,NAND(A,B))) |
所以该运算就是将前面得到的时间(从dx转移到di)作为初始值,不断地加上0xfade(第一次加密前要加),每次加上都按顺序异或一个文件内容中的word并写回buffer.

写入key种子到加密后的文件.

写入加密后的buffer到加密后文件.
所以文件结构如下图:

参考脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | w=open("./FLAG.ENC",'rb').read()remain=w[7]key=w[8]|(w[9]<<8)cipher=w[10:]plain=[]for i in range(0,len(cipher),2): key+=0xfade key&=0xffff tmp=cipher[i]|(cipher[i+1]<<8) p=tmp^key plain.append(p&0xff) plain.append(p>>8)if remain==1: plain.pop()print(bytes(plain))#flag{D0s_L0ck3r_WitH_n4Nd_ExpRs!|solarsec_202509} |
wireshark
先是一个antsword的流量,然后提取一个压缩包,里面是一个keys.txt,应该后续需要用到

看后续的流量,发现是一个哥斯拉的流量,写脚本进行爆破,脚本如下。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | import base64, gzip, hashlib, binasciifrom typing import Optional, List, Tuple# --------------- CONFIG ---------------RESPONSE = ( "2abd2b6d922769cceu1uNDRjMzYyYXr4Ly2dfisoK359UYTmBysDfrBUAwJQtCsGBivnAwJSh4dSVXwGKNBSnTFjijiPchszZmE=bb57d9b8561ce2a5")KEYS_FILE = "keys.txt"PASSWORD = "pass"MIN_RATIO = 0.7# --------------- helpers ---------------def printable_ratio(b: bytes) -> float: ifnot b: return0.0 good = sum(1for x in b if32 <= x <= 126) return good / len(b)def try_base64_decode(s: str) -> Optional[bytes]: if isinstance(s, str): s = s.encode("latin1") try: return base64.b64decode(s, validate=False) except Exception: try: padded = s + b"=" * ((4 - (len(s) % 4)) % 4) return base64.b64decode(padded, validate=False) except Exception: returnNonedef try_gzip_decompress(b: bytes) -> Optional[bytes]: try: if isinstance(b, (bytes, bytearray)) and b.startswith(b'\x1f\x8b'): return gzip.decompress(b) except Exception: pass returnNonedef xor_with_key_bytes(data: bytes, key_bytes: bytes, offset: int = 0) -> bytes: ifnot key_bytes: return data out = bytearray(len(data)) k = len(key_bytes) for i in range(len(data)): out[i] = data[i] ^ key_bytes[(i + offset) % k] return bytes(out)def to_preview(b: bytes, n: int = 200) -> str: try: return b.decode("utf-8", errors="replace")[:n] except Exception: return binascii.hexlify(b[:n]).decode()# --------------- key variants ---------------def generate_key_variants(orig_key: str, password: str = "pass"): variants = [] variants.append(("raw", orig_key)) try: mdk = hashlib.md5(orig_key.encode()).hexdigest()[:16] variants.append(("md5_key16", mdk)) except Exception: pass try: md2 = hashlib.md5((password + orig_key).encode()).hexdigest()[:16] variants.append(("md5_pass_key16", md2)) except Exception: pass return variants# --------------- attempt functions ---------------def attempt_eval_b64_variant(response: str, variant_val, offsets: List[int]): out = [] middle = response if isinstance(response, str) and len(response) > 48: med = response[16:-16] if any(c in med for c in"=+/"): middle = med b = try_base64_decode(middle) if b isNone: return out if isinstance(variant_val, (bytes, bytearray)): kb = variant_val else: kb = str(variant_val).encode("latin1") klen = len(kb) or1 max_off = min(klen, 32) for off in offsets: dec = xor_with_key_bytes(b, kb, offset=off) pr = printable_ratio(dec) if pr >= MIN_RATIO: out.append((f"EVAL_B64 off={off}", dec, pr)) gz = try_gzip_decompress(dec) if gz isnotNoneand printable_ratio(gz) >= MIN_RATIO: out.append((f"EVAL_B64->GZIP off={off}", gz, printable_ratio(gz))) return outdef attempt_raw_variants(response: str, variant_val, offsets: List[int]): out = [] s = response.strip() bfull = try_base64_decode(s) if bfull isnotNone: if isinstance(variant_val, (bytes, bytearray)): kb = variant_val else: kb = str(variant_val).encode("latin1") klen = len(kb) or1 max_off = min(klen, 32) for off in offsets: dec = xor_with_key_bytes(bfull, kb, offset=off) pr = printable_ratio(dec) if pr >= MIN_RATIO: out.append((f"RAW_B64FULL off={off}", dec, pr)) gz = try_gzip_decompress(dec) if gz isnotNoneand printable_ratio(gz) >= MIN_RATIO: out.append((f"RAW_B64FULL->GZIP off={off}", gz, printable_ratio(gz))) return out# --------------- driver ---------------def brute_force_response(response: str, keys_file: str, password: str = "pass"): try: with open(keys_file, "r", encoding="utf-8") as f: keys = [line.strip() for line in f if line.strip()] except FileNotFoundError: print(f"keys file '{keys_file}' not found.") return offsets_try = list(range(0, 8)) for idx, orig in enumerate(keys, 1): print("\n" + "="*60) print(f"[{idx}/{len(keys)}] testing key: {orig!r}") variants = generate_key_variants(orig, password=password) for vname, kval in variants: eval_candidates = attempt_eval_b64_variant(response, kval, offsets_try) for label, dec, pr in eval_candidates: print(f" {label:25} pr={pr:.3f} preview={to_preview(dec)}") raw_candidates = attempt_raw_variants(response, kval, offsets_try) for label, dec, pr in raw_candidates: print(f" {label:25} pr={pr:.3f} preview={to_preview(dec)}")# --------------- run ---------------if __name__ == "__main__": print("Starting brute-force run (MIN_RATIO =", MIN_RATIO, ")") brute_force_response(RESPONSE, KEYS_FILE, password=PASSWORD)flag{ccebdb78-4b5c-4252-b20a-0039913c5c94} |
[培训]传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!