Model Extraction: Audit Your Scoring API Against Cloning, Then Defend It
Hands-on lab · IDE in your browser

Model Extraction: Audit Your Scoring API Against Cloning, Then Defend It

Run the extraction audit a prediction API needs: copy your own credit model through its public endpoint, measure how many queries a faithful copy costs against a no-query baseline, and test whether returning labels instead of probabilities helps.

Time
60 min
Checked steps
6
Level
Advanced
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Map the attack surface
Query
Retriever
LLM
Poisoned doc
retrieved chunk
Answer
0%
Attack-success rate
Attacks blocked · benign answers pass
graded on real output, not the model's talk

The job

Lendwise sells credit decisions through a public scoring API. The model behind it took two years of loan outcomes to build, and anyone with an API key can collect its answers and train a copy. Security wants an extraction audit before the next pricing change: what a copy costs, which defences raise that cost, and what risk is left after them. This week's traffic already has two heavy senders in it.

6 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Clone your own API

    Lendwise sells credit decisions through CreditAPI.score(X): send applicant rows, get approval probabilities back.

  2. 2

    What a clone costs

    A fidelity number only means something next to what a copy gets for free.

  3. 3

    Harden the output

    The usual first fix is to return less.

  4. 4

    A per-client budget

    A copy needs thousands of queries; a real customer scores a few applicants a day.

  5. 5

    Spot the scraper

    A cap slows everyone equally.

  6. 6

    What the defences leave

    An audit ends with the risk that remains, measured.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Clone your own API

Lendwise sells credit decisions through CreditAPI.score(X): send applicant rows, get approval probabilities back. The model behind it took two years of loan outcomes to train. Anyone who can call the endpoint can also collect (query, answer) pairs and fit a copy. Before you defend against that, run it yourself, so you know what you are defending.

An outsider does not have your applicants, but your API docs publish each feature's valid range (H.RANGES, a pair of arrays: lows and highs). Made-up rows inside those ranges are enough to start.

Write three functions in extraction.py:

  • box_queries(n, seed=0): n rows, each feature drawn uniformly between its low and high. Use np.random.default_rng(seed) so the same seed gives the same rows.
  • steal(api, X): send X to api.score, fit H.fit_surrogate on the rows and the answers, return it.
  • fidelity(surrogate, X): the share of rows where the copy and your real model make the same decision. The copy approves when its prediction is at least 0.5; H.decide(X) gives the real decisions.

Run it and read the agreement on real applicants.

extraction.py, the file you edit113 lines
"""Audit your own scoring API for model extraction, then defend it.

The API, its data and the copycat's model are given in api.py. You write the functions below.
"""
import math

import numpy as np

import api as H


# ---- Step 1: clone your own API ---------------------------------------------------------------------------

def box_queries(n, seed=0):
    """n made-up applicants, each feature drawn uniformly between the published H.RANGES."""
    # TODO (Step 1): n rows, each feature uniform between H.RANGES[0] and H.RANGES[1], seeded.
    raise NotImplementedError("Step 1: write box_queries()")


def steal(api, X):
    """Send X to api.score and fit H.fit_surrogate on what comes back. Return the surrogate."""
    # TODO (Step 1): H.fit_surrogate(X, api.score(X)).
    raise NotImplementedError("Step 1: write steal()")


def fidelity(surrogate, X):
    """Share of rows in X where the surrogate (prediction >= 0.5 means approve) agrees with H.decide."""
    # TODO (Step 1): mean of (surrogate.predict(X) >= 0.5) == H.decide(X).
    raise NotImplementedError("Step 1: write fidelity()")


# ---- Step 2: what a clone costs ----------------------------------------------------------------------------

def baseline(X):
    """Fidelity of a clone that never queried: always give the most common decision on X."""
    raise NotImplementedError("baseline() arrives in Step 2")


def budget_curve(api, budgets, seed=0):
    """[(n, fidelity on H.HOLDOUT)] for a surrogate stolen with box_queries(n, seed), for each n in budgets."""
    raise NotImplementedError("budget_curve() arrives in Step 2")


def queries_needed(curve, target):
    """Smallest n in curve whose fidelity reaches target, or None if no budget does."""
    raise NotImplementedError("queries_needed() arrives in Step 2")


# ---- Step 3: harden the output -----------------------------------------------------------------------------

def hardening_report(n=2000, seed=0):
    """{mode: fidelity on H.HOLDOUT} of a surrogate stolen with box_queries(n, seed) from H.CreditAPI(mode),
    for mode in "proba", "round", "label"."""
    raise NotImplementedError("hardening_report() arrives in Step 3")


# ---- Step 4: a per-client budget ---------------------------------------------------------------------------

def pick_cap(usage, headroom=1.5):
    """Rows per client per day: headroom x the 99th percentile of last month's client-day row counts, rounded up."""
    raise NotImplementedError("pick_cap() arrives in Step 4")


def admit(requests, cap):
    """Rows served for each request, in order. A client gets at most cap rows per day; a request that crosses
    the cap is trimmed to what is left, and later requests that day get 0."""
    used = {}
    out = []
    for r in requests:
        key = (r["client"], r["day"])
        if used.get(key, 0) < cap:
            out.append(len(r["X"]))
        else:
            out.append(0)
        used[key] = used.get(key, 0) + 1
    return out


def days_to_clone(needed, cap):
    """Days one client needs to send `needed` queries at `cap` rows a day."""
    raise NotImplementedError("days_to_clone() arrives in Step 4")


# ---- Step 5: spot the scraper -------------------------------------------------------------------------------

def ood_score(X):
    """Mean Mahalanobis distance of the rows in X from your real applicants (mean and covariance of H.LOANS[0])."""
    raise NotImplementedError("ood_score() arrives in Step 5")


def calibrate(chunk=50, headroom=1.2):
    """headroom x the highest ood_score over consecutive chunks of `chunk` rows of H.HOLDOUT."""
    raise NotImplementedError("calibrate() arrives in Step 5")


def flag_clients(traffic, threshold):
    """Clients whose rows, pooled over all their requests, have an ood_score above threshold."""
    raise NotImplementedError("flag_clients() arrives in Step 5")


# ---- Step 6: what the defences leave an attacker -------------------------------------------------------------

def served_rows(traffic, flagged, cap):
    """{client: rows actually served}: flagged clients get nothing, everyone else goes through admit(cap), and
    a trimmed request serves its first rows."""
    raise NotImplementedError("served_rows() arrives in Step 6")


def defence_report(api, traffic, cap, threshold, watch):
    """For each client in watch: {"sent", "served", "flagged", "fidelity_before", "fidelity_after"}. Fidelity is
    on H.HOLDOUT for a surrogate stolen from every row the client sent (before) or was served (after);
    fidelity_after is None when fewer than 20 rows were served."""
    raise NotImplementedError("defence_report() arrives in Step 6")
Provided for you:api.pyholdout.csvloans.csvtraffic.csvtry_it.pyusage_last_month.csv

Frequently asked questions

What is a model extraction attack?

It is an attack that copies a model through its prediction API. The attacker sends many inputs, collects the answers, and fits their own model to the pairs. The copy can be used without paying, studied offline to find weaknesses, or resold.

Does returning only labels stop model extraction?

Usually not by much. A copy learns the decision boundary, and labels still describe the boundary. In this lab a label-only API changes the copy's agreement by about a point while taking the scores away from legitimate customers.

How do you defend an API against model extraction?

Limit how many rows each client can score per day, sized from real customer usage, and count every row a request carries. Flag clients whose queries do not look like real inputs, for example with a Mahalanobis distance from your training data. Then measure what an attacker that stays under both limits can still copy, and report that residual risk.

Model extraction attacks and how to defend a prediction API

A model extraction attack copies a machine learning model through its prediction API: the attacker sends inputs, records the outputs, and trains a surrogate that makes the same decisions. The copy is measured by fidelity, the share of inputs where it agrees with the original. In this lab you audit a synthetic credit-scoring API. You build a copy from made-up queries, chart fidelity against query budget, and test output hardening. Then you defend it with a per-client row budget and an out-of-distribution detector, and measure what each attacker can still copy.