你的瞳色是我生命苦寻的永生花, 从此在我生命里悄悄发芽

漏洞描述

CVE-2025-53547 是一个影响 Kubernetes 包管理工具 Helm 的高危代码注入漏洞。该漏洞在 Helm 3.18.4 之前的版本中存在。攻击者可以通过精心构造的 Chart.yaml 文件和符号链接的 Chart.lock 文件,在更新依赖时将恶意内容写入目标文件,从而导致本地代码执行。

漏洞修复

对比Helm官方仓库v3.18.3与v3.18.4在向Chart.lock文件写入内容之前先检查了一下文件的属性

image-20250909231955823

代码分析

根据代码改动可定位到漏洞点存在于pkg/downloader/manager.go文件的writeLock函数中

image-20250909235427540

该函数中在当前目录下面生成Chart.lock文件然后将data内容写入进去。data即为lock锁的内容

在进行文件写入的时候并没有检查文件类型,如果Chart.lock文件是软链接,则会对连接到的文件进行数据写入

一个demo

这个demo 向本目录下的example.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
package main

import (
"fmt"
"os"
)

func main() {
// 要创建的文件名
filename := "example.txt"

// 要写入的数据
data := "Hello, this is some data written to the file."

// 创建文件(如果文件已存在,会清空重写)
file, err := os.Create(filename)
if err != nil {
fmt.Println("创建文件失败:", err)
return
}
// 记得关闭文件
defer file.Close()

// 写入数据
_, err = file.WriteString(data)
if err != nil {
fmt.Println("写入数据失败:", err)
return
}

fmt.Println("文件创建并写入成功:", filename)
}

在运行代码的时候 先创建一个名为example.txt的软链接,再运行文件则会导致他想目标文件写入数据

image-20250910000040539

下一步寻找调用writeLock的函数

在整个项目中全局搜索仅找到一处地方调用了writeLock函数

image-20250910000508702

也就是pkg/downloader/manager.go文件中的Update()函数

相关代码

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
func (m *Manager) Update() error {
c, err := m.loadChartDir()
if err != nil {
return err
}
// If no dependencies are found, we consider this a successful
// completion.
req := c.Metadata.Dependencies
if req == nil {
return nil
}
// Get the names of the repositories the dependencies need that Helm is
// configured to know about.
repoNames, err := m.resolveRepoNames(req)
if err != nil {
return err
}
// For the repositories Helm is not configured to know about, ensure Helm
// has some information about them and, when possible, the index files
// locally.
// TODO(mattfarina): Repositories should be explicitly added by end users
// rather than automatic. In Helm v4 require users to add repositories. They
// should have to add them in order to make sure they are aware of the
// repositories and opt-in to any locations, for security.
repoNames, err = m.ensureMissingRepos(repoNames, req)
if err != nil {
return err
}
// For each of the repositories Helm is configured to know about, update
// the index information locally.
if !m.SkipUpdate {
if err := m.UpdateRepositories(); err != nil {
return err
}
}
// Now we need to find out which version of a chart best satisfies the
// dependencies in the Chart.yaml
lock, err := m.resolve(req, repoNames)
if err != nil {
return err
}
// Now we need to fetch every package here into charts/
if err := m.downloadAll(lock.Dependencies); err != nil {
return err
}
// downloadAll might overwrite dependency version, recalculate lock digest
newDigest, err := resolver.HashReq(req, lock.Dependencies)
if err != nil {
return err
}
lock.Digest = newDigest
// If the lock file hasn't changed, don't write a new one.
oldLock := c.Lock
if oldLock != nil && oldLock.Digest == lock.Digest {
return nil
}
// Finally, we need to write the lockfile.
return writeLock(m.ChartPath, lock, c.Metadata.APIVersion == chart.APIVersionV1)
}

Update()调用wirteLock的时候传入了m.ChartPath, lock

m.ChartPath是我们执行helm命令时的路径信息

在源代码中加上一个输出函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (m *Manager) Print() {
if m.Out == nil {
m.Out = os.Stdout
}

fmt.Fprintf(m.Out, "Manager{\n")
fmt.Fprintf(m.Out, " ChartPath: %s\n", m.ChartPath)
fmt.Fprintf(m.Out, " Verify: %v\n", m.Verify)
fmt.Fprintf(m.Out, " Debug: %v\n", m.Debug)
fmt.Fprintf(m.Out, " Keyring: %s\n", m.Keyring)
fmt.Fprintf(m.Out, " SkipUpdate: %v\n", m.SkipUpdate)
fmt.Fprintf(m.Out, " RepositoryConfig: %s\n", m.RepositoryConfig)
fmt.Fprintf(m.Out, " RepositoryCache: %s\n", m.RepositoryCache)
fmt.Fprintf(m.Out, " Getters: %d provider(s)\n", len(m.Getters))
if m.RegistryClient != nil {
fmt.Fprintf(m.Out, " RegistryClient: %T\n", m.RegistryClient)
} else {
fmt.Fprintf(m.Out, " RegistryClient: <nil>\n")
}
fmt.Fprintf(m.Out, "}\n")
}

image-20250910152405656

然后对其进行调用 来查看m的各属性信息

image-20250910152524306

image-20250910152545405

m.ChartPat即为当前目录

lock是由m.resolve处理之后返回的

image-20250910152750109

resolve函数中New了一个resolver对象 将m.ChartPath, m.RepositoryCache, m.RegistryClient三个属性赋给res,然后res调用Resolve

image-20250910152919708

Resolve根据 Chart.yaml 里的依赖(reqs),逐一确认依赖能否满足 semver 版本约束,并填充 locked(最终写入 Chart.lock)

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
149
150
151
152
153
154
func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string) (*chart.Lock, error) {

// Now we clone the dependencies, locking as we go.
locked := make([]*chart.Dependency, len(reqs))
missing := []string{}
for i, d := range reqs {
constraint, err := semver.NewConstraint(d.Version)
if err != nil {
return nil, errors.Wrapf(err, "dependency %q has an invalid version/constraint format", d.Name)
}

if d.Repository == "" {
// Local chart subfolder
if _, err := GetLocalPath(filepath.Join("charts", d.Name), r.chartpath); err != nil {
return nil, err
}

locked[i] = &chart.Dependency{
Name: d.Name,
Repository: "",
Version: d.Version,
}
continue
}
if strings.HasPrefix(d.Repository, "file://") {
chartpath, err := GetLocalPath(d.Repository, r.chartpath)
if err != nil {
return nil, err
}

ch, err := loader.LoadDir(chartpath)
if err != nil {
return nil, err
}

v, err := semver.NewVersion(ch.Metadata.Version)
if err != nil {
// Not a legit entry.
continue
}

if !constraint.Check(v) {
missing = append(missing, fmt.Sprintf("%q (repository %q, version %q)", d.Name, d.Repository, d.Version))
continue
}

locked[i] = &chart.Dependency{
Name: d.Name,
Repository: d.Repository,
Version: ch.Metadata.Version,
}
continue
}

repoName := repoNames[d.Name]
// if the repository was not defined, but the dependency defines a repository url, bypass the cache
if repoName == "" && d.Repository != "" {
locked[i] = &chart.Dependency{
Name: d.Name,
Repository: d.Repository,
Version: d.Version,
}
continue
}

var vs repo.ChartVersions
var version string
var ok bool
found := true
if !registry.IsOCI(d.Repository) {
repoIndex, err := repo.LoadIndexFile(filepath.Join(r.cachepath, helmpath.CacheIndexFile(repoName)))
if err != nil {
return nil, errors.Wrapf(err, "no cached repository for %s found. (try 'helm repo update')", repoName)
}

vs, ok = repoIndex.Entries[d.Name]
if !ok {
return nil, errors.Errorf("%s chart not found in repo %s", d.Name, d.Repository)
}
found = false
} else {
version = d.Version

// Check to see if an explicit version has been provided
_, err := semver.NewVersion(version)

// Use an explicit version, otherwise search for tags
if err == nil {
vs = []*repo.ChartVersion{{
Metadata: &chart.Metadata{
Version: version,
},
}}

} else {
// Retrieve list of tags for repository
ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, fmt.Sprintf("%s://", registry.OCIScheme)), d.Name)
tags, err := r.registryClient.Tags(ref)
if err != nil {
return nil, errors.Wrapf(err, "could not retrieve list of tags for repository %s", d.Repository)
}

vs = make(repo.ChartVersions, len(tags))
for ti, t := range tags {
// Mock chart version objects
version := &repo.ChartVersion{
Metadata: &chart.Metadata{
Version: t,
},
}
vs[ti] = version
}
}
}

locked[i] = &chart.Dependency{
Name: d.Name,
Repository: d.Repository,
Version: version,
}
// The versions are already sorted and hence the first one to satisfy the constraint is used
for _, ver := range vs {
v, err := semver.NewVersion(ver.Version)
// OCI does not need URLs
if err != nil || (!registry.IsOCI(d.Repository) && len(ver.URLs) == 0) {
// Not a legit entry.
continue
}
if constraint.Check(v) {
found = true
locked[i].Version = v.Original()
break
}
}

if !found {
missing = append(missing, fmt.Sprintf("%q (repository %q, version %q)", d.Name, d.Repository, d.Version))
}
}
if len(missing) > 0 {
return nil, errors.Errorf("can't get a valid version for %d subchart(s): %s. Make sure a matching chart version exists in the repo, or change the version constraint in Chart.yaml", len(missing), strings.Join(missing, ", "))
}

digest, err := HashReq(reqs, locked)
if err != nil {
return nil, err
}

return &chart.Lock{
Generated: time.Now(),
Digest: digest,
Dependencies: locked,
}, nil
}

pkg/chart/dependency.go为Lock添加一个Print方法

image-20250910160823106

然后在pkg/downloader/manager.go调用Print

image-20250910160936746

然后运行就可以看Lock内容

image-20250910161030787

这个内容就是在Chart.lock中写入的内容

image-20250910161110386

到这就是整个过程POChttps://github.com/DVKunion/CVE-2025-53547-POC

image-20250910161633418

尝试的挫败

目前网上已存在的利用方式是试着将Chart.lock链接到.bashrc上。。。但是当你真的这种做的时候

你就会发现这样会报错然后崩溃

image-20250910162346184

再回头看Update函数

image-20250910162441082

Update函数首先调用了loadChartDir()函数

loadChartDir()函数中又调用了loader.LoadDir()传入了m.ChartPath也就是.

image-20250910162526418

LoadDir()函数中会读取目录中的文件,将文件名与文件内如保存到file列表中,然后调用LoadFiles()函数

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
func LoadDir(dir string) (*chart.Chart, error) {
topdir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}

// Just used for errors.
c := &chart.Chart{}

//加载不需要打包进Chart的文件
rules := ignore.Empty()
ifile := filepath.Join(topdir, ignore.HelmIgnore)
if _, err := os.Stat(ifile); err == nil {
r, err := ignore.ParseFile(ifile)
if err != nil {
return c, err
}
rules = r
}
rules.AddDefaults()

files := []*BufferedFile{}
topdir += string(filepath.Separator)

walk := func(name string, fi os.FileInfo, err error) error {
n := strings.TrimPrefix(name, topdir)
if n == "" {
// No need to process top level. Avoid bug with helmignore .* matching
// empty names. See issue 1779.
return nil
}

// Normalize to / since it will also work on Windows
n = filepath.ToSlash(n)

if err != nil {
return err
}
if fi.IsDir() {
// Directory-based ignore rules should involve skipping the entire
// contents of that directory.
if rules.Ignore(n, fi) {
return filepath.SkipDir
}
return nil
}

// If a .helmignore file matches, skip this file.
if rules.Ignore(n, fi) {
return nil
}

// Irregular files include devices, sockets, and other uses of files that
// are not regular files. In Go they have a file mode type bit set.
// See https://golang.org/pkg/os/#FileMode for examples.
if !fi.Mode().IsRegular() {
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name)
}

if fi.Size() > MaxDecompressedFileSize {
return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), MaxDecompressedFileSize)
}

data, err := os.ReadFile(name)
if err != nil {
return errors.Wrapf(err, "error reading %s", n)
}

data = bytes.TrimPrefix(data, utf8bom)
//将文件数据与名称写入到file列表中
files = append(files, &BufferedFile{Name: n, Data: data})
return nil
}
if err = sympath.Walk(topdir, walk); err != nil {
return c, err
}

return LoadFiles(files)
}

LoadFiles()函数中如果存在Chart.lock文件则将会尝试对其进行yaml解析

image-20250910164029296

那么这样一来 则就会对Chart.lock的内容要求有限制。只允许他是yaml或者空文件

那么将其连接到.bashrc上面的计划也就泡汤,因为没有上面.bashrc文件回事yaml格式或者内容为空

条件放宽与潜在利用

既然目的是想让里面的命令被执行

那么除了.bashrc这一类文件还有一类文件即存放在/ect/cron.*/目录文件会被执行

但Chart.lock链接的文件又必须存在。没办法

如果这些目录下刚好有一个空文件

或许可以进行利用了。

image-20250910165435668

image-20250910165504749

小插曲

网上大多标出的触发的方法是用helm dependency update命令触发的时候

但是我在复现的过程中发现helm dependency build也能触发

究其原因是如果有lock文件 Build会直接调用Update函数

image-20250910165758371

参考文献:

  1. 【漏洞复现】CVE-2025-53547 helm恶意代码执行漏洞复现&分析 https://mp.weixin.qq.com/s/oawEoi_SbYn46NuIht0BCg
  2. PoC: https://github.com/DVKunion/CVE-2025-53547-POC/tree/main
  3. helm官方git更新: https://github.com/helm/helm/compare/v3.18.3...v3.18.4

本文首发于先知社区https://xz.aliyun.com/news/18811