In Lima, a malicious disk image could read a single file on the host filesystem as a qcow2/vmdk backing file
Description
Lima launches Linux virtual machines, typically on macOS, for running containerd. Prior to version 0.16.0, a virtual machine instance with a malicious disk image could read a single file on the host filesystem, even when no filesystem is mounted from the host. The official templates of Lima and the well-known third party products (Colima, Rancher Desktop, and Finch) are unlikely to be affected by this issue. To exploit this issue, the attacker has to embed the target file path (an absolute or a relative path from the instance directory) in a malicious disk image, as the qcow2 (or vmdk) backing file path string. As Lima refuses to run as the root, it is practically impossible for the attacker to read the entire host disk via /dev/rdiskN. Also, practically, the attacker cannot read at least the first 512 bytes (MBR) of the target file. The issue has been patched in Lima in version 0.16.0 by prohibiting using a backing file path in the VM base image.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/lima-vm/limaGo | < 0.16.0 | 0.16.0 |
Affected products
1Patches
101dbd4d9cabeMerge pull request from GHSA-f7qw-jj9c-rpq9
5 files changed · +367 −42
pkg/qemu/imgutil/imgutil.go+112 −21 modified@@ -5,14 +5,78 @@ import ( "encoding/json" "fmt" "os/exec" - "path/filepath" - "strings" + + "github.com/sirupsen/logrus" ) +type InfoChild struct { + Name string `json:"name,omitempty"` // since QEMU 8.0 + Info Info `json:"info,omitempty"` // since QEMU 8.0 +} + +type InfoFormatSpecific struct { + Type string `json:"type,omitempty"` // since QEMU 1.7 + Data json.RawMessage `json:"data,omitempty"` // since QEMU 1.7 +} + +func (sp *InfoFormatSpecific) Qcow2() *InfoFormatSpecificDataQcow2 { + if sp.Type != "qcow2" { + return nil + } + var x InfoFormatSpecificDataQcow2 + if err := json.Unmarshal(sp.Data, &x); err != nil { + panic(err) + } + return &x +} + +func (sp *InfoFormatSpecific) Vmdk() *InfoFormatSpecificDataVmdk { + if sp.Type != "vmdk" { + return nil + } + var x InfoFormatSpecificDataVmdk + if err := json.Unmarshal(sp.Data, &x); err != nil { + panic(err) + } + return &x +} + +type InfoFormatSpecificDataQcow2 struct { + Compat string `json:"compat,omitempty"` // since QEMU 1.7 + LazyRefcounts bool `json:"lazy-refcounts,omitempty"` // since QEMU 1.7 + Corrupt bool `json:"corrupt,omitempty"` // since QEMU 2.2 + RefcountBits int `json:"refcount-bits,omitempty"` // since QEMU 2.3 + CompressionType string `json:"compression-type,omitempty"` // since QEMU 5.1 + ExtendedL2 bool `json:"extended-l2,omitempty"` // since QEMU 5.2 +} + +type InfoFormatSpecificDataVmdk struct { + CreateType string `json:"create-type,omitempty"` // since QEMU 1.7 + CID int `json:"cid,omitempty"` // since QEMU 1.7 + ParentCID int `json:"parent-cid,omitempty"` // since QEMU 1.7 + Extents []InfoFormatSpecificDataVmdkExtent `json:"extents,omitempty"` // since QEMU 1.7 +} + +type InfoFormatSpecificDataVmdkExtent struct { + Filename string `json:"filename,omitempty"` // since QEMU 1.7 + Format string `json:"format,omitempty"` // since QEMU 1.7 + VSize int64 `json:"virtual-size,omitempty"` // since QEMU 1.7 + ClusterSize int `json:"cluster-size,omitempty"` // since QEMU 1.7 +} + // Info corresponds to the output of `qemu-img info --output=json FILE` type Info struct { - Format string `json:"format,omitempty"` // since QEMU 1.3 - VSize int64 `json:"virtual-size,omitempty"` + Filename string `json:"filename,omitempty"` // since QEMU 1.3 + Format string `json:"format,omitempty"` // since QEMU 1.3 + VSize int64 `json:"virtual-size,omitempty"` // since QEMU 1.3 + ActualSize int64 `json:"actual-size,omitempty"` // since QEMU 1.3 + DirtyFlag bool `json:"dirty-flag,omitempty"` // since QEMU 5.2 + ClusterSize int `json:"cluster-size,omitempty"` // since QEMU 1.3 + BackingFilename string `json:"backing-filename,omitempty"` // since QEMU 1.3 + FullBackingFilename string `json:"full-backing-filename,omitempty"` // since QEMU 1.3 + BackingFilenameFormat string `json:"backing-filename-format,omitempty"` // since QEMU 1.3 + FormatSpecific *InfoFormatSpecific `json:"format-specific,omitempty"` // since QEMU 1.7 + Children []InfoChild `json:"children,omitempty"` // since QEMU 8.0 } func ConvertToRaw(source string, dest string) error { @@ -27,6 +91,14 @@ func ConvertToRaw(source string, dest string) error { return nil } +func ParseInfo(b []byte) (*Info, error) { + var imgInfo Info + if err := json.Unmarshal(b, &imgInfo); err != nil { + return nil, err + } + return &imgInfo, nil +} + func GetInfo(f string) (*Info, error) { var stdout, stderr bytes.Buffer cmd := exec.Command("qemu-img", "info", "--output=json", "--force-share", f) @@ -36,26 +108,45 @@ func GetInfo(f string) (*Info, error) { return nil, fmt.Errorf("failed to run %v: stdout=%q, stderr=%q: %w", cmd.Args, stdout.String(), stderr.String(), err) } - var imgInfo Info - if err := json.Unmarshal(stdout.Bytes(), &imgInfo); err != nil { - return nil, err - } - return &imgInfo, nil + return ParseInfo(stdout.Bytes()) } -func DetectFormat(f string) (string, error) { - switch ext := strings.ToLower(filepath.Ext(f)); ext { - case ".qcow2": - return "qcow2", nil - case ".raw": - return "raw", nil +func AcceptableAsBasedisk(info *Info) error { + switch info.Format { + case "qcow2", "raw": + // NOP + default: + logrus.WithField("filename", info.Filename). + Warnf("Unsupported image format %q. The image may not boot, or may have an extra privilege to access the host filesystem. Use with caution.", info.Format) + } + if info.BackingFilename != "" { + return fmt.Errorf("base disk (%q) must not have a backing file (%q)", info.Filename, info.BackingFilename) } - imgInfo, err := GetInfo(f) - if err != nil { - return "", err + if info.FullBackingFilename != "" { + return fmt.Errorf("base disk (%q) must not have a backing file (%q)", info.Filename, info.FullBackingFilename) } - if imgInfo.Format == "" { - return "", fmt.Errorf("failed to detect format of %q", f) + if info.FormatSpecific != nil { + if vmdk := info.FormatSpecific.Vmdk(); vmdk != nil { + for _, e := range vmdk.Extents { + if e.Filename != info.Filename { + return fmt.Errorf("base disk (%q) must not have an extent file (%q)", info.Filename, e.Filename) + } + } + } } - return imgInfo.Format, nil + // info.Children is set since QEMU 8.0 + switch len(info.Children) { + case 0: + // NOP + case 1: + if info.Filename != info.Children[0].Info.Filename { + return fmt.Errorf("base disk (%q) child must not have a different filename (%q)", info.Filename, info.Children[0].Info.Filename) + } + if len(info.Children[0].Info.Children) > 0 { + return fmt.Errorf("base disk (%q) child must not have children of its own", info.Filename) + } + default: + return fmt.Errorf("base disk (%q) must not have multiple children: %+v", info.Filename, info.Children) + } + return nil }
pkg/qemu/imgutil/imgutil_test.go+212 −0 added@@ -0,0 +1,212 @@ +package imgutil + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestParseInfo(t *testing.T) { + t.Run("qcow2", func(t *testing.T) { + // qemu-img create -f qcow2 foo.qcow2 4G + // (QEMU 8.0) + const s = `{ + "children": [ + { + "name": "file", + "info": { + "children": [ + ], + "virtual-size": 197120, + "filename": "foo.qcow2", + "format": "file", + "actual-size": 200704, + "format-specific": { + "type": "file", + "data": { + } + }, + "dirty-flag": false + } + } + ], + "virtual-size": 4294967296, + "filename": "foo.qcow2", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 200704, + "format-specific": { + "type": "qcow2", + "data": { + "compat": "1.1", + "compression-type": "zlib", + "lazy-refcounts": false, + "refcount-bits": 16, + "corrupt": false, + "extended-l2": false + } + }, + "dirty-flag": false +}` + + info, err := ParseInfo([]byte(s)) + assert.NilError(t, err) + assert.Equal(t, 1, len(info.Children)) + assert.Check(t, info.FormatSpecific != nil) + qcow2 := info.FormatSpecific.Qcow2() + assert.Check(t, qcow2 != nil) + assert.Equal(t, qcow2.Compat, "1.1") + + t.Run("diff", func(t *testing.T) { + // qemu-img create -f qcow2 -F qcow2 -b foo.qcow2 bar.qcow2 + // (QEMU 8.0) + const s = `{ + "children": [ + { + "name": "file", + "info": { + "children": [ + ], + "virtual-size": 197120, + "filename": "bar.qcow2", + "format": "file", + "actual-size": 200704, + "format-specific": { + "type": "file", + "data": { + } + }, + "dirty-flag": false + } + } + ], + "backing-filename-format": "qcow2", + "virtual-size": 4294967296, + "filename": "bar.qcow2", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 200704, + "format-specific": { + "type": "qcow2", + "data": { + "compat": "1.1", + "compression-type": "zlib", + "lazy-refcounts": false, + "refcount-bits": 16, + "corrupt": false, + "extended-l2": false + } + }, + "full-backing-filename": "foo.qcow2", + "backing-filename": "foo.qcow2", + "dirty-flag": false +}` + info, err := ParseInfo([]byte(s)) + assert.NilError(t, err) + assert.Equal(t, 1, len(info.Children)) + assert.Equal(t, "foo.qcow2", info.BackingFilename) + assert.Equal(t, "bar.qcow2", info.Filename) + assert.Check(t, info.FormatSpecific != nil) + qcow2 := info.FormatSpecific.Qcow2() + assert.Check(t, qcow2 != nil) + assert.Equal(t, qcow2.Compat, "1.1") + }) + }) + t.Run("vmdk", func(t *testing.T) { + t.Run("twoGbMaxExtentSparse", func(t *testing.T) { + // qemu-img create -f vmdk foo.vmdk 4G -o subformat=twoGbMaxExtentSparse + // (QEMU 8.0) + const s = `{ + "children": [ + { + "name": "extents.1", + "info": { + "children": [ + ], + "virtual-size": 327680, + "filename": "foo-s002.vmdk", + "format": "file", + "actual-size": 327680, + "format-specific": { + "type": "file", + "data": { + } + }, + "dirty-flag": false + } + }, + { + "name": "extents.0", + "info": { + "children": [ + ], + "virtual-size": 327680, + "filename": "foo-s001.vmdk", + "format": "file", + "actual-size": 327680, + "format-specific": { + "type": "file", + "data": { + } + }, + "dirty-flag": false + } + }, + { + "name": "file", + "info": { + "children": [ + ], + "virtual-size": 512, + "filename": "foo.vmdk", + "format": "file", + "actual-size": 4096, + "format-specific": { + "type": "file", + "data": { + } + }, + "dirty-flag": false + } + } + ], + "virtual-size": 4294967296, + "filename": "foo.vmdk", + "cluster-size": 65536, + "format": "vmdk", + "actual-size": 659456, + "format-specific": { + "type": "vmdk", + "data": { + "cid": 918420663, + "parent-cid": 4294967295, + "create-type": "twoGbMaxExtentSparse", + "extents": [ + { + "virtual-size": 2147483648, + "filename": "foo-s001.vmdk", + "cluster-size": 65536, + "format": "SPARSE" + }, + { + "virtual-size": 2147483648, + "filename": "foo-s002.vmdk", + "cluster-size": 65536, + "format": "SPARSE" + } + ] + } + }, + "dirty-flag": false +}` + info, err := ParseInfo([]byte(s)) + assert.NilError(t, err) + assert.Equal(t, 3, len(info.Children)) + assert.Equal(t, "foo.vmdk", info.Filename) + assert.Check(t, info.FormatSpecific != nil) + vmdk := info.FormatSpecific.Vmdk() + assert.Check(t, vmdk != nil) + assert.Equal(t, vmdk.CreateType, "twoGbMaxExtentSparse") + }) + }) +}
pkg/qemu/qemu.go+23 −7 modified@@ -96,13 +96,19 @@ func EnsureDisk(cfg Config) error { if err != nil { return err } + baseDiskInfo, err := imgutil.GetInfo(baseDisk) + if err != nil { + return fmt.Errorf("failed to get the information of base disk %q: %w", baseDisk, err) + } + if err = imgutil.AcceptableAsBasedisk(baseDiskInfo); err != nil { + return fmt.Errorf("file %q is not acceptable as the base disk: %w", baseDisk, err) + } + if baseDiskInfo.Format == "" { + return fmt.Errorf("failed to inspect the format of %q", baseDisk) + } args := []string{"create", "-f", "qcow2"} if !isBaseDiskISO { - baseDiskFormat, err := imgutil.DetectFormat(baseDisk) - if err != nil { - return err - } - args = append(args, "-F", baseDiskFormat, "-b", baseDisk) + args = append(args, "-F", baseDiskInfo.Format, "-b", baseDisk) } args = append(args, diffDisk, strconv.Itoa(int(diskSize))) cmd := exec.Command("qemu-img", args...) @@ -570,14 +576,24 @@ func Cmdline(cfg Config) (string, []string, error) { } if isBaseDiskCDROM { args = appendArgsIfNoConflict(args, "-boot", "order=d,splash-time=0,menu=on") - args = append(args, "-drive", fmt.Sprintf("file=%s,media=cdrom,readonly=on", baseDisk)) + args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw,media=cdrom,readonly=on", baseDisk)) } else { args = appendArgsIfNoConflict(args, "-boot", "order=c,splash-time=0,menu=on") } if diskSize, _ := units.RAMInBytes(*cfg.LimaYAML.Disk); diskSize > 0 { args = append(args, "-drive", fmt.Sprintf("file=%s,if=virtio,discard=on", diffDisk)) } else if !isBaseDiskCDROM { - args = append(args, "-drive", fmt.Sprintf("file=%s,if=virtio,discard=on", baseDisk)) + baseDiskInfo, err := imgutil.GetInfo(baseDisk) + if err != nil { + return "", nil, fmt.Errorf("failed to get the information of %q: %w", baseDisk, err) + } + if err = imgutil.AcceptableAsBasedisk(baseDiskInfo); err != nil { + return "", nil, fmt.Errorf("file %q is not acceptable as the base disk: %w", baseDisk, err) + } + if baseDiskInfo.Format == "" { + return "", nil, fmt.Errorf("failed to inspect the format of %q", baseDisk) + } + args = append(args, "-drive", fmt.Sprintf("file=%s,format=%s,if=virtio,discard=on", baseDisk, baseDiskInfo.Format)) } for _, extraDisk := range extraDisks { args = append(args, "-drive", fmt.Sprintf("file=%s,if=virtio,discard=on", extraDisk))
pkg/vz/disk.go+11 −5 modified@@ -49,13 +49,19 @@ func EnsureDisk(driver *driver.BaseDriver) error { if err != nil { return err } + baseDiskInfo, err := imgutil.GetInfo(baseDisk) + if err != nil { + return fmt.Errorf("failed to get the information of base disk %q: %w", baseDisk, err) + } + if err = imgutil.AcceptableAsBasedisk(baseDiskInfo); err != nil { + return fmt.Errorf("file %q is not acceptable as the base disk: %w", baseDisk, err) + } + if baseDiskInfo.Format == "" { + return fmt.Errorf("failed to inspect the format of %q", baseDisk) + } args := []string{"create", "-f", "qcow2"} if !isBaseDiskISO { - baseDiskFormat, err := imgutil.DetectFormat(baseDisk) - if err != nil { - return err - } - args = append(args, "-F", baseDiskFormat, "-b", baseDisk) + args = append(args, "-F", baseDiskInfo.Format, "-b", baseDisk) } args = append(args, diffDisk, strconv.Itoa(int(diskSize))) cmd := exec.Command("qemu-img", args...)
pkg/vz/vm_darwin.go+9 −9 modified@@ -373,12 +373,12 @@ func attachNetwork(driver *driver.BaseDriver, vmConfig *vz.VirtualMachineConfigu } func validateDiskFormat(diskPath string) error { - format, err := imgutil.DetectFormat(diskPath) + info, err := imgutil.GetInfo(diskPath) if err != nil { return fmt.Errorf("failed to detect the format of %q: %w", diskPath, err) } - if format != "raw" { - return fmt.Errorf("expected the format of %q to be \"raw\", got %q", diskPath, format) + if info.Format != "raw" { + return fmt.Errorf("expected the format of %q to be \"raw\", got %q", diskPath, info.Format) } // TODO: ensure that the disk is formatted with GPT or ISO9660 return nil @@ -437,23 +437,23 @@ func attachDisks(driver *driver.BaseDriver, vmConfig *vz.VirtualMachineConfigura } extraDiskPath := filepath.Join(d.Dir, filenames.DataDisk) - extraDiskFormat, err := imgutil.DetectFormat(extraDiskPath) + extraDiskInfo, err := imgutil.GetInfo(extraDiskPath) if err != nil { return fmt.Errorf("failed to run detect disk format %q: %q", diskName, err) } - if extraDiskFormat != "raw" { - if strings.Contains(extraDiskFormat, string(os.PathSeparator)) { + if extraDiskInfo.Format != "raw" { + if strings.Contains(extraDiskInfo.Format, string(os.PathSeparator)) { return fmt.Errorf( "failed to convert disk %q to raw for vz driver because extraDiskFormat %q contains a path separator %q", diskName, - extraDiskFormat, + extraDiskInfo.Format, os.PathSeparator, ) } rawPath := fmt.Sprintf("%s.raw", extraDiskPath) - oldFormatPath := fmt.Sprintf("%s.%s", extraDiskPath, extraDiskFormat) + oldFormatPath := fmt.Sprintf("%s.%s", extraDiskPath, extraDiskInfo.Format) if err = imgutil.ConvertToRaw(extraDiskPath, rawPath); err != nil { - return fmt.Errorf("failed to convert %s disk %q to raw for vz driver: %w", extraDiskFormat, diskName, err) + return fmt.Errorf("failed to convert %s disk %q to raw for vz driver: %w", extraDiskInfo.Format, diskName, err) } if err = os.Rename(extraDiskPath, oldFormatPath); err != nil { return fmt.Errorf("failed to rename additional disk for vz driver: %w", err)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-f7qw-jj9c-rpq9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-32684ghsaADVISORY
- github.com/lima-vm/lima/commit/01dbd4d9cabe692afa4517be3995771f0ebb38a5ghsax_refsource_MISCWEB
- github.com/lima-vm/lima/releases/tag/v0.16.0ghsax_refsource_MISCWEB
- github.com/lima-vm/lima/security/advisories/GHSA-f7qw-jj9c-rpq9ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.