Podinfo 文件上传漏洞 | CVE-2025-70849 复现&研究

0x0 背景介绍

podinfo 6.9.0 及之前版本中,未经过身份验证的攻击者可以通过向/store端点发送伪造的 POST请求上传任意文件。由于应用在未启用严格的Content-Security-Policy(CSP)或未进行充分的Content-Type验证的情况下直接渲染上传内容,从而导致存储型跨站脚本攻击(XSS)

0x1 环境搭建

1.1、Ubuntu24+Docker搭建配置

bash 复制代码
#1、创建专属项目
mkdir podinfo-rce && cd podinfo-rce
#2、拉取环境
git clone https://github.com/stefanprodan/podinfo.git
git checkout 6.9.0
#3、拉取镜像
docker build -t podinfo:6.9.0-local .
#4、挂载 data 目录,赋权,我一开始没创建但是上传失败
mkdir -p ~/podinfo-data
chmod 777 ~/podinfo-data  # 简化权限(测试环境可用)
#5、启动项目
docker run -d \
  --name podinfo-690 \
  -p 9898:9898 \
  -v ~/podinfo-data:/data \
  podinfo:6.9.0-local

0x2 漏洞复现

2.1、YML复现

  • YML验证
bash 复制代码
https://github.com/Kai-One001/cve-/blob/main/Podinfo_Upload_CVE_2025_70849.yml
  • 验证是否成功

2.2、复现流量特征 (PCAP)

  • 无认证请求接口,流量整体明显,还是方便监控的


0x3 漏洞原理分析

我们需要先找下入口文件,go语言程序的起点一般都是 main() 函数,在podinfo 中,主程序就在:cmd/podinfo/main.go

3.1、程序入口

go 复制代码
import (
                // ... 其他包
	"github.com/stefanprodan/podinfo/pkg/api/http"
)
func main() {

	//具体调用分析// 1. 加载 HTTP 服务器配置
	var srvCfg http.Config
	if err := viper.Unmarshal(&srvCfg); err != nil {
	    logger.Panic("config unmarshal failed", zap.Error(err))
	}

	// 2. 创建 HTTP 服务器实例
	srv, _ := http.NewServer(&srvCfg, logger)
	httpServer, httpsServer, healthy, ready := srv.ListenAndServe()

	// 3. 启动服务器
	httpServer, httpsServer, healthy, ready := srv.ListenAndServe()
}
  • HTTP服务器的实现位于 pkg/api/http/中,接着往下找

3.2、上传接口 /store:任意内容写入磁盘

  • 文件路径:pkg/api/http/store.go
  • 函数:storeWriteHandler
go 复制代码
func (s *Server) storeWriteHandler(w http.ResponseWriter, r *http.Request) {
    _, span := s.tracer.Start(r.Context(), "storeWriteHandler")
    defer span.End()

    body, err := io.ReadAll(r.Body)
    if err != nil {
        s.ErrorResponse(w, r, span, "reading the request body failed", http.StatusBadRequest)
        return
    }

    hash := hash(string(body))
    err = os.WriteFile(path.Join(s.config.DataPath, hash), body, 0644)
    if err != nil {
        s.logger.Warn("writing file failed", zap.Error(err), zap.String("file", path.Join(s.config.DataPath, hash)))
        s.ErrorResponse(w, r, span, "writing file failed", http.StatusInternalServerError)
        return
    }

    s.JSONResponseCode(w, r, map[string]string{"hash": hash}, http.StatusAccepted)
}
风险点 说明
未认证访问 无任何身份验证或授权检查,只要网络可达用户都可以调用
任意内容上传 使用 io.ReadAll(r.Body) 读取原始请求体 未校验 Content-Type(可为 text/htmlapplication/javascript 等) 未限制内容长度、格式、字符集
直接落盘 调用 os.WriteFile(path.Join(s.config.DataPath, hash), body, 0644) 原样写入磁盘
路径可控 DataPath 默认为 /data(见 main.gofs.String("data-path", "/data", ...)),能通过命令行参数或环境变量 PODINFO_DATA_PATH 修改
文件名机制 文件名为 SHA1(body),内容完全由访问者控制
文件权限 权限设为 0644,运行用户可读写,其他用户可读

3.3、下载接口 /store/{hash}:不安全内容回显

  • 文件位置: pkg/api/http/store.go
  • 函数: storeReadHandler
go 复制代码
func (s *Server) storeReadHandler(w http.ResponseWriter, r *http.Request) {
    _, span := s.tracer.Start(r.Context(), "storeReadHandler")
    defer span.End()

    hash := mux.Vars(r)["hash"]
    content, err := os.ReadFile(path.Join(s.config.DataPath, hash))
    if err != nil {
        s.logger.Warn("reading file failed", zap.Error(err), zap.String("file", path.Join(s.config.DataPath, hash)))
        s.ErrorResponse(w, r, span, "reading file failed", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusAccepted)
    w.Write([]byte(content))
}
风险点 说明
内容直接原样回显 只做os.ReadFile读取,直接w.Write(content)输出;没有对内容进行任何过滤、转义或安全封装
Content-Type 缺失 代码中未显式设置 Content-Type Swagger 注解 @Produce plain 仅为文档,不影响运行时行为 Go HTTP服务会基于前 512 字节进行 MIME 嗅探,可能将HTML/JS识别为可执行类型
关键安全头缺失 未设置 X-Content-Type-Options: nosniff → 无法阻止浏览器覆盖 MIME 类型 未设置 Content-Security-Policy → 无法限制脚本执行- 未设置 X-Frame-Options → 存在点击劫持风险
路径遍历防护不足 虽使用 SHA1 作为文件名,但未校验 hash 参数是否为 40 位十六进制字符串 缺少对路径拼接的安全加固(尽管当前因命名机制暂无实际穿越风险)
与上传接口形成完整攻击链 恶意内容写入后可直接通过此接口访问

3.4、路由与中间件注册机制

pkg/api/http/server.go中注册了文件存取相关的HTTP路由:

  • 路由注册代码:
go 复制代码
func (s *Server) registerHandlers() {
    // ... 其他路由
    
    s.router.HandleFunc("/store", s.storeWriteHandler).Methods("POST", "PUT")
    s.router.HandleFunc("/store/{hash}", s.storeReadHandler).Methods("GET").Name("store")
    
    // ... 其他路由
}
  • 中间件注册代码:
go 复制代码
func (s *Server) registerMiddlewares() {
    prom := NewPrometheusMiddleware()
    s.router.Use(prom.Handler)
    otel := NewOpenTelemetryMiddleware()
    s.router.Use(otel)
    httpLogger := NewLoggingMiddleware(s.logger)
    s.router.Use(httpLogger.Handler)
    s.router.Use(versionMiddleware)
    if s.config.RandomDelay {
        randomDelayer := NewRandomDelayMiddleware(s.config.RandomDelayMin, s.config.RandomDelayMax, s.config.RandomDelayUnit)
        s.router.Use(randomDelayer.Handler)
    }
    if s.config.RandomError {
        s.router.Use(randomErrorMiddleware)
    }
}
  • 服务器启动流程:
go 复制代码
func (s *Server) ListenAndServe() (*http.Server, *http.Server, *int32, *int32) {
    // ...
    s.registerHandlers()    // 先注册路由
    s.registerMiddlewares() // 后注册中间件(全局应用)
    // ...
}
  • /storePOST/PUT,无任何鉴权中间件要求

  • /store/{hash}GET,无鉴权、不做参数过滤

  • 全局应用 :所有中间件通过Use()应用到所有路由,store路由也没有例外

3.5、前端 UI 与 /store 的关系

ui/vue.html仅展示前端界面,并调用部分API

  • 文件位置:pkg\api\http\index.go
go 复制代码
package http

import (
	"html/template"
	"net/http"
	"path"
)

// Index godoc
// @Summary Index
// @Description renders podinfo UI
// @Tags HTTP API
// @Produce html
// @Router / [get]
// @Success 200 {string} string "OK"
func (s *Server) indexHandler(w http.ResponseWriter, r *http.Request) {
	_, span := s.tracer.Start(r.Context(), "indexHandler")
	defer span.End()

	tmpl, err := template.New("vue.html").ParseFiles(path.Join(s.config.UIPath, "vue.html"))
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte(path.Join(s.config.UIPath, "vue.html") + err.Error()))
		return
	}

	data := struct {
		Title string
		Logo  string
	}{
		Title: s.config.Hostname,
		Logo:  s.config.UILogo,
	}

	if err := tmpl.Execute(w, data); err != nil {
		http.Error(w, path.Join(s.config.UIPath, "vue.html")+err.Error(), http.StatusInternalServerError)
	}
}
  • ui/vue.html中的JS主要与/api/info/api/echo交互:
html 复制代码
new Vue({

 ...

 methods: {

  getInfo: function() {

   const xhr = new XMLHttpRequest();

   let self = this;

   xhr.open('GET', "api/info")

   xhr.onload = function () {

​    data = JSON.parse(xhr.responseText)

​    ...

​    self.info = data

​    self.cuddleStyle = {

​      backgroundColor: data.color

​    }

​    self.info.logo = data.logo

​    document.title = data.hostname

​    ...

   }

   ...

  },

  postBackend: function() {

   var self = this

   fetch("api/echo", {

​     method: 'post',

​     headers: {

​      "Content-type": "application/json; charset=UTF-8",

​      "X-APP": Math.random(),

​     },

​     body: JSON.stringify({random: Math.random()})

   })

   ...

  }

 }

})
  • 官方UI本身没有直接使用/store内容

  • 存储型XSS更类似于在同域下托管恶意页面/脚本,然后通过外部引导(链接、iframe/script 引用)让用户浏览器加载

0x4 修复建议

修复方案

  1. 升级最新版本: 尽快升级至官方发布的最新修复版本,具体参考: GitHub Release

  2. 临时防护措施:
    加强上传校验 :限制/store端点的访问权限(加认证+校验MIME类型和扩展名校验)
    强制设置安全响应头 :添加X-Content-Type-OptionsContent-Security-Policy等,禁止内联脚本执行
    日志监控与告警 :对/store端点的异常请求进行实时监控,拦截恶意上传行为

免责声明:本文仅用于安全研究目的,未经授权不得用于非法渗透测试活动。

相关推荐
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-02-13
大数据·人工智能·经验分享·搜索引擎·产品运营
dozenyaoyida3 小时前
RS预览失败问题分析和解决
网络·经验分享·嵌入式硬件·tcp·wifi6兼容性·视频预览卡顿
Eric2233 小时前
CLI-Agent-Manager:面向 Vibe Coding 的多 Agent 统一管理面板
人工智能·后端·开源
是做服装的同学3 小时前
服装企业管理信息系统是什么?它的核心功能和市场优势有哪些?
大数据·经验分享·其他
小马过河R4 小时前
Skill三件套:构建可进化技能仓库的开源工具链
人工智能·开源·ai编程·vibe coding·skills·ai辅助编码
我是章汕呐4 小时前
stata中如何实现OLS回归
经验分享·数据挖掘·回归·学习方法
三流架构师5 小时前
硬笔书法资源合集
经验分享
wenzhangli75 小时前
OoderA2UI流式样式设计:SkillCenter重磅组件实现传统组件一键换新
人工智能·网络协议·开源