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
1 change: 1 addition & 0 deletions experiments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ experiments:
| [host_path_mount](host_path_volume.yaml) | This experiment attempts to mount a sensitive host filesystem path into a container | MITRE |
| [cluster_admin_binding](cluster_admin_binding.yaml) | This experiment attempts to create a container with the cluster-admin role binding attached | MITRE |
| [remote_execute_api](remote_execute_api.yaml) | This experiment attempts to create a deployment with a configurable image and verifies based off of API calls to the image | MITRE |
| [execute_api](execute_api.yaml) | This experiment attempts to call a service with a payload | MITRE |

## Implementing a new Experiment

Expand Down
50 changes: 0 additions & 50 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
package executor

import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/operantai/secops-chaos/internal/k8s"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/portforward"
"k8s.io/client-go/transport/spdy"
"k8s.io/utils/pointer"
)

Expand All @@ -27,13 +20,6 @@ type RemoteExecutorConfig struct {
Namespace string
Image string
Parameters RemoteExecutor
Addr string
StopCh chan struct{}
ReadyCh chan struct{}
ErrCh chan error
Out *bytes.Buffer
ErrOut *bytes.Buffer
LocalPort int32
}

type RemoteExecutor struct {
Expand Down Expand Up @@ -63,12 +49,6 @@ func NewExecutorConfig(name, namespace, image string, imageParameters []string,
TargetPort: targetPort,
ImageParameters: imageParameters,
},
StopCh: make(chan struct{}, 1),
ReadyCh: make(chan struct{}, 1),
ErrCh: make(chan error, 1),
Out: new(bytes.Buffer),
ErrOut: new(bytes.Buffer),
Addr: addr,
}
}

Expand Down Expand Up @@ -140,36 +120,6 @@ func (r *RemoteExecutorConfig) Deploy(ctx context.Context, client *kubernetes.Cl
return err
}

func (r *RemoteExecutorConfig) OpenLocalPort(ctx context.Context, client *k8s.Client) (*portforward.PortForwarder, error) {
clientset := client.Clientset
// Deployments can not be port forwarded to directly, this is similar to how kubectl does it
pods, err := clientset.CoreV1().Pods(r.Namespace).List(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("app=%s", r.Name)})
if err != nil {
return nil, err
}

// Currently only supports one replica in deployment
if len(pods.Items) != 1 {
return nil, fmt.Errorf("Deployment failed to deploy pods")
}

// Build the port forwarder from restconfig
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward", r.Namespace, pods.Items[0].Name)
hostIP := strings.TrimPrefix(strings.TrimPrefix(client.RestConfig.Host, "http://"), "https://")
url := url.URL{Scheme: "https", Path: path, Host: hostIP}
transport, upgrader, err := spdy.RoundTripperFor(client.RestConfig)
if err != nil {
return nil, err
}
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, &url)
forwarder, err := portforward.New(dialer, []string{fmt.Sprintf("0:%d", r.Parameters.TargetPort)}, r.StopCh, r.ReadyCh, r.Out, r.ErrOut)
if err != nil {
return nil, err
}

return forwarder, nil
}

func (r *RemoteExecutorConfig) Cleanup(ctx context.Context, client *kubernetes.Clientset) error {
err := client.AppsV1().Deployments(r.Namespace).Delete(ctx, r.Name, metav1.DeleteOptions{})
if err != nil {
Expand Down
206 changes: 206 additions & 0 deletions internal/experiments/experiments_execute_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package experiments

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/operantai/secops-chaos/internal/categories"
"github.com/operantai/secops-chaos/internal/k8s"
"github.com/operantai/secops-chaos/internal/verifier"
"gopkg.in/yaml.v3"
)

type ExecuteAPIExperimentConfig struct {
Metadata ExperimentMetadata `yaml:"metadata"`
Parameters ExecuteAPI `yaml:"parameters"`
}

type ExecuteAPI struct {
Targets []ExecuteAPITargets `yaml:"targets"`
}

type ExecuteAPITargets struct {
Target string `yaml:"target"`
Port int `yaml:"port"`
Payloads []ExecuteAPIPayload `yaml:"payloads"`
}

type ExecuteAPIPayload struct {
Description string `yaml:"description"`
Path string `yaml:"path"`
Method string `yaml:"method"`
Headers map[string]string `yaml:"headers"`
Payload string `yaml:"payload"`
ExpectedResponse string `yaml:"expected_response"`
}

type ExecuteAPIResult struct {
ExperimentName string `json:"experiment_name"`
Description string `json:"description"`
Timestamp time.Time `json:"timestamp"`
Status int `json:"status"`
Response string `json:"response"`
}

func (p *ExecuteAPIExperimentConfig) Type() string {
return "execute_api"
}

func (p *ExecuteAPIExperimentConfig) Description() string {
return "This experiment port forwards to a service running in Kubernetes and issues API calls to that service"
}
func (p *ExecuteAPIExperimentConfig) Technique() string {
return categories.MITRE.Execution.ApplicationExploit.Technique
}
func (p *ExecuteAPIExperimentConfig) Tactic() string {
return categories.MITRE.Execution.ApplicationExploit.Tactic
}
func (p *ExecuteAPIExperimentConfig) Framework() string {
return string(categories.Mitre)
}

func (p *ExecuteAPIExperimentConfig) Run(ctx context.Context, client *k8s.Client, experimentConfig *ExperimentConfig) error {
var config ExecuteAPIExperimentConfig
yamlObj, _ := yaml.Marshal(experimentConfig)
err := yaml.Unmarshal(yamlObj, &config)
if err != nil {
return err
}

for _, target := range config.Parameters.Targets {
pf := client.NewPortForwarder(ctx)
if err != nil {
return err
}
defer pf.Stop()
forwardedPort, err := pf.Forward(config.Metadata.Namespace, fmt.Sprintf("app=%s", target.Target), target.Port)
if err != nil {
return err
}
results := make(map[string]ExecuteAPIResult)
for _, payload := range target.Payloads {
url := url.URL{
Scheme: "http",
Host: fmt.Sprintf("%s:%d", pf.Addr(), forwardedPort.Local),
Path: payload.Path,
}

var requestBody io.Reader
if payload.Payload != "" {
requestBody = strings.NewReader(payload.Payload)
}

req, err := http.NewRequest(payload.Method, url.String(), requestBody)
if err != nil {
return err
}

for k, v := range payload.Headers {
req.Header.Add(k, v)
}

response, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer response.Body.Close()

responseBody, err := io.ReadAll(response.Body)
if err != nil {
return err
}

results[payload.Description] = ExecuteAPIResult{
Description: payload.Description,
ExperimentName: config.Metadata.Name,
Timestamp: time.Now(),
Status: response.StatusCode,
Response: string(responseBody),
}
}

resultJSON, err := json.Marshal(results)
if err != nil {
return fmt.Errorf("Failed to marshal experiment results: %w", err)
}

file, err := createTempFile(p.Type(), config.Metadata.Name)
if err != nil {
return fmt.Errorf("Unable to create file cache for experiment results %w", err)
}

_, err = file.Write(resultJSON)
if err != nil {
return fmt.Errorf("Failed to write experiment results: %w", err)
}
}

return nil
}

func (p *ExecuteAPIExperimentConfig) Verify(ctx context.Context, client *k8s.Client, experimentConfig *ExperimentConfig) (*verifier.Outcome, error) {
var config ExecuteAPIExperimentConfig
yamlObj, _ := yaml.Marshal(experimentConfig)
err := yaml.Unmarshal(yamlObj, &config)
if err != nil {
return nil, err
}

v := verifier.New(
config.Metadata.Name,
config.Description(),
config.Framework(),
config.Tactic(),
config.Technique(),
)

rawResults, err := getTempFileContentsForExperiment(p.Type(), config.Metadata.Name)
if err != nil {
return nil, fmt.Errorf("Could not fetch experiment results: %w", err)
}

for _, rawResult := range rawResults {
var results map[string]ExecuteAPIResult
err = json.Unmarshal(rawResult, &results)
if err != nil {
return nil, fmt.Errorf("Could not parse experiment result: %w", err)
}

for _, target := range config.Parameters.Targets {
for _, payload := range target.Payloads {
result, found := results[payload.Description]
if !found {
continue
}
if result.Response == payload.ExpectedResponse {
v.Success(payload.Description)
continue
}
v.Fail(payload.Description)
}
}
}

return v.GetOutcome(), nil
}

func (p *ExecuteAPIExperimentConfig) Cleanup(ctx context.Context, client *k8s.Client, experimentConfig *ExperimentConfig) error {
var config RemoteExecuteAPIExperimentConfig
yamlObj, _ := yaml.Marshal(experimentConfig)
err := yaml.Unmarshal(yamlObj, &config)
if err != nil {
return err
}

if err := removeTempFilesForExperiment(p.Type(), config.Metadata.Name); err != nil {
return err
}

return nil
}
41 changes: 12 additions & 29 deletions internal/experiments/experiments_list_k8s_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ package experiments
import (
"context"
"fmt"
"net/http"
"net/url"

"github.com/operantai/secops-chaos/internal/executor"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"net/http"
"net/url"

"github.com/operantai/secops-chaos/internal/categories"
"github.com/operantai/secops-chaos/internal/k8s"
Expand Down Expand Up @@ -143,44 +144,26 @@ func (p *ListK8sSecretsConfig) Verify(ctx context.Context, client *k8s.Client, e
config.Tactic(),
config.Technique(),
)
executorConfig := executor.NewExecutorConfig(
config.Metadata.Name,
config.Metadata.Namespace,
config.Parameters.ExecutorConfig.Image,
config.Parameters.ExecutorConfig.ImageParameters,
config.Parameters.ExecutorConfig.ServiceAccountName,
config.Parameters.ExecutorConfig.Target.Port,
)

forwarder, err := executorConfig.OpenLocalPort(ctx, client)
pf := client.NewPortForwarder(ctx)
if err != nil {
return nil, err
}

go func() {
err = forwarder.ForwardPorts()
if err != nil {
executorConfig.ErrCh <- fmt.Errorf("Local Port Failed to open: %w", err)
}
}()

// Waits until local port is ready or open errors
select {
case <-executorConfig.ReadyCh:
break
case err := <-executorConfig.ErrCh:
defer pf.Stop()
forwardedPort, err := pf.Forward(
config.Metadata.Namespace,
fmt.Sprintf("app=%s", config.Metadata.Name),
int(config.Parameters.ExecutorConfig.Target.Port),
)
if err != nil {
return nil, err
}

path := config.Parameters.ExecutorConfig.Target.Path
ports, err := forwarder.GetPorts()
if err != nil {
return nil, err
}
for _, namespace := range config.Parameters.Namespaces {
requestUrl := url.URL{
Scheme: "http",
Host: fmt.Sprintf("%s:%d", executorConfig.Addr, int32(ports[0].Local)),
Host: fmt.Sprintf("%s:%d", pf.Addr(), int32(forwardedPort.Local)),
Path: fmt.Sprintf("%s/%s", path, namespace),
}
response, err := http.Get(requestUrl.String())
Expand Down
Loading