added material

This commit is contained in:
Jeremy Janella
2026-07-26 22:53:03 -04:00
commit 95b1c6bf27
369 changed files with 3468754 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
import argparse
import base64
import io
import json
from dataclasses import dataclass
import os
import time
from typing import Tuple
import numpy as np
import requests
from PIL import Image
import torch
import torch.nn as nn
# Define MNIST normalization constants
MNIST_MEAN = 0.1307 # average pixel intensity of MNIST images scaled to [0,1]
MNIST_STD = 0.3081 # standard deviation of pixel intensities in [0,1]
class SimpleClassifier(nn.Module):
"""CNN matching the server-side architecture with log-softmax outputs."""
def __init__(self) -> None:
super().__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.conv1(x)
x = torch.relu(x)
x = self.conv2(x)
x = torch.relu(x)
x = torch.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = torch.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
return torch.log_softmax(x, dim=1)
def mnist_normalize(x01: torch.Tensor) -> torch.Tensor:
"""Normalize a [0,1] tensor to MNIST stats for the classifier."""
return (x01 - MNIST_MEAN) / MNIST_STD
def png_from_x01(x01: np.ndarray) -> str:
"""Encode a `[0,1]` grayscale image `(28,28)` to base64 PNG string."""
x255 = np.clip((x01 * 255.0).round(), 0, 255).astype(np.uint8)
img = Image.fromarray(x255, mode="L")
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode("ascii")
def png_from_x_anysize(x01: np.ndarray, size: tuple[int, int]) -> str:
"""Encode a `[0,1]` grayscale array to base64 PNG of a specific size.
Parameters
----------
x01 : np.ndarray
Input 2D array in `[0,1]`.
size : (int, int)
Target `(width, height)` for the PNG.
"""
x255 = np.clip((x01 * 255.0).round(), 0, 255).astype(np.uint8)
img = Image.fromarray(x255, mode="L").resize(size, resample=Image.NEAREST)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode("ascii")
def x01_from_b64_png(b64: str) -> np.ndarray:
"""Decode base64 PNG to `[0,1]` numpy array of shape `(28,28)`."""
raw = base64.b64decode(b64)
img = Image.open(io.BytesIO(raw)).convert("L")
if img.size != (28, 28):
raise ValueError("Expected 28x28 PNG")
x = np.asarray(img, dtype=np.float32) / 255.0
return np.clip(x, 0.0, 1.0)
@dataclass
class Challenge:
l2_threshold: float
target: int
label: int
sample_index: int
x01: np.ndarray # (1,1,28,28)
def fetch_challenge(host: str, retries: int = 30, delay: float = 1.0) -> Challenge:
"""Fetch challenge with simple retry/backoff to tolerate startup races."""
last_err = None
for _ in range(max(1, retries)):
try:
r = requests.get(f"{host}/challenge", timeout=5)
r.raise_for_status()
payload = r.json()
break
except Exception as e: # noqa: BLE001
last_err = e
time.sleep(delay)
else:
raise RuntimeError(
f"Failed to connect to {host}/challenge: {last_err}"
) from last_err
x2d = x01_from_b64_png(payload["image_b64"]) # (28,28)
x4d = x2d[None, None, ...]
return Challenge(
l2_threshold=float(payload["l2_threshold"]),
target=int(payload["target"]),
label=int(payload["label"]),
sample_index=int(payload["sample_index"]),
x01=x4d.astype(np.float32),
)
def load_model(weights_path: str) -> SimpleClassifier:
model = SimpleClassifier()
state = torch.load(weights_path, map_location=torch.device("cpu"))
model.load_state_dict(state)
model.eval()
return model
def deepfool_targeted(
model: nn.Module,
x01: np.ndarray,
target: int,
overshoot: float = 0.08,
max_iter: int = 100,
) -> np.ndarray:
"""Compute a targeted DeepFool adversarial example in `[0,1]` pixel space.
The update follows the linearized boundary between the current predicted
class and the fixed `target` class, stepping by the minimal L2 amount
required to cross that boundary, with a small overshoot. Iterates are
clamped to `[0,1]` to preserve a valid image domain.
Parameters
----------
model : nn.Module
Classifier in eval mode.
x01 : np.ndarray
Baseline `[0,1]` image with shape `(1,1,28,28)`.
target : int
Desired target class in `[0, 9]`.
overshoot : float, optional
Multiplicative margin used on the accumulated perturbation to remain
across the decision boundary after PNG quantization, by default 0.08.
max_iter : int, optional
Maximum number of iterations, by default 100.
Returns
-------
np.ndarray
Adversarial image in `[0,1]` with shape `(1,1,28,28)`.
"""
x01_t = torch.from_numpy(x01).float()
r_tot = torch.zeros_like(x01_t)
with torch.enable_grad():
for _ in range(max_iter):
x = (
torch.clamp(x01_t + (1 + overshoot) * r_tot, 0.0, 1.0)
.detach()
.requires_grad_(True)
)
logits = model(mnist_normalize(x))
pred = int(torch.argmax(logits, dim=1).item())
if pred == target:
break
# Gradients for current prediction and target
model.zero_grad(set_to_none=True)
logits[0, pred].backward(retain_graph=True)
grad_pred = x.grad.detach().clone()
x.grad.zero_()
model.zero_grad(set_to_none=True)
logits[0, target].backward(retain_graph=True)
grad_t = x.grad.detach().clone()
x.grad.zero_()
w = grad_t - grad_pred
g = (logits[0, target] - logits[0, pred]).detach()
denom = torch.norm(w.flatten()) + 1e-12
p = torch.abs(g) / denom
r_i = (p + 1e-4) * w / (torch.norm(w.flatten()) + 1e-12)
r_tot = r_tot + r_i
x_adv = torch.clamp(x01_t + (1 + overshoot) * r_tot, 0.0, 1.0)
return x_adv.detach().cpu().numpy()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--host", default="154.57.164.67:31825", help="Server base URL"
)
parser.add_argument(
"--weights", default="solver/deepfool_weights.pth", help="Path to model weights (downloads from /weights if missing)"
)
args = parser.parse_args()
chall = fetch_challenge(args.host)
if not os.path.exists(args.weights):
os.makedirs(os.path.dirname(args.weights), exist_ok=True)
wb = requests.get(f"{args.host}/weights", timeout=15).content
with open(args.weights, "wb") as f:
f.write(wb)
model = load_model(args.weights)
# Local sanity prediction on clean image
x = torch.from_numpy(chall.x01)
clean_pred = int(torch.argmax(model(mnist_normalize(x)), dim=1).item())
if clean_pred != chall.label:
print(
f"Warning: local clean prediction {clean_pred} != label {chall.label}; proceeding regardless"
)
# Run targeted DeepFool with a robust overshoot schedule against PNG quantization
overshoots = [0.08, 0.10, 0.12, 0.15, 0.25]
x_adv = None
adv_pred = None
l2 = None
for ov in overshoots:
cand = deepfool_targeted(
model, chall.x01, target=chall.target, overshoot=ov, max_iter=100
)
cand_pred = int(
torch.argmax(model(mnist_normalize(torch.from_numpy(cand))), dim=1).item()
)
cand_l2 = float(np.linalg.norm(cand - chall.x01))
if cand_pred == chall.target and cand_l2 <= chall.l2_threshold:
x_adv, adv_pred, l2 = cand, cand_pred, cand_l2
break
if x_adv is None:
# Take the closest candidate and proceed; server will validate
cand = deepfool_targeted(
model, chall.x01, target=chall.target, overshoot=overshoots[0], max_iter=100
)
x_adv = cand
adv_pred = int(
torch.argmax(model(mnist_normalize(torch.from_numpy(cand))), dim=1).item()
)
l2 = float(np.linalg.norm(cand - chall.x01))
print(
json.dumps(
{
"l2": l2,
"pred": adv_pred,
"clean_pred": clean_pred,
"threshold": chall.l2_threshold,
"target": chall.target,
},
indent=2,
)
)
# Submit
b64 = png_from_x01(x_adv[0, 0])
r = requests.post(f"{args.host}/submit", json={"image_b64": b64}, timeout=15)
try:
r.raise_for_status()
except Exception:
print("Server response:", r.text)
raise
print("Flag:", r.json().get("flag"))
# Robustness tests (negative cases)
print("\nRobustness checks (expected rejections):")
def attempt(desc: str, payload: dict | None, path: str = "/submit") -> dict:
try:
if payload is None:
resp = requests.post(f"{args.host}{path}", json={}, timeout=10)
else:
resp = requests.post(f"{args.host}{path}", json=payload, timeout=10)
status = resp.status_code
detail = None
try:
detail = resp.json()
except Exception:
detail = {"text": resp.text}
return {"case": desc, "status": status, "detail": detail}
except Exception as e: # noqa: BLE001
return {"case": desc, "status": None, "error": str(e)}
base2d = chall.x01[0, 0]
cases = []
# 1) Clean image (should fail: not the target class)
cases.append(
attempt(
"clean-image",
{"image_b64": png_from_x01(base2d)},
)
)
# 2) Too large L2 (zeros image), likely misclassified but exceeds threshold
zeros2d = np.zeros_like(base2d, dtype=np.float32)
cases.append(
attempt(
"l2-too-large",
{"image_b64": png_from_x01(zeros2d)},
)
)
# 3) Wrong image size (32x32)
cases.append(
attempt(
"wrong-size-32x32",
{"image_b64": png_from_x_anysize(base2d, (32, 32))},
)
)
# 4) Invalid base64/PNG
cases.append(
attempt(
"invalid-base64",
{"image_b64": "not_base64_png_data"},
)
)
# 5) Missing field (schema error)
cases.append(attempt("missing-field", None))
# 6) Predict endpoint with wrong size
cases.append(
attempt(
"predict-wrong-size",
{"image_b64": png_from_x_anysize(base2d, (30, 30))},
path="/predict",
)
)
# 7) Adversarial but wrong target (should fail targeted check while within L2)
def craft_wrong_target() -> dict | None:
# Try all non-target classes and several overshoots; pick first that meets L2 and predicts that class
classes = [c for c in range(10) if c != chall.target]
for c in classes:
for ov in [0.08, 0.10, 0.12, 0.15, 0.25]:
cand = deepfool_targeted(
model, chall.x01, target=c, overshoot=ov, max_iter=100
)
# Quantization round-trip to match server path
b64 = png_from_x01(cand[0, 0])
x2d = x01_from_b64_png(b64)
cand_q = x2d[None, None, ...].astype(np.float32)
pred = int(
torch.argmax(
model(mnist_normalize(torch.from_numpy(cand_q))), dim=1
).item()
)
l2_q = float(np.linalg.norm(cand_q - chall.x01))
if pred == c and l2_q <= chall.l2_threshold:
return {
"image_b64": b64,
"meta": {"class": c, "l2": l2_q, "overshoot": ov},
}
return None
wrong = craft_wrong_target()
if wrong is not None:
cases.append(
attempt("adversarial-wrong-target", {"image_b64": wrong["image_b64"]})
)
else:
cases.append(
{
"case": "adversarial-wrong-target",
"status": None,
"detail": {"note": "could not construct within L2"},
}
)
print(json.dumps({"negative_cases": cases}, indent=2))
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import argparse
import base64
import io
import json
from dataclasses import dataclass
import os
import time
from typing import Tuple
import numpy as np
import requests
from PIL import Image
import torch
import torch.nn as nn
# Define MNIST normalization constants
MNIST_MEAN = 0.1307 # average pixel intensity of MNIST images scaled to [0,1]
MNIST_STD = 0.3081 # standard deviation of pixel intensities in [0,1]
class SimpleClassifier(nn.Module):
"""CNN matching the server-side architecture with log-softmax outputs."""
def __init__(self) -> None:
super().__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.conv1(x)
x = torch.relu(x)
x = self.conv2(x)
x = torch.relu(x)
x = torch.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = torch.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
return torch.log_softmax(x, dim=1)
def mnist_normalize(x01: torch.Tensor) -> torch.Tensor:
"""Normalize a [0,1] tensor to MNIST stats for the classifier."""
return (x01 - MNIST_MEAN) / MNIST_STD
def png_from_x01(x01: np.ndarray) -> str:
"""Encode a `[0,1]` grayscale image `(28,28)` to base64 PNG string."""
x255 = np.clip((x01 * 255.0).round(), 0, 255).astype(np.uint8)
img = Image.fromarray(x255, mode="L")
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return base64.b64encode(buf.getvalue()).decode("ascii")
def x01_from_b64_png(b64: str) -> np.ndarray:
"""Decode base64 PNG to `[0,1]` numpy array of shape `(28,28)`."""
raw = base64.b64decode(b64)
img = Image.open(io.BytesIO(raw)).convert("L")
if img.size != (28, 28):
raise ValueError("Expected 28x28 PNG")
x = np.asarray(img, dtype=np.float32) / 255.0
return np.clip(x, 0.0, 1.0)
@dataclass
class Challenge:
epsilon: float
label: int
sample_index: int
x01: np.ndarray # (1,1,28,28)
def fetch_challenge(host: str, retries: int = 30, delay: float = 1.0) -> Challenge:
"""Fetch challenge with simple retry/backoff to tolerate startup races."""
last_err = None
for _ in range(max(1, retries)):
try:
r = requests.get(f"{host}/challenge", timeout=5)
r.raise_for_status()
payload = r.json()
break
except Exception as e: # noqa: BLE001
last_err = e
time.sleep(delay)
else:
raise RuntimeError(
f"Failed to connect to {host}/challenge: {last_err}"
) from last_err
x2d = x01_from_b64_png(payload["image_b64"]) # (28,28)
x4d = x2d[None, None, ...]
return Challenge(
epsilon=float(payload["epsilon"]),
label=int(payload["label"]),
sample_index=int(payload["sample_index"]),
x01=x4d.astype(np.float32),
)
def load_model(weights_path: str) -> SimpleClassifier:
model = SimpleClassifier()
state = torch.load(weights_path, map_location=torch.device("cpu"))
model.load_state_dict(state)
model.eval()
return model
def fgsm_untargeted(
model: nn.Module, x01: np.ndarray, y: int, epsilon: float
) -> np.ndarray:
"""Craft FGSM adversarial example under L_inf in [0,1].
Parameters
----------
model : nn.Module
Classifier in eval mode.
x01 : np.ndarray
Baseline `[0,1]` image with shape `(1,1,28,28)`.
y : int
True class label.
epsilon : float
L_inf bound in `[0,1]` space.
Returns
-------
np.ndarray
Adversarial image in `[0,1]` with shape `(1,1,28,28)`.
"""
x = torch.from_numpy(x01).float().requires_grad_(True)
target = torch.tensor([y], dtype=torch.long)
logits = model(mnist_normalize(x))
loss = nn.NLLLoss()(logits, target)
model.zero_grad()
loss.backward()
grad_sign = x.grad.detach().sign()
x_adv = x + epsilon * grad_sign
x_adv = torch.clamp(x_adv, 0.0, 1.0)
return x_adv.detach().cpu().numpy()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--host", default="http://154.57.164.80:31923", help="Server base URL"
)
parser.add_argument(
"--weights", default="solver/fgsm_weights.pth", help="Path to model weights (downloads from /weights if missing)"
)
args = parser.parse_args()
chall = fetch_challenge(args.host)
if not os.path.exists(args.weights):
os.makedirs(os.path.dirname(args.weights), exist_ok=True)
wb = requests.get(f"{args.host}/weights", timeout=15).content
with open(args.weights, "wb") as f:
f.write(wb)
model = load_model(args.weights)
# Local sanity prediction on clean image
x = torch.from_numpy(chall.x01)
clean_pred = int(torch.argmax(model(mnist_normalize(x)), dim=1).item())
if clean_pred != chall.label:
print(
f"Warning: local clean prediction {clean_pred} != label {chall.label}; proceeding regardless"
)
# Try conservative epsilons to account for 8-bit PNG quantization
safety = [1.0 / 255.0 + 1e-4, 2e-3, 1e-3, 5e-4, 0.0]
x_adv = None
adv_pred = None
linf = None
for m in safety:
eps_eff = max(0.0, chall.epsilon - m)
cand = fgsm_untargeted(model, chall.x01, chall.label, eps_eff)
cand_pred = int(
torch.argmax(model(mnist_normalize(torch.from_numpy(cand))), dim=1).item()
)
if cand_pred != chall.label:
x_adv = cand
adv_pred = cand_pred
linf = float(np.max(np.abs(x_adv - chall.x01)))
break
if x_adv is None:
# Fall back to slight under-shoot of epsilon
eps_eff = chall.epsilon * 0.99
x_adv = fgsm_untargeted(model, chall.x01, chall.label, eps_eff)
adv_pred = int(
torch.argmax(model(mnist_normalize(torch.from_numpy(x_adv))), dim=1).item()
)
linf = float(np.max(np.abs(x_adv - chall.x01)))
print(
json.dumps({"linf": linf, "pred": adv_pred, "clean_pred": clean_pred}, indent=2)
)
# Submit
x2d = x_adv[0, 0]
b64 = png_from_x01(x2d)
r = requests.post(f"{args.host}/submit", json={"image_b64": b64}, timeout=10)
try:
r.raise_for_status()
except Exception:
print("Server response:", r.text)
raise
print("Flag:", r.json().get("flag"))
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.
+655
View File
@@ -0,0 +1,655 @@
import argparse
import requests
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as transforms
from PIL import Image
import io
import base64
import numpy as np
import sys
import os
def base64_to_tensor(base64_str: str) -> torch.Tensor:
"""
Convert base64-encoded PNG to tensor.
Args:
base64_str: Base64-encoded PNG string
Returns:
torch.Tensor: Image tensor in [0,1] range with shape (C, H, W)
"""
img_bytes = base64.b64decode(base64_str)
img = Image.open(io.BytesIO(img_bytes))
tensor = transforms.ToTensor()(img)
return tensor
def tensor_to_base64(tensor: torch.Tensor) -> str:
"""
Convert tensor to base64-encoded PNG.
Args:
tensor: Image tensor in [0,1] range with shape (C, H, W)
Returns:
str: Base64-encoded PNG string
"""
img_array = (tensor.permute(1, 2, 0).numpy() * 255).astype(np.uint8)
img = Image.fromarray(img_array)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
return base64.b64encode(buffer.getvalue()).decode("utf-8")
class CIFAR10CNN(nn.Module):
"""
Simple CNN for CIFAR-10 classification.
Architecture:
- Conv block 1: 3→32 channels, BatchNorm, ReLU, MaxPool
- Conv block 2: 32→64 channels, BatchNorm, ReLU, MaxPool
- FC1: 64*8*8 → 128, ReLU, Dropout(0.5)
- FC2: 128 → 10 (logits)
"""
def __init__(self, num_classes: int = 10):
"""
Initialize the CNN.
Args:
num_classes: Number of output classes (default: 10 for CIFAR-10)
"""
super(CIFAR10CNN, self).__init__()
# First convolutional block
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(32)
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2, 2) # 32x32 -> 16x16
# Second convolutional block
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2, 2) # 16x16 -> 8x8
# Fully connected layers
self.fc1 = nn.Linear(64 * 8 * 8, 128)
self.relu3 = nn.ReLU()
self.dropout = nn.Dropout(0.5)
self.fc2 = nn.Linear(128, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass.
Args:
x: Input tensor of shape (batch_size, 3, 32, 32)
Returns:
torch.Tensor: Logits of shape (batch_size, num_classes)
"""
x = self.pool1(self.relu1(self.bn1(self.conv1(x))))
x = self.pool2(self.relu2(self.bn2(self.conv2(x))))
x = x.view(x.size(0), -1) # Flatten
x = self.dropout(self.relu3(self.fc1(x)))
x = self.fc2(x)
return x
def load_model(model_path: str, device: str = "cuda") -> CIFAR10CNN:
"""
Load trained CIFAR-10 model.
Args:
model_path: Path to model checkpoint (.pth file)
device: Device to load model on ('cuda' or 'cpu')
Returns:
CIFAR10CNN: Loaded model in eval mode
"""
model = CIFAR10CNN(num_classes=10)
# Load checkpoint
checkpoint = torch.load(model_path, map_location=device)
# Handle both direct state_dict and checkpoint dict formats
if isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
model.load_state_dict(checkpoint["model_state_dict"])
else:
model.load_state_dict(checkpoint)
model = model.to(device)
model.eval()
return model
def ifgsm_targeted_attack(
model: nn.Module,
image: torch.Tensor,
target_class: int,
epsilon: float,
mean: list,
std: list,
num_iterations: int = 50,
alpha: float = None,
device: str = "cuda",
) -> torch.Tensor:
"""
Implement Iterative FGSM targeted attack.
This implements the complete I-FGSM algorithm:
1. Start with clean image
2. For each iteration:
- Compute loss with respect to target class (minimize for targeted)
- Compute gradient of loss w.r.t. input
- Take small step in direction that decreases loss
- Project perturbation to L∞ ball
- Clip to valid range [0,1]
Args:
model: Trained model
image: Clean image tensor in [0,1] range, shape (3, 32, 32)
target_class: Target class to achieve
epsilon: L∞ perturbation budget
mean: Normalization mean per channel
std: Normalization std per channel
num_iterations: Number of iterations
alpha: Step size (defaults to epsilon/num_iterations)
device: Device to run attack on
Returns:
torch.Tensor: Adversarial image in [0,1] range
"""
# Step size: divide epsilon by number of iterations for fine control
if alpha is None:
alpha = epsilon / num_iterations
# Convert normalization params to tensors
mean_t = torch.tensor(mean, device=device).view(3, 1, 1)
std_t = torch.tensor(std, device=device).view(3, 1, 1)
# Move to device and clone
x_adv = image.clone().to(device)
x_orig = image.clone().to(device)
# Target tensor
target = torch.tensor([target_class], device=device)
print(f"\n{'=' * 60}")
print(f"I-FGSM Targeted Attack")
print(f"{'=' * 60}")
print(f"Target class: {target_class}")
print(f"Epsilon: {epsilon:.6f} ({epsilon * 255:.1f}/255)")
print(f"Iterations: {num_iterations}")
print(f"Step size (alpha): {alpha:.6f}")
print(f"{'=' * 60}\n")
for iteration in range(num_iterations):
# Normalize image for model input
x_norm = (x_adv - mean_t) / std_t
x_norm.requires_grad = True
# Forward pass
outputs = model(x_norm.unsqueeze(0))
# Targeted attack: minimize loss toward target class
loss = F.cross_entropy(outputs, target)
# Backward pass to get gradients
model.zero_grad()
loss.backward()
grad_norm = x_norm.grad
# Convert gradient from normalized space to pixel space
# Chain rule: d/dx = d/dx_norm * dx_norm/dx = grad_norm * (1/std)
grad_pixel = grad_norm / std_t
# Targeted attack: move in NEGATIVE gradient direction (minimize loss)
x_adv = x_adv - alpha * grad_pixel.sign()
# Project to L∞ ball around original image
delta = x_adv - x_orig
delta = torch.clamp(delta, -epsilon, epsilon)
x_adv = x_orig + delta
# Clip to valid pixel range
x_adv = torch.clamp(x_adv, 0.0, 1.0)
# Detach for next iteration
x_adv = x_adv.detach()
if (iteration + 1) % 10 == 0:
# Check current prediction
with torch.no_grad():
x_norm_check = (x_adv - mean_t) / std_t
pred = model(x_norm_check.unsqueeze(0)).argmax(dim=1).item()
print(
f" Iteration {iteration + 1}/{num_iterations} - Current prediction: {pred}"
)
print(f"\n{'=' * 60}")
print(f"Attack complete!")
print(f"{'=' * 60}\n")
return x_adv.detach().cpu()
def test_api_robustness(host: str, image: torch.Tensor) -> bool:
"""
Test API robustness against invalid inputs.
Args:
host: Challenge server host URL
image: Clean challenge image
Returns:
bool: True if all robustness tests pass
"""
print(f"\n{'=' * 60}")
print("API Robustness Tests")
print(f"{'=' * 60}\n")
all_passed = True
# Test 1: Invalid JSON
print("[Test 1] Invalid JSON format...")
try:
response = requests.post(
f"{host}/submit",
data="invalid json",
headers={"content-type": "application/json"},
)
if response.status_code == 400:
print(" [+] Correctly rejected invalid JSON")
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 2: Missing image field
print("\n[Test 2] Missing 'image' field...")
try:
response = requests.post(f"{host}/submit", json={"wrong_field": "data"})
if response.status_code == 400:
result = response.json()
if "error" in result and "image" in result["error"].lower():
print(" [+] Correctly rejected missing image field")
else:
print(f" [-] Expected error message about missing image field")
all_passed = False
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 3: Invalid base64
print("\n[Test 3] Invalid base64 encoding...")
try:
response = requests.post(
f"{host}/submit", json={"image": "not_valid_base64!!!"}
)
if response.status_code == 400:
print(" [+] Correctly rejected invalid base64")
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 4: Corrupted image data
print("\n[Test 4] Corrupted image data...")
try:
# Send corrupted base64 that decodes but isn't a valid PNG
corrupted_base64 = base64.b64encode(b"not a valid png image").decode("utf-8")
response = requests.post(f"{host}/submit", json={"image": corrupted_base64})
if response.status_code == 400:
print(" [+] Correctly rejected corrupted image data")
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
print(f"\n{'=' * 60}")
print(f"API Robustness: {'PASSED' if all_passed else 'FAILED'}")
print(f"{'=' * 60}\n")
return all_passed
def test_anti_cheat(
host: str,
model: nn.Module,
image: torch.Tensor,
target_class: int,
epsilon: float,
mean: list,
std: list,
device: str,
) -> bool:
"""
Test anti-cheat mechanisms.
Args:
host: Challenge server host URL
model: Trained model
image: Clean challenge image
target_class: Target class for attack
epsilon: L∞ constraint
mean: Normalization mean
std: Normalization std
device: Device to run on
Returns:
bool: True if all anti-cheat tests pass
"""
print(f"\n{'=' * 60}")
print("Anti-Cheat Mechanism Tests")
print(f"{'=' * 60}\n")
all_passed = True
mean_t = torch.tensor(mean).view(3, 1, 1)
std_t = torch.tensor(std).view(3, 1, 1)
# Test 1: Clean image (no modification)
print("[Test 1] Submitting clean image (should fail)...")
try:
clean_base64 = tensor_to_base64(image)
response = requests.post(f"{host}/submit", json={"image": clean_base64})
result = response.json()
if not result["success"] and not result["validation"]["target_achieved"]:
print(" [+] Correctly rejected clean image")
else:
print(" [-] Should reject clean image (no target achieved)")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 2: Perturbation too large
print("\n[Test 2] Perturbation exceeding epsilon (should fail)...")
try:
large_pert = image.clone()
large_pert = torch.clamp(large_pert + epsilon * 2, 0, 1)
large_base64 = tensor_to_base64(large_pert)
response = requests.post(f"{host}/submit", json={"image": large_base64})
result = response.json()
if not result["success"] and not result["validation"]["linf_satisfied"]:
print(" [+] Correctly rejected oversized perturbation")
else:
print(" [-] Should reject perturbation > epsilon")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 3: Patch-only attack (localized perturbation)
print("\n[Test 3] Localized patch attack (should fail)...")
try:
patch_image = image.clone()
# Only modify top-left 8x8 patch
patch_image[:, :8, :8] = torch.clamp(
patch_image[:, :8, :8] + epsilon * 0.9, 0, 1
)
patch_base64 = tensor_to_base64(patch_image)
response = requests.post(f"{host}/submit", json={"image": patch_base64})
result = response.json()
if not result["success"] and not result["validation"].get(
"sufficient_coverage", True
):
print(" [+] Correctly rejected localized patch")
else:
print(" [-] Should reject patch-only attacks")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 4: Wrong gradient direction (reversed targeted attack)
print("\n[Test 4] Reversed gradient direction (should fail)...")
try:
x_wrong = image.clone().to(device)
# Do targeted attack but with WRONG sign (positive instead of negative)
for _ in range(30):
x_norm = (x_wrong - mean_t.to(device)) / std_t.to(device)
x_norm.requires_grad = True
outputs = model(x_norm.unsqueeze(0))
loss = F.cross_entropy(outputs, torch.tensor([target_class], device=device))
model.zero_grad()
loss.backward()
grad_norm = x_norm.grad
grad_pixel = grad_norm / std_t.to(device)
# WRONG: Use positive sign (should be negative for targeted)
x_wrong = x_wrong + (epsilon / 30) * grad_pixel.sign()
x_wrong = torch.clamp(x_wrong, 0, 1)
x_wrong = x_wrong.cpu()
wrong_base64 = tensor_to_base64(x_wrong)
response = requests.post(f"{host}/submit", json={"image": wrong_base64})
result = response.json()
# Should fail either due to not achieving target OR failing direction check
if not result["success"]:
if result["validation"].get("target_achieved") and not result[
"validation"
].get("direction_check", True):
print(" [+] Correctly rejected wrong gradient direction")
elif not result["validation"].get("target_achieved"):
print(" [+] Wrong direction prevented target achievement")
else:
print(" [-] Should reject reversed gradients")
all_passed = False
else:
print(" [-] Should reject attacks with wrong gradient direction")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
print(f"\n{'=' * 60}")
print(f"Anti-Cheat Tests: {'PASSED' if all_passed else 'FAILED'}")
print(f"{'=' * 60}\n")
return all_passed
def solve_challenge(host: str, device: str = "cuda", run_tests: bool = False):
"""
Main solver function.
Args:
host: Challenge server host URL
device: Device for attack ('cuda' or 'cpu')
run_tests: Whether to run robustness and anti-cheat tests
"""
print(f"\n{'=' * 60}")
print(f"Skills Assessment 1")
print(f"{'=' * 60}\n")
# Determine model path (assume running from solver/ directory)
solver_dir = os.path.dirname(os.path.abspath(__file__))
challenge_dir = os.path.dirname(solver_dir)
assets_dir = os.path.join(challenge_dir, "assets")
model_path = os.path.join(assets_dir, "cifar10_model_best.pth")
# Ensure assets directory exists
os.makedirs(assets_dir, exist_ok=True)
# Fetch model if missing
if not os.path.exists(model_path):
print(f"[1/5] Model not found at {model_path}")
weights_url = f"{host}/model/weights" # Adjust if your endpoint differs
print(f"Attempting to download weights from: {weights_url}")
try:
resp = requests.get(weights_url, timeout=30)
resp.raise_for_status()
with open(model_path, "wb") as f:
f.write(resp.content)
print(f" Weights downloaded and saved to {model_path}")
except requests.exceptions.RequestException as e:
print(f"[-] Error: Failed to download model weights from {weights_url}")
print(f"Details: {e}")
print("Hint: Ensure the challenge server exposes /model/weights and is reachable.")
raise
# Step 1: Load model
print("[1/5] Loading CIFAR-10 model...")
model = load_model(model_path, device=device)
print(f" Model loaded on {device}")
# Step 2: Get challenge parameters
print("\n[2/5] Fetching challenge...")
response = requests.get(f"{host}/challenge")
response.raise_for_status()
challenge = response.json()
print(
f" Original class: {challenge['original_class']} ({challenge['original_class_name']})"
)
print(
f" Target class: {challenge['target_class']} ({challenge['target_class_name']})"
)
print(f" Epsilon (L∞): {challenge['epsilon']:.6f}")
print(f" Max iterations hint: {challenge['max_iterations_hint']}")
# Step 3: Decode challenge image
print("\n[3/5] Decoding challenge image...")
image = base64_to_tensor(challenge["image"])
mean = challenge["normalization"]["mean"]
std = challenge["normalization"]["std"]
print(f" Image shape: {image.shape}")
print(f" Normalization: mean={mean}, std={std}")
# Verify original prediction
with torch.no_grad():
mean_t = torch.tensor(mean).view(3, 1, 1)
std_t = torch.tensor(std).view(3, 1, 1)
img_norm = (image - mean_t) / std_t
orig_pred = model(img_norm.unsqueeze(0).to(device)).argmax(dim=1).item()
print(f" Verified original prediction: {orig_pred}")
# Optional: Run robustness and anti-cheat tests
if run_tests:
print("\n" + "=" * 60)
print("RUNNING SECURITY TESTS")
print("=" * 60)
robustness_passed = test_api_robustness(host, image)
anticheat_passed = test_anti_cheat(
host,
model,
image,
challenge["target_class"],
challenge["epsilon"],
mean,
std,
device,
)
if robustness_passed and anticheat_passed:
print(f"\n{'=' * 60}")
print("[+] ALL SECURITY TESTS PASSED")
print(f"{'=' * 60}\n")
else:
print(f"\n{'=' * 60}")
print("[-] SOME SECURITY TESTS FAILED")
print(f"{'=' * 60}\n")
print("Proceeding with legitimate attack...\n")
# Step 4: Run I-FGSM attack
print("\n[4/5] Running I-FGSM targeted attack...")
adv_image = ifgsm_targeted_attack(
model=model,
image=image,
target_class=challenge["target_class"],
epsilon=challenge["epsilon"],
mean=mean,
std=std,
num_iterations=50,
device=device,
)
# Verify adversarial prediction
with torch.no_grad():
adv_norm = (adv_image - mean_t) / std_t
adv_pred = model(adv_norm.unsqueeze(0).to(device)).argmax(dim=1).item()
print(f" Final adversarial prediction: {adv_pred}")
# Step 5: Submit adversarial example
print("\n[5/5] Submitting adversarial example...")
adv_base64 = tensor_to_base64(adv_image)
response = requests.post(f"{host}/submit", json={"image": adv_base64})
response.raise_for_status()
result = response.json()
# Display results
print(f"\n{'=' * 60}")
print("Validation Results")
print(f"{'=' * 60}")
validation = result["validation"]
print(f"L∞ norm: {validation['linf_norm']:.6f} / {challenge['epsilon']:.6f}")
print(f"L∞ constraint satisfied: {validation['linf_satisfied']}")
print(f"Valid range [0,1]: {validation['valid_range']}")
print(f"Adversarial prediction: {validation['adversarial_class']}")
print(f"Target class: {validation['target_class']}")
print(f"Target achieved: {validation['target_achieved']}")
print(f"\nSuccess: {result['success']}")
if result["success"]:
print(f"\n{'=' * 60}")
print(f"[+] FLAG: {result['flag']}")
print(f"{'=' * 60}\n")
else:
print(f"\n[-] {result['message']}")
if "hint" in result:
print(f"[!] {result['hint']}\n")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Working solver for Skills Assessment 1"
)
parser.add_argument(
"--host",
type=str,
default="http://154.57.164.76:31384",
help="Challenge server host URL (default: http://localhost:8000)",
)
parser.add_argument(
"--device",
type=str,
default="cuda" if torch.cuda.is_available() else "cpu",
help="Device to run attack on (default: cuda if available, else cpu)",
)
parser.add_argument(
"--skip-tests",
action="store_true",
help="Skip API robustness and anti-cheat tests (tests run by default)",
)
args = parser.parse_args()
try:
solve_challenge(args.host, args.device, not args.skip_tests)
except requests.exceptions.ConnectionError:
print(f"\n[-] Error: Could not connect to {args.host}")
except Exception as e:
print(f"\n[-] Error: {str(e)}\n")
raise
if __name__ == "__main__":
main()
+702
View File
@@ -0,0 +1,702 @@
#!/usr/bin/env python3
import argparse
import requests
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as transforms
from PIL import Image
import io
import base64
import numpy as np
import sys
import os
def base64_to_tensor(base64_str: str) -> torch.Tensor:
"""
Convert base64-encoded PNG to tensor.
Args:
base64_str: Base64-encoded PNG string
Returns:
torch.Tensor: Image tensor in [0,1] range with shape (C, H, W)
"""
img_bytes = base64.b64decode(base64_str)
img = Image.open(io.BytesIO(img_bytes))
tensor = transforms.ToTensor()(img)
return tensor
def tensor_to_base64(tensor: torch.Tensor) -> str:
"""
Convert tensor to base64-encoded PNG.
Args:
tensor: Image tensor in [0,1] range with shape (C, H, W)
Returns:
str: Base64-encoded PNG string
"""
img_array = (tensor.permute(1, 2, 0).numpy() * 255).astype(np.uint8)
img = Image.fromarray(img_array)
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return base64.b64encode(buffer.getvalue()).decode('utf-8')
class CIFAR10CNN(nn.Module):
"""
Simple CNN for CIFAR-10 classification.
Architecture:
- Conv block 1: 3→32 channels, BatchNorm, ReLU, MaxPool
- Conv block 2: 32→64 channels, BatchNorm, ReLU, MaxPool
- FC1: 64*8*8 → 128, ReLU, Dropout(0.5)
- FC2: 128 → 10 (logits)
"""
def __init__(self, num_classes: int = 10):
"""
Initialize the CNN.
Args:
num_classes: Number of output classes (default: 10 for CIFAR-10)
"""
super(CIFAR10CNN, self).__init__()
# First convolutional block
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(32)
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2, 2) # 32x32 -> 16x16
# Second convolutional block
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2, 2) # 16x16 -> 8x8
# Fully connected layers
self.fc1 = nn.Linear(64 * 8 * 8, 128)
self.relu3 = nn.ReLU()
self.dropout = nn.Dropout(0.5)
self.fc2 = nn.Linear(128, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass.
Args:
x: Input tensor of shape (batch_size, 3, 32, 32)
Returns:
torch.Tensor: Logits of shape (batch_size, num_classes)
"""
x = self.pool1(self.relu1(self.bn1(self.conv1(x))))
x = self.pool2(self.relu2(self.bn2(self.conv2(x))))
x = x.view(x.size(0), -1) # Flatten
x = self.dropout(self.relu3(self.fc1(x)))
x = self.fc2(x)
return x
def load_model(model_path: str, device: str = 'cuda') -> CIFAR10CNN:
"""
Load trained CIFAR-10 model.
Args:
model_path: Path to model checkpoint (.pth file)
device: Device to load model on ('cuda' or 'cpu')
Returns:
CIFAR10CNN: Loaded model in eval mode
"""
model = CIFAR10CNN(num_classes=10)
# Load checkpoint
checkpoint = torch.load(model_path, map_location=device)
# Handle both direct state_dict and checkpoint dict formats
if isinstance(checkpoint, dict) and 'model_state_dict' in checkpoint:
model.load_state_dict(checkpoint['model_state_dict'])
else:
model.load_state_dict(checkpoint)
model = model.to(device)
model.eval()
return model
def deepfool_attack(
model: nn.Module,
image: torch.Tensor,
mean: list,
std: list,
num_classes: int = 10,
overshoot: float = 0.02,
max_iter: int = 50,
device: str = 'cuda'
) -> tuple:
"""
DeepFool minimal perturbation attack.
This implements the complete DeepFool algorithm:
1. Linearize the classifier around the current point
2. For each class k != current_class:
- Compute gradient direction w_k that increases class k score
- Compute distance to the linearized decision boundary
3. Find the closest decision boundary
4. Take minimal step to reach that boundary (with overshoot)
5. Re-linearize and repeat until misclassification
Args:
model: Trained CIFAR-10 model
image: Clean image in [0,1] range, shape (3, 32, 32)
mean: Normalization mean per channel
std: Normalization std per channel
num_classes: Number of classes to consider
overshoot: Overshoot parameter to ensure crossing boundary
max_iter: Maximum iterations
device: Device to run attack on
Returns:
tuple: (adversarial_image, total_perturbation, iterations, final_class)
"""
# Prepare normalization tensors
mean_t = torch.tensor(mean, device=device).view(3, 1, 1)
std_t = torch.tensor(std, device=device).view(3, 1, 1)
# Work in pixel space [0,1]
x = image.clone().to(device)
x_orig = image.clone().to(device)
# Normalize for model
x_norm = (x - mean_t) / std_t
# Get initial prediction
with torch.no_grad():
logits = model(x_norm.unsqueeze(0))
current_class = logits.argmax(dim=1).item()
original_class = current_class
r_total = torch.zeros_like(x)
print(f"\n{'='*60}")
print(f"DeepFool Attack")
print(f"{'='*60}")
print(f"Original class: {original_class}")
print(f"Num classes: {num_classes}")
print(f"Overshoot: {overshoot}")
print(f"Max iterations: {max_iter}")
print(f"{'='*60}\n")
for iteration in range(max_iter):
# Enable gradients
x_norm = (x - mean_t) / std_t
x_norm.requires_grad = True
# Forward pass
logits = model(x_norm.unsqueeze(0))
current_class = logits.argmax(dim=1).item()
# Check if misclassified
if current_class != original_class:
print(f" Misclassification achieved at iteration {iteration + 1}")
print(f" New class: {current_class}")
break
# Find closest decision boundary
min_dist = float('inf')
best_w = None
best_f = None
for k in range(num_classes):
if k == current_class:
continue
# Zero gradients
if x_norm.grad is not None:
x_norm.grad.zero_()
# Gradient for class k
logits[0, k].backward(retain_graph=True)
grad_k = x_norm.grad.clone()
# Zero gradients
if x_norm.grad is not None:
x_norm.grad.zero_()
# Gradient for current class
logits[0, current_class].backward(retain_graph=True)
grad_current = x_norm.grad.clone()
# Direction: increases k, decreases current
w_k = grad_k - grad_current
# Score gap
f_k = (logits[0, k] - logits[0, current_class]).item()
# Distance to boundary in normalized space
w_norm = torch.norm(w_k)
dist = abs(f_k) / (w_norm + 1e-10)
# Track minimum
if dist < min_dist:
min_dist = dist
best_w = w_k
best_f = f_k
# Compute perturbation in normalized space
w_norm_sq = torch.norm(best_w)**2
r_i = (abs(best_f) / (w_norm_sq + 1e-10)) * best_w
# Convert to pixel space (chain rule)
r_i_pixel = r_i * std_t
# Apply perturbation with overshoot
r_total = r_total + (1 + overshoot) * r_i_pixel
x = x_orig + r_total
# Clip to valid range
x = torch.clamp(x, 0.0, 1.0)
if (iteration + 1) % 10 == 0:
# Check L2 in normalized space
with torch.no_grad():
x_norm_check = (x - mean_t) / std_t
orig_norm = (x_orig - mean_t) / std_t
l2 = torch.norm(x_norm_check - orig_norm).item()
print(f" Iteration {iteration + 1}/{max_iter} - L2 norm: {l2:.4f}")
print(f"\n{'='*60}")
print(f"Attack complete!")
print(f"{'='*60}\n")
return x.detach().cpu(), r_total.detach().cpu(), iteration + 1, current_class
def test_api_robustness(host: str, image: torch.Tensor) -> bool:
"""
Test API robustness against invalid inputs.
Args:
host: Challenge server host URL
image: Clean challenge image
Returns:
bool: True if all robustness tests pass
"""
print(f"\n{'='*60}")
print("API Robustness Tests")
print(f"{'='*60}\n")
all_passed = True
# Test 1: Invalid JSON
print("[Test 1] Invalid JSON format...")
try:
response = requests.post(
f"{host}/submit",
data="invalid json",
headers={'content-type': 'application/json'}
)
if response.status_code == 400:
print(" [+] Correctly rejected invalid JSON")
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 2: Missing image field
print("\n[Test 2] Missing 'image' field...")
try:
response = requests.post(
f"{host}/submit",
json={"wrong_field": "data"}
)
if response.status_code == 400:
result = response.json()
if 'error' in result and 'image' in result['error'].lower():
print(" [+] Correctly rejected missing image field")
else:
print(f" [-] Expected error message about missing image field")
all_passed = False
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 3: Invalid base64
print("\n[Test 3] Invalid base64 encoding...")
try:
response = requests.post(
f"{host}/submit",
json={"image": "not_valid_base64!!!"}
)
if response.status_code == 400:
print(" [+] Correctly rejected invalid base64")
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 4: Corrupted image data
print("\n[Test 4] Corrupted image data...")
try:
# Send corrupted base64 that decodes but isn't a valid PNG
corrupted_base64 = base64.b64encode(b"not a valid png image").decode('utf-8')
response = requests.post(
f"{host}/submit",
json={"image": corrupted_base64}
)
if response.status_code == 400:
print(" [+] Correctly rejected corrupted image data")
else:
print(f" [-] Expected 400, got {response.status_code}")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
print(f"\n{'='*60}")
print(f"API Robustness: {'PASSED' if all_passed else 'FAILED'}")
print(f"{'='*60}\n")
return all_passed
def test_anti_cheat(host: str, model: nn.Module, image: torch.Tensor,
l2_threshold: float, mean: list, std: list,
device: str) -> bool:
"""
Test anti-cheat mechanisms for DeepFool.
Args:
host: Challenge server host URL
model: Trained model
image: Clean challenge image
l2_threshold: L2 constraint threshold
mean: Normalization mean
std: Normalization std
device: Device to run on
Returns:
bool: True if all anti-cheat tests pass
"""
print(f"\n{'='*60}")
print("Anti-Cheat Mechanism Tests")
print(f"{'='*60}\n")
all_passed = True
mean_t = torch.tensor(mean).view(3, 1, 1)
std_t = torch.tensor(std).view(3, 1, 1)
# Test 1: Clean image (no modification)
print("[Test 1] Submitting clean image (should fail)...")
try:
clean_base64 = tensor_to_base64(image)
response = requests.post(f"{host}/submit", json={"image": clean_base64})
result = response.json()
if not result['success'] and not result['validation']['misclassification']:
print(" [+] Correctly rejected clean image")
else:
print(" [-] Should reject clean image (no misclassification)")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 2: L2 perturbation too large
print("\n[Test 2] L2 perturbation exceeding threshold (should fail)...")
try:
large_pert = image.clone()
# Add large perturbation
large_pert = torch.clamp(large_pert + 0.5, 0, 1)
large_base64 = tensor_to_base64(large_pert)
response = requests.post(f"{host}/submit", json={"image": large_base64})
result = response.json()
if not result['success'] and not result['validation']['l2_satisfied']:
print(" [+] Correctly rejected oversized L2 perturbation")
else:
print(" [-] Should reject perturbation exceeding L2 threshold")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 3: I-FGSM attack (L∞-based, should fail L∞ check)
print("\n[Test 3] I-FGSM attack (L∞-based, should fail)...")
try:
x_fgsm = image.clone().to(device)
x_norm = (x_fgsm - mean_t.to(device)) / std_t.to(device)
x_norm.requires_grad = True
outputs = model(x_norm.unsqueeze(0))
target = torch.tensor([0], device=device) # Arbitrary target
# Targeted I-FGSM
for _ in range(20):
loss = F.cross_entropy(outputs, target)
model.zero_grad()
loss.backward()
grad_norm = x_norm.grad
grad_pixel = grad_norm / std_t.to(device)
x_fgsm = x_fgsm - 0.01 * grad_pixel.sign()
x_fgsm = torch.clamp(x_fgsm, 0, 1)
x_norm = (x_fgsm - mean_t.to(device)) / std_t.to(device)
x_norm.requires_grad = True
outputs = model(x_norm.unsqueeze(0))
x_fgsm = x_fgsm.cpu()
fgsm_base64 = tensor_to_base64(x_fgsm)
response = requests.post(f"{host}/submit", json={"image": fgsm_base64})
result = response.json()
if not result['success'] and not result['validation'].get('reasonable_linf', True):
print(" [+] Correctly rejected I-FGSM attack (L∞ too large)")
else:
print(" [-] Should reject I-FGSM attacks")
all_passed = False
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
# Test 4: Random perturbation (wrong direction)
print("\n[Test 4] Random perturbation (wrong direction, should fail)...")
try:
random_pert = image.clone()
# Small random perturbation that causes misclassification
noise = torch.randn_like(random_pert) * 0.03
random_pert = torch.clamp(random_pert + noise, 0, 1)
# Check if it causes misclassification
with torch.no_grad():
orig_norm = (image - mean_t) / std_t
rand_norm = (random_pert - mean_t) / std_t
orig_pred = model(orig_norm.unsqueeze(0).to(device)).argmax().item()
rand_pred = model(rand_norm.unsqueeze(0).to(device)).argmax().item()
if rand_pred != orig_pred:
random_base64 = tensor_to_base64(random_pert)
response = requests.post(f"{host}/submit", json={"image": random_base64})
result = response.json()
if not result['success'] and not result['validation'].get('deepfool_direction', True):
print(" [+] Correctly rejected random perturbation")
else:
print(" [-] Should reject random perturbations")
all_passed = False
else:
print(" [!] Random perturbation didn't cause misclassification, skipping")
except Exception as e:
print(f" [-] Exception: {e}")
all_passed = False
print(f"\n{'='*60}")
print(f"Anti-Cheat Tests: {'PASSED' if all_passed else 'FAILED'}")
print(f"{'='*60}\n")
return all_passed
def solve_challenge(host: str, device: str = 'cuda', run_tests: bool = False):
"""
Main solver function.
Args:
host: Challenge server host URL
device: Device for attack ('cuda' or 'cpu')
run_tests: Whether to run robustness and anti-cheat tests
"""
print(f"\n{'='*60}")
print(f"Skills Assessment 2")
print(f"{'='*60}\n")
# Determine model path (assume running from solver/ directory)
solver_dir = os.path.dirname(os.path.abspath(__file__))
challenge_dir = os.path.dirname(solver_dir)
assets_dir = os.path.join(challenge_dir, "assets")
model_path = os.path.join(assets_dir, "cifar10_model_best.pth")
# Ensure assets directory exists
os.makedirs(assets_dir, exist_ok=True)
# Fetch model if missing
if not os.path.exists(model_path):
print(f"[1/5] Model not found at {model_path}")
weights_url = f"{host}/model/weights" # Adjust if your endpoint differs
print(f"Attempting to download weights from: {weights_url}")
try:
resp = requests.get(weights_url, timeout=30)
resp.raise_for_status()
with open(model_path, "wb") as f:
f.write(resp.content)
print(f" Weights downloaded and saved to {model_path}")
except requests.exceptions.RequestException as e:
print(f"[-] Error: Failed to download model weights from {weights_url}")
print(f"Details: {e}")
print("Hint: Ensure the challenge server exposes /model/weights and is reachable.")
raise
# Step 1: Load model
print("[1/5] Loading CIFAR-10 model...")
model = load_model(model_path, device=device)
print(f" Model loaded on {device}")
# Step 2: Get challenge
print("\n[2/5] Fetching challenge...")
response = requests.get(f"{host}/challenge")
response.raise_for_status()
challenge = response.json()
print(f" Original class: {challenge['original_class']} ({challenge['original_class_name']})")
print(f" L2 threshold: {challenge['l2_threshold']}")
print(f" Num classes hint: {challenge['num_classes_hint']}")
print(f" Overshoot hint: {challenge['overshoot_hint']}")
# Step 3: Decode image
print("\n[3/5] Decoding challenge image...")
image = base64_to_tensor(challenge['image'])
mean = challenge['normalization']['mean']
std = challenge['normalization']['std']
print(f" Image shape: {image.shape}")
print(f" Normalization: mean={mean}, std={std}")
# Verify original prediction
with torch.no_grad():
mean_t = torch.tensor(mean).view(3, 1, 1)
std_t = torch.tensor(std).view(3, 1, 1)
img_norm = (image - mean_t) / std_t
orig_pred = model(img_norm.unsqueeze(0).to(device)).argmax(dim=1).item()
print(f" Verified original prediction: {orig_pred}")
# Optional: Run robustness and anti-cheat tests
if run_tests:
print("\n" + "="*60)
print("RUNNING SECURITY TESTS")
print("="*60)
robustness_passed = test_api_robustness(host, image)
anticheat_passed = test_anti_cheat(
host, model, image, challenge['l2_threshold'],
mean, std, device
)
if robustness_passed and anticheat_passed:
print(f"\n{'='*60}")
print("[+] ALL SECURITY TESTS PASSED")
print(f"{'='*60}\n")
else:
print(f"\n{'='*60}")
print("[-] SOME SECURITY TESTS FAILED")
print(f"{'='*60}\n")
print("Proceeding with legitimate attack...\n")
# Step 4: Run DeepFool attack
print("\n[4/5] Running DeepFool attack...")
adv_image, perturbation, iters, final_class = deepfool_attack(
model=model,
image=image,
mean=mean,
std=std,
num_classes=challenge['num_classes_hint'],
overshoot=challenge['overshoot_hint'],
max_iter=challenge['max_iterations_hint'],
device=device
)
print(f" Iterations: {iters}")
print(f" Final class: {final_class}")
# Verify L2 norm in normalized space
with torch.no_grad():
mean_t = torch.tensor(mean).view(3, 1, 1)
std_t = torch.tensor(std).view(3, 1, 1)
orig_norm = (image - mean_t) / std_t
adv_norm = (adv_image - mean_t) / std_t
delta_norm = adv_norm - orig_norm
l2_norm = torch.norm(delta_norm).item()
print(f" L2 norm (normalized): {l2_norm:.4f}")
print(f" Threshold: {challenge['l2_threshold']}")
print(f" Within threshold: {l2_norm <= challenge['l2_threshold']}")
# Step 5: Submit
print("\n[5/5] Submitting adversarial example...")
adv_base64 = tensor_to_base64(adv_image)
response = requests.post(
f"{host}/submit",
json={"image": adv_base64}
)
response.raise_for_status()
result = response.json()
# Display results
print(f"\n{'='*60}")
print("Validation Results")
print(f"{'='*60}")
validation = result['validation']
print(f"L2 norm: {validation['l2_norm']:.4f} / {validation['l2_threshold']}")
print(f"L2 constraint satisfied: {validation['l2_satisfied']}")
print(f"Valid range [0,1]: {validation['valid_range']}")
print(f"Original prediction: {validation['original_class']}")
print(f"Adversarial prediction: {validation['adversarial_class']}")
print(f"Misclassification: {validation['misclassification']}")
print(f"\nSuccess: {result['success']}")
if result['success']:
print(f"\n{'='*60}")
print(f"[+] FLAG: {result['flag']}")
print(f"{'='*60}\n")
else:
print(f"\n[-] {result['message']}")
if 'hint' in result:
print(f"[!] {result['hint']}\n")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description='Working solver for Skills Assessment 2'
)
parser.add_argument(
'--host',
type=str,
default='http://154.57.164.66:31701',
help='Challenge server host URL (default: http://localhost:8001)'
)
parser.add_argument(
'--device',
type=str,
default='cuda' if torch.cuda.is_available() else 'cpu',
help='Device to run attack on (default: cuda if available, else cpu)'
)
parser.add_argument(
'--skip-tests',
action='store_true',
help='Skip API robustness and anti-cheat tests (tests run by default)'
)
args = parser.parse_args()
try:
solve_challenge(args.host, args.device, not args.skip_tests)
except requests.exceptions.ConnectionError:
print(f"\n[-] Error: Could not connect to {args.host}")
print("Make sure the challenge server is running (./run.sh)\n")
except Exception as e:
print(f"\n[-] Error: {str(e)}\n")
raise
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.