Files
AI-Red-Teaming-CSCD94/first-order-attacks/df-exit.py
T
Jeremy Janella 8da2d2fd6f added material
2026-07-26 23:12:07 -04:00

378 lines
12 KiB
Python

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()