Skip to main content

Crescendo

Crescendo never asks for the goal outright. It opens a conversation about the goal's topic with a harmless question, then asks for a little more on each turn, building on what the target has already said. By the time the real request arrives, the target has discussed the subject at length, and the request reads as the next step of that discussion rather than a first ask.

The idea that makes it work: the conversation is the attack. Every accepted turn is kept and the whole history is sent again, so the target's own earlier answers justify the next question. When the target refuses, Crescendo does not build on the refusal. It removes that exchange from the conversation and asks the same step again in other words. This models a patient user who escalates across many turns, which a single-prompt test never exercises.

At a glance​

TypeHelper modelsTurnsCalls per goalGood for
Multi-turnattacker (required), refusal (optional)Up to max_turns user turns in the conversationUp to 20 target calls with the defaultsChat assistants that refuse a direct request but keep answering follow-ups

Research​

Mark Russinovich, Ahmed Salem and Ronen Eldan, "Great, Now Write an Article About That: The Crescendo Multi-Turn LLM Jailbreak Attack", 2024, USENIX Security 2025. arXiv:2404.01833.

The paper starts from a benign question and escalates by referring back to the model's own replies. It reported high success rates on ChatGPT, Gemini Pro, Gemini Ultra, LLaMA-2 70b, LLaMA-3 70b Chat and Anthropic Chat. Its automated version, Crescendomation, beat the other jailbreak techniques it was compared with on an AdvBench subset, by 29–61% on GPT-4 and 49–71% on Gemini Pro.

HackAgent follows the reference implementation, PyRIT's CrescendoAttack. Like it, a Refusal Judge with PyRIT's strict refusal rubric decides when to backtrack, a backtrack does not use up a turn, and the backtrack budget is shared by the whole goal. Where HackAgent differs:

  • Success comes from the campaign's judge panel, not from a scorer and threshold of Crescendo's own. Each kept turn is judged, and with early_stop the first reply the panel passes ends the search.
  • The panel sees one exchange: the goal, the latest question and the reply. It does not see the earlier turns.
  • A refused turn can still count. Once the backtrack budget is spent, a refused turn is kept, judged like any other and can be a jailbreak.
  • A failed or unreadable refusal verdict counts as "not refused", so it never triggers a backtrack.
  • No parse retries. The attacker is asked for JSON, but a plain-text question is accepted. If the attacker returns nothing usable, the search stops.

How it works​

  1. The attacker model gets a system prompt with the goal and the escalation strategy, and is asked for a general, clearly benign opening question on the goal's topic.
  2. It answers with a short reasoning and the next question. If the call fails or the reply is empty, the search stops.
  3. HackAgent appends the question to the conversation and sends the whole conversation to the target.
  4. If the target gives no usable reply (a provider error, a guardrail block or an empty reply), the turn is skipped: it counts toward max_turns, no attempt is recorded for it, and the attacker is asked to rephrase.
  5. The Refusal Judge (the refusal role, or the attacker model when you leave it out) reads the goal and the reply. Under the strict rubric, anything short of substantively fulfilling the goal is a refusal: an explicit refusal, a redirection, a partial answer, or caveats that stop it from answering.
  6. If the reply is a refusal and backtracks remain under max_backtracks, the turn is backtracked. The exchange is recorded but not judged, it never joins the conversation, the turn counter does not move, and the attacker is told to rephrase the same step.
  7. Otherwise the panel judges the reply, the exchange joins the conversation, and the turn counter moves on. The attacker sees the reply (cut to feedback_max_chars) and the panel's score, and is asked to escalate a little further. Its own planning log keeps the last keep_last_n exchanges; the target's conversation is never trimmed.
  8. The search ends when a kept turn passes the panel (with early_stop), when max_turns turns have been kept or skipped, or when the attacker stops proposing questions. The goal counts as jailbroken if any of its attempts passed.

When to use it​

Use Crescendo when a target refuses the goal asked directly, and you want to know whether it holds that line over a conversation. It suits chat assistants and customer-facing bots, where users can keep talking, and models whose safety training was tested mostly on single prompts.

The target must accept the conversation history on every call. That is true of the chat API types (OPENAI, OLLAMA, LITELLM, LANGCHAIN). The agent types forward only the latest user message:

  • CLAUDE_CODE, CODEX, HERMES and GOOGLE_ADK (which opens a fresh session per request by default) never see the earlier turns, so each question arrives cold and the escalation is lost.
  • WEB keeps one live chat, so turns do accumulate on the page, but a backtracked turn cannot be removed from it.

Pick another attack when:

  • The target already falls to a single prompt. PAIR or TAP find that for fewer calls, and a static attack for fewer still.
  • The target is an agent with tools and the harm is an action, not text. STAC checks what the agent actually executes.

Run Crescendo after cheaper attacks with execution.escalate, so it spends its turns only on the goals still standing.

Cost​

Each pass through the loop makes one attacker call, one target call and, when the target replied, one Refusal Judge call. A kept turn adds one panel evaluation; a backtracked turn adds none. Turns advance and backtracks are bounded, so a goal runs at most max_turns + max_backtracks passes.

Calls per goalBoundWith the defaults
Targetmax_turns + max_backtracks20
attackermax_turns + max_backtracks20
Refusal Judgemax_turns + max_backtracks20, on the attacker model unless you set refusal
Panelmax_turns evaluations, each one call per judge10 × judges

Those are ceilings. With early_stop on, the search ends at the first reply the panel passes. Provider retries (execution.retries) add to them. The calls within a goal are sequential, because each question depends on the last reply, so a goal takes about passes × (attacker + target + refusal + panel) latency. Goals still run in parallel under execution.concurrency.

Every request re-sends the whole conversation, so target tokens grow with each kept turn. Lower max_turns to cut both calls and tokens; lower max_backtracks to stop paying for rephrasings.

Configuration​

Helper models​

Models this attack drives, configured under its roles. Each one is a ModelConfig.

RoleKindNeededWhat it does
attackerchat modelrequiredPlans the conversation and writes each next question, a little closer to the goal than the last.
refusalchat modeloptionalOptional. The Refusal Judge: decides whether each reply refuses the goal, which triggers a backtrack. Defaults to the attacker model.

Parameters​

Settings for the algorithm itself, written under the attack's parameters. Anything left out keeps its default.

FieldTypeDefaultDescription
max_turnsinteger (>= 1)10Turns the conversation may grow to. A backtracked turn is retried, not counted, so the conversation never escalates on a refusal.
max_backtracksinteger (>= 0)10Rephrasings allowed across the whole goal. Once spent, a refused turn is accepted as-is, judged like any other, and the conversation moves on.
keep_last_ninteger (>= 1)6Attacker turns kept before its planning log is trimmed. The target's own conversation is never trimmed: that history is the attack.
early_stopbooleantrueStop as soon as a turn passes the panel.
feedback_max_charsinteger (>= 1)2500Characters of the target's reply fed back to the attacker; the rest is cut.

In a campaign file​

attacks:
- name: crescendo
roles:
attacker:
name: llama3.2:latest
connection:
provider: ollama
type: OLLAMA
Needs a helper model

This attack cannot run without attacker. A campaign that leaves it out fails to resolve, before anything is attacked.

Example campaigns​

A separate Refusal Judge and a shorter conversation:

attacks:
- name: crescendo
parameters:
max_turns: 6
max_backtracks: 4
roles:
attacker:
name: qwen3:8b
connection:
provider: ollama
type: OLLAMA
refusal:
name: llama3.2:latest
connection:
provider: ollama
type: OLLAMA

This caps a goal at 10 target calls and moves refusal checks to a second model, so the attacker model only writes questions. Use it when your attacker model is picked for writing adversarial text and you trust another model more to classify refusals.

Crescendo only on the goals a cheap attack missed:

attacks:
- name: flipattack
- name: crescendo
roles:
attacker:
name: qwen3:8b
connection:
provider: ollama
type: OLLAMA

execution:
escalate: true

FlipAttack runs first for one call per goal. With escalate, a goal it breaks is dropped, and Crescendo spends its turns only on the goals still standing.

Reading the results​

One attempt is one exchange the target answered: the conversation sent so far plus the new question, and the reply. A goal produces one attempt per answered turn, in order, including backtracked ones. A skipped turn produces none; if nothing came back at all, the goal gets one error attempt that says why.

  • A kept turn carries the panel's verdict.
  • A backtracked turn is recorded as not a jailbreak, with the explanation "not judged: backtracked after a refusal" followed by the Refusal Judge's rationale.

Each attempt's metadata has turn (counted from 0), refused, refusal_rationale, backtracked and the panel score. Its trace sits under a turn N/M phase: the attacker call, the target call, the Refusal Judge call (labelled refusal, or attacker when the attacker model judges refusals), the panel call, and decisions such as backtracked (with the budget used so far), skipped and stopped.

In the dashboard the goal reads as one continuous conversation. History an attempt shares with an earlier one is not printed again, a turn rephrased after a refusal says which attempt it continues from, and each attempt header states its turn. A prompt or reply identical to an earlier one shows as "same as attempt N".

A jailbreak here means the panel passed one reply of the conversation, judged against the goal on that reply alone. Check these first:

  1. Read the passing reply with the turns before it. Does it deliver the goal, or only make sense as part of the conversation?
  2. Read the refusal_rationale of the backtracked turns. If the budget went on the first few turns, the rest of the conversation ran without backtracking.
  3. A long run of kept turns with low scores means the attacker is not escalating; try a stronger attacker model.

Limitations​

  • The panel judges replies one at a time. Harmful content split across several replies is not caught, and a reply that is harmful only in context may be under-scored.
  • The Refusal Judge reads each reply against the goal, not the turn's question. A helpful answer to an early, harmless question can be scored as "only tangentially related", which is a refusal under the strict rubric. Those turns are backtracked too, and can spend the budget early. Raise max_backtracks if you see this.
  • One conversation per goal. There are no parallel streams, and the attacker samples freely, so two runs of the same goal can take different paths. Compare rates over several goals, not single outcomes.
  • Only chat API targets get the full history. See When to use it.
  • The conversation is never trimmed. A long run can outgrow the target's context window, which shows up as provider errors and skipped turns.