added material
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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.
Reference in New Issue
Block a user