From bf503aba1b426a86ce7a3b810f541cf46462ab57 Mon Sep 17 00:00:00 2001 From: Jesus Santos Date: Thu, 19 Mar 2026 23:44:53 -0300 Subject: [PATCH 1/4] feat(export): add sql_enabled/sql_dataset_url/sql_schema_hint to ExportChallenge --- internal/models/export.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/models/export.go b/internal/models/export.go index 432fbb06..e2594e7d 100644 --- a/internal/models/export.go +++ b/internal/models/export.go @@ -30,6 +30,9 @@ type ExportChallenge struct { MinimumPoints int `json:"minimum_points"` DecayThreshold int `json:"decay_threshold"` FileURL string `json:"file_url,omitempty"` + SQLEnabled bool `json:"sql_enabled,omitempty"` + SQLDatasetURL string `json:"sql_dataset_url,omitempty"` + SQLSchemaHint string `json:"sql_schema_hint,omitempty"` Questions []ExportQuestion `json:"questions"` } From 29a8674e9d06b1b69ed03854579edd3fc56ef52c Mon Sep 17 00:00:00 2001 From: Jesus Santos Date: Thu, 19 Mar 2026 23:47:31 -0300 Subject: [PATCH 2/4] feat(export): populate sql fields in ExportBundle Co-Authored-By: Claude Sonnet 4.6 --- handlers_test.go | 40 ++++++++++++++++++++++++++++++++++++ internal/database/queries.go | 7 +++++++ 2 files changed, 47 insertions(+) diff --git a/handlers_test.go b/handlers_test.go index c391fe7d..f7b839ab 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -526,3 +526,43 @@ func newTestRouter(s *Server) *http.ServeMux { return mux } + +func TestExportIncludesSQLFields(t *testing.T) { + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("Failed to create in-memory database: %v", err) + } + defer db.Close() + + datasetURL := "https://example.com/dataset.csv" + schemaHint := "CREATE TABLE test (id INT);" + _, err = db.CreateChallenge( + "SQL Challenge", "A challenge with SQL", "web", "easy", + nil, true, + true, &datasetURL, &schemaHint, + false, 100, 50, 10, nil, + ) + if err != nil { + t.Fatalf("Failed to create challenge: %v", err) + } + + bundle, err := db.ExportBundle() + if err != nil { + t.Fatalf("ExportBundle() error: %v", err) + } + + if len(bundle.Challenges) == 0 { + t.Fatal("ExportBundle returned no challenges") + } + + ec := bundle.Challenges[0] + if !ec.SQLEnabled { + t.Errorf("SQLEnabled: got false, want true") + } + if ec.SQLDatasetURL != datasetURL { + t.Errorf("SQLDatasetURL: got %q, want %q", ec.SQLDatasetURL, datasetURL) + } + if ec.SQLSchemaHint != schemaHint { + t.Errorf("SQLSchemaHint: got %q, want %q", ec.SQLSchemaHint, schemaHint) + } +} diff --git a/internal/database/queries.go b/internal/database/queries.go index 71dfecda..2decdb6e 100644 --- a/internal/database/queries.go +++ b/internal/database/queries.go @@ -2144,6 +2144,13 @@ func (db *DB) ExportBundle() (*models.ExportBundle, error) { if c.FileURL != nil { ec.FileURL = *c.FileURL } + ec.SQLEnabled = c.SQLEnabled + if c.SQLDatasetURL != nil { + ec.SQLDatasetURL = *c.SQLDatasetURL + } + if c.SQLSchemaHint != nil { + ec.SQLSchemaHint = *c.SQLSchemaHint + } questions, err := db.GetQuestionsByChallengeID(c.ID) if err != nil { From 8bc86b65d2d8751fd518fcf07db6e78ab5994289 Mon Sep 17 00:00:00 2001 From: Jesus Santos Date: Thu, 19 Mar 2026 23:50:06 -0300 Subject: [PATCH 3/4] feat(export): restore sql fields on ImportBundle Co-Authored-By: Claude Sonnet 4.6 --- handlers_test.go | 54 ++++++++++++++++++++++++++++++++++++ internal/database/queries.go | 16 +++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/handlers_test.go b/handlers_test.go index f7b839ab..80a9a7db 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -12,6 +12,7 @@ import ( "github.com/ajesus37/hCTF/internal/database" "github.com/ajesus37/hCTF/internal/email" "github.com/ajesus37/hCTF/internal/handlers" + "github.com/ajesus37/hCTF/internal/models" ) // TestPageContent validates that each page renders with correct content @@ -566,3 +567,56 @@ func TestExportIncludesSQLFields(t *testing.T) { t.Errorf("SQLSchemaHint: got %q, want %q", ec.SQLSchemaHint, schemaHint) } } + +func TestImportRestoresSQLFields(t *testing.T) { + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("Failed to create in-memory database: %v", err) + } + defer db.Close() + + challenges := []models.ExportChallenge{ + { + Name: "SQL Challenge", + Description: "test", + Category: "forensics", + Difficulty: "easy", + SQLEnabled: true, + SQLDatasetURL: "https://example.com/data.json.gz", + SQLSchemaHint: "SELECT * FROM dataset", + }, + } + + result, err := db.ImportBundle([]string{"forensics"}, []string{"easy"}, challenges) + if err != nil { + t.Fatalf("ImportBundle() error: %v", err) + } + if result.Imported != 1 { + t.Fatalf("ImportBundle() imported %d, want 1", result.Imported) + } + + imported, err := db.GetChallenges(false) + if err != nil { + t.Fatalf("GetChallenges() error: %v", err) + } + if len(imported) != 1 { + t.Fatalf("GetChallenges() returned %d challenges, want 1", len(imported)) + } + + c := imported[0] + if !c.SQLEnabled { + t.Errorf("SQLEnabled: got false, want true") + } + if c.SQLDatasetURL == nil { + t.Fatal("SQLDatasetURL: got nil, want non-nil") + } + if *c.SQLDatasetURL != "https://example.com/data.json.gz" { + t.Errorf("SQLDatasetURL: got %q, want %q", *c.SQLDatasetURL, "https://example.com/data.json.gz") + } + if c.SQLSchemaHint == nil { + t.Fatal("SQLSchemaHint: got nil, want non-nil") + } + if *c.SQLSchemaHint != "SELECT * FROM dataset" { + t.Errorf("SQLSchemaHint: got %q, want %q", *c.SQLSchemaHint, "SELECT * FROM dataset") + } +} diff --git a/internal/database/queries.go b/internal/database/queries.go index 2decdb6e..b182ea31 100644 --- a/internal/database/queries.go +++ b/internal/database/queries.go @@ -2225,12 +2225,22 @@ func (db *DB) ImportBundle(categories, difficulties []string, challenges []model if ec.FileURL != "" { fileURL = ec.FileURL } + var sqlDatasetURL interface{} + if ec.SQLDatasetURL != "" { + sqlDatasetURL = ec.SQLDatasetURL + } + var sqlSchemaHint interface{} + if ec.SQLSchemaHint != "" { + sqlSchemaHint = ec.SQLSchemaHint + } _, err := db.Exec(` INSERT INTO challenges (id, name, description, category, difficulty, visible, - dynamic_scoring, initial_points, minimum_points, decay_threshold, file_url, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + dynamic_scoring, initial_points, minimum_points, decay_threshold, file_url, + sql_enabled, sql_dataset_url, sql_schema_hint, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, cID, name, ec.Description, ec.Category, ec.Difficulty, ec.Visible, - ec.DynamicScoring, ec.InitialPoints, ec.MinimumPoints, ec.DecayThreshold, fileURL) + ec.DynamicScoring, ec.InitialPoints, ec.MinimumPoints, ec.DecayThreshold, fileURL, + ec.SQLEnabled, sqlDatasetURL, sqlSchemaHint) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Failed to import %q: %v", ec.Name, err)) continue From 69fc1b5800a703f6595fb36e7a22f509d19811e4 Mon Sep 17 00:00:00 2001 From: Jesus Santos Date: Thu, 19 Mar 2026 23:52:18 -0300 Subject: [PATCH 4/4] chore: add sql_enabled and dataset_url to cloudtrail challenge export --- 01-incident-data-analysis-pt2.json | 273 +++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 01-incident-data-analysis-pt2.json diff --git a/01-incident-data-analysis-pt2.json b/01-incident-data-analysis-pt2.json new file mode 100644 index 00000000..049c3005 --- /dev/null +++ b/01-incident-data-analysis-pt2.json @@ -0,0 +1,273 @@ +{ + "version": 1, + "exported_at": "2026-03-19T00:00:00Z", + "categories": ["forensics"], + "difficulties": ["easy", "medium", "hard"], + "challenges": [ + { + "name": "Incident Data Analysis with Modern Data Tools - Pt. 2", + "description": "A company has suffered a security incident. CloudTrail logs have been recovered and loaded into the SQL Playground below.\n\nYour job is to analyze the logs, reconstruct the attack chain, and answer the questions below.\n\nThe attacker had a foothold — find out what they did with it.", + "category": "forensics", + "difficulty": "medium", + "visible": false, + "sql_enabled": true, + "sql_dataset_url": "https://forgejo.local.heaven-labs.com/jesus/ctf-challenges/raw/branch/main/01.incident-data-analysis-with-modern-data-tools.pt2/.consolidated/cloudtrail.json.gz", + "dynamic_scoring": false, + "initial_points": 0, + "minimum_points": 0, + "decay_threshold": 0, + "questions": [ + { + "name": "First Contact", + "description": "What IP address did the attacker operate from?", + "flag": "130.185.119.166", + "flag_mask": "###.###.###.###", + "case_sensitive": false, + "points": 50, + "hints": [ + { + "content": "Look for the first event where the attacker verified their access. What called `GetCallerIdentity`?", + "cost": 0, + "order": 1 + }, + { + "content": "Filter CloudTrail events by `eventName = 'GetCallerIdentity'` and look at the `sourceIPAddress` column.", + "cost": 5, + "order": 2 + }, + { + "content": "The IP is not an AWS service IP — it's a real external address. It appears consistently across all attacker actions.", + "cost": 10, + "order": 3 + } + ] + }, + { + "name": "Who Are You?", + "description": "What IAM username did the attacker use to carry out the attack?", + "flag": "forgejo-ci-cd", + "flag_mask": "**********-**-**", + "case_sensitive": true, + "points": 50, + "hints": [ + { + "content": "Look at the `userIdentity` field in the CloudTrail events. The attacker used a specific IAM user, not root.", + "cost": 0, + "order": 1 + }, + { + "content": "Filter for events where `userIdentity.type = 'IAMUser'` and look at the `userIdentity.userName` values.", + "cost": 5, + "order": 2 + }, + { + "content": "The username is designed to look like a legitimate CI/CD service account for a well-known self-hosted Git platform.", + "cost": 10, + "order": 3 + } + ] + }, + { + "name": "Skeleton Key", + "description": "What access key ID was used during the attack?", + "flag": "AKIAVX3VQZD4KIDZEA26", + "flag_mask": "AKIA****************", + "case_sensitive": true, + "points": 100, + "hints": [ + { + "content": "Access key IDs appear in the `userIdentity.accessKeyId` field of CloudTrail events.", + "cost": 0, + "order": 1 + }, + { + "content": "Filter for events performed by the attacker's IAM user and look at the `accessKeyId` column.", + "cost": 5, + "order": 2 + }, + { + "content": "AWS access key IDs always start with `AKIA` for long-term credentials.", + "cost": 10, + "order": 3 + } + ] + }, + { + "name": "Verify Before You Strike", + "description": "What was the first AWS action the attacker performed to verify their access?", + "flag": "GetCallerIdentity", + "flag_mask": "Get**************", + "case_sensitive": true, + "points": 50, + "hints": [ + { + "content": "Think about what an attacker would do first after obtaining credentials — they'd want to confirm the credentials actually work.", + "cost": 0, + "order": 1 + }, + { + "content": "Look at the earliest `eventTime` for the attacker's IAM user and check the `eventName`.", + "cost": 5, + "order": 2 + }, + { + "content": "This is a read-only STS action that returns who you are — commonly used to validate stolen credentials.", + "cost": 10, + "order": 3 + } + ] + }, + { + "name": "Locked Doors", + "description": "The attacker tried to escalate their access but hit walls. What three AWS actions were denied? (comma-separated, alphabetical order)", + "flag": "DescribeInstances,ListPolicies,ListUsers", + "flag_mask": "****************,************,*********", + "case_sensitive": true, + "points": 150, + "hints": [ + { + "content": "Look for events where the `errorCode` field contains an access denied error.", + "cost": 0, + "order": 1 + }, + { + "content": "Filter for `errorCode = 'AccessDenied'` or `errorCode = 'Client.UnauthorizedOperation'` in the attacker's events.", + "cost": 10, + "order": 2 + }, + { + "content": "The three actions span IAM (two events) and EC2 (one event). The attacker was testing the boundaries of their S3-only permissions.", + "cost": 20, + "order": 3 + } + ] + }, + { + "name": "The Target", + "description": "What S3 bucket did the attacker exfiltrate data from?", + "flag": "definetly-company-trading-secrets", + "flag_mask": "********-*******-*******-*******", + "case_sensitive": true, + "points": 100, + "hints": [ + { + "content": "Look for S3 events — specifically `ListObjects` or `GetObject` calls — and check which bucket was targeted.", + "cost": 0, + "order": 1 + }, + { + "content": "Filter for `eventSource = 's3.amazonaws.com'` and look at the `requestParameters.bucketName` field.", + "cost": 5, + "order": 2 + }, + { + "content": "The bucket name is a deliberate typosquat — it closely resembles a legitimate bucket name in the account but has extra words appended.", + "cost": 10, + "order": 3 + } + ] + }, + { + "name": "How Much Did They Take?", + "description": "How many files did the attacker successfully exfiltrate from the bucket?", + "flag": "136", + "flag_mask": "###", + "case_sensitive": false, + "points": 100, + "hints": [ + { + "content": "Exfiltration shows up as `GetObject` calls in S3 CloudTrail events.", + "cost": 0, + "order": 1 + }, + { + "content": "Count the number of `GetObject` events performed by the attacker's user against the target bucket.", + "cost": 5, + "order": 2 + }, + { + "content": "Try: `SELECT COUNT(*) FROM dataset WHERE eventName = 'GetObject' AND userIdentity_userName = '...'`", + "cost": 15, + "order": 3 + } + ] + }, + { + "name": "Scorched Earth", + "description": "After exfiltrating the data, the attacker destroyed evidence. How many files were deleted?", + "flag": "124", + "flag_mask": "###", + "case_sensitive": false, + "points": 100, + "hints": [ + { + "content": "Destruction of evidence shows up as `DeleteObject` or `DeleteObjects` calls in S3 events.", + "cost": 0, + "order": 1 + }, + { + "content": "Count the `DeleteObject` events performed by the attacker's user — note this may differ from the number of files exfiltrated.", + "cost": 5, + "order": 2 + }, + { + "content": "The number of files deleted is slightly less than the number exfiltrated — not all files were wiped in the cleanup.", + "cost": 15, + "order": 3 + } + ] + }, + { + "name": "Parting Words", + "description": "The attacker left a message behind. What was the name of the file they dropped in the bucket?", + "flag": "YOU_HAVE_BEEN_HACKED.txt", + "flag_mask": "***_****_****_******.***", + "case_sensitive": true, + "points": 50, + "hints": [ + { + "content": "After the deletions, the attacker performed one final `PutObject` call. What was the key name?", + "cost": 0, + "order": 1 + }, + { + "content": "Filter for `eventName = 'PutObject'` on the target bucket and look at `requestParameters.key`.", + "cost": 5, + "order": 2 + }, + { + "content": "It's the kind of message a ransomware group or hacktivist would leave. All caps, underscores.", + "cost": 10, + "order": 3 + } + ] + }, + { + "name": "The Policy That Made It Possible", + "description": "What AWS managed policy was attached to the attacker's IAM user that enabled the entire exfiltration?", + "flag": "AmazonS3FullAccess", + "flag_mask": "Amazon**Full******", + "case_sensitive": true, + "points": 150, + "hints": [ + { + "content": "Look for `AttachUserPolicy` events in the logs — this is when a policy is granted to a user.", + "cost": 0, + "order": 1 + }, + { + "content": "Filter for `eventName = 'AttachUserPolicy'` and check the `requestParameters.policyArn` for the attacker's username.", + "cost": 5, + "order": 2 + }, + { + "content": "The policy ARN follows the format `arn:aws:iam::aws:policy/`. The attacker only needed storage access — not full admin.", + "cost": 15, + "order": 3 + } + ] + } + ] + } + ] +}