1use clap::{Parser, ValueHint};
2use eyre::{Context, Result, eyre};
3use foundry_cli::{opts::BuildOpts, utils::LoadConfig};
4use foundry_common::{provider::mpp::transport::LazyAccountsProvider, shell};
5use foundry_compilers::utils::source_files_iter;
6use foundry_config::impl_figment_convert;
7use foundry_evm::constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS, MONAD_CHEATCODE_ADDRESS};
8use mpp::client::Fetch;
9use serde::Serialize;
10use serde_json::{Value, json};
11use std::{
12 fmt::Write as _,
13 io::Write as _,
14 path::{Path, PathBuf},
15};
16use url::Url;
17
18use crate::{
19 cmd::properties::{
20 SELECT_ALL_TESTS, Sandbox, TestFile, restrict_config, shorten, stderr, suite_results,
21 },
22 result::{TestResult, TestStatus},
23 traces::{CallKind, TraceKind},
24 workspace::relative_to_root,
25};
26
27const DEFAULT_ENDPOINT: &str = "https://gateway.tempo-dev.workers.dev";
29
30const DEFAULT_MODEL: &str = "@cf/zai-org/glm-5.3";
32
33const NO_EXPLOIT: &str = "NO_EXPLOIT";
35
36const MAX_SOURCE_BYTES: usize = 64_000;
38
39#[derive(Clone, Debug, Parser)]
51pub struct ExploitArgs {
52 #[arg(long, value_hint = ValueHint::FilePath, value_name = "PATH")]
54 target: PathBuf,
55
56 #[arg(long, value_hint = ValueHint::FilePath, value_name = "PATH")]
58 context: Vec<PathBuf>,
59
60 #[arg(long, env = "FORGE_EXPLOIT_ENDPOINT", default_value = DEFAULT_ENDPOINT)]
62 endpoint: Url,
63
64 #[arg(long, env = "FORGE_EXPLOIT_MODEL", default_value = DEFAULT_MODEL)]
66 model: String,
67
68 #[arg(long, default_value_t = 3)]
70 attempts: usize,
71
72 #[arg(long, default_value_t = 4)]
74 turns: usize,
75
76 #[arg(long, default_value_t = 8_192)]
78 max_tokens: u32,
79
80 #[command(flatten)]
81 build: BuildOpts,
82}
83
84impl_figment_convert!(ExploitArgs, build);
85
86#[derive(Debug, Serialize)]
88struct Finding {
89 attempt: usize,
90 turns: usize,
91 file: PathBuf,
92 tests: Vec<String>,
93 bugs: Vec<String>,
94}
95
96#[derive(Debug, Serialize)]
97struct Report<'a> {
98 target: &'a Path,
99 endpoint: &'a str,
100 model: &'a str,
101 findings: &'a [Finding],
102}
103
104enum Run {
105 Proven(Vec<String>),
106 Rejected(String),
107}
108
109impl ExploitArgs {
110 pub async fn run(self) -> Result<()> {
111 let mut config = self.load_config()?;
112 restrict_config(&mut config, false, false);
113 let root = config.root.clone();
114 let target = root.join(&self.target);
115 let target_rel = relative_to_root(&root, &target);
116 let target_source = read_source(&target)?;
117 let mut context = String::new();
118 for path in &self.context {
119 let path = root.join(path);
120 write!(
121 context,
122 "\n\n--- {} ---\n{}",
123 relative_to_root(&root, &path).display(),
124 read_source(&path)?
125 )?;
126 }
127 let remappings =
128 config.get_all_remappings().map(|r| r.to_string()).collect::<Vec<_>>().join("\n");
129 let mut sources = source_files_iter(&config.src, &["sol"])
130 .map(|path| relative_to_root(&root, &path).display().to_string())
131 .collect::<Vec<_>>();
132 sources.sort();
133 let sources = sources.join("\n");
134 let mut sandbox = Sandbox::new(&config, vec![])?;
135 sandbox.end_on_interrupt(Vec::new())?;
136 if let Some(errors) = sandbox.build()? {
137 return Err(eyre!(
138 "the project must compile before Forge looks for exploits: {errors}"
139 ));
140 }
141
142 let out_dir = config.test.join("exploit");
143 let chat = Chat::new(&self.endpoint, &self.model, self.max_tokens)?;
144 let mut findings = Vec::<Finding>::new();
145 let mut next = 1;
146
147 for attempt in 1..=self.attempts {
148 while out_dir.join(format!("Exploit{next}Test.t.sol")).exists() {
151 next += 1;
152 }
153 let contract = format!("Exploit{next}Test");
154 let file = out_dir.join(format!("{contract}.t.sol"));
155 let file_rel = relative_to_root(&root, &file);
156 let mut prompt = format!(
157 "Target: {target}\n\n{target_source}{context}\n\nProject sources:\n{sources}\n\n\
158 Remappings:\n{remappings}\n\nWrite the test file at {file}. Name the test \
159 contract {contract}.",
160 target = target_rel.display(),
161 file = file_rel.display(),
162 );
163 if !findings.is_empty() {
164 prompt.push_str("\n\nThese bugs are already proven; find a different one:\n");
165 for bug in findings.iter().flat_map(|f| &f.bugs) {
166 writeln!(prompt, "{bug}")?;
167 }
168 }
169 let mut messages = vec![
170 json!({"role": "system", "content": SYSTEM}),
171 json!({"role": "user", "content": prompt}),
172 ];
173
174 sh_status!("Attempt {attempt}/{}: asking {}", self.attempts, self.model)?;
175 for turn in 1..=self.turns {
176 let reply = chat.complete(&messages).await?;
177 messages.push(json!({"role": "assistant", "content": reply}));
178
179 let Some(code) = solidity_block(&reply) else {
180 if reply.contains(NO_EXPLOIT) {
181 sh_status!("Attempt {attempt}: the model found no exploit")?;
182 } else {
183 sh_warn!("Attempt {attempt}: the reply had no Solidity code block")?;
184 }
185 break;
186 };
187 let feedback = match run_test(&sandbox, &file_rel, code)? {
188 Run::Proven(tests) => {
189 std::fs::create_dir_all(&out_dir)?;
190 std::fs::OpenOptions::new()
191 .write(true)
192 .create_new(true)
193 .open(&file)
194 .and_then(|mut target| target.write_all(code.as_bytes()))
195 .wrap_err_with(|| format!("failed to write {}", file_rel.display()))?;
196 let bugs = bug_tags(code);
197 sh_status!("Attempt {attempt}: proven by {}", file_rel.display())?;
198 findings.push(Finding {
199 attempt,
200 turns: turn,
201 file: file_rel.clone(),
202 tests,
203 bugs,
204 });
205 break;
206 }
207 Run::Rejected(feedback) => feedback,
208 };
209 sh_status!("Attempt {attempt}, turn {turn}: not proven yet")?;
210 messages.push(json!({"role": "user", "content": feedback}));
211 }
212 }
213
214 let report_path = config.cache_path.join("exploit").join("report.json");
215 std::fs::create_dir_all(report_path.parent().unwrap())?;
216 let report = Report {
217 target: &target_rel,
218 endpoint: self.endpoint.as_str(),
219 model: &self.model,
220 findings: &findings,
221 };
222 std::fs::write(&report_path, serde_json::to_string_pretty(&report)?)?;
223
224 if shell::is_json() {
225 sh_println!("{}", serde_json::to_string(&report)?)?;
226 } else {
227 for finding in &findings {
228 for bug in &finding.bugs {
229 sh_println!("{}\t{bug}", finding.file.display())?;
230 }
231 }
232 sh_status!(
233 "{} of {} attempts proved an exploit; report in {}",
234 findings.len(),
235 self.attempts,
236 relative_to_root(&root, &report_path).display()
237 )?;
238 }
239 Ok(())
240 }
241}
242
243const SYSTEM: &str = "You are a smart contract security researcher auditing a Foundry project. \
244Find one concrete, exploitable vulnerability in the target contract and prove it with a Foundry \
245test.
246
247Reply with exactly one solidity fenced code block holding a complete test file:
248- an SPDX line, a pragma compatible with the target, and imports of forge-std/Test.sol and of the \
249contracts you need, using the project's remappings or paths relative to the test file;
250- one contract with the name you are given, inheriting Test, that deploys everything it needs in \
251setUp();
252- one or more functions named test_exploit_* that pass only when the exploit works: assert what \
253the attacker gained or which guarantee broke, never something that holds anyway;
254- each such function successfully calls a function of a target contract that can change its \
255state; view calls and reverted calls alone do not count;
256- directly above each such function, a comment line \
257// @bug Contract.function path:line - one sentence saying what is wrong;
258- no ffi, no forks, no network access and no environment variables.
259
260When the test does not compile or does not pass you get the output; reply with the whole fixed \
261file. If you find no exploitable vulnerability, reply NO_EXPLOIT and nothing else.";
262
263struct Chat {
264 client: reqwest::Client,
265 provider: LazyAccountsProvider,
266 url: Url,
267 model: String,
268 max_tokens: u32,
269}
270
271impl Chat {
272 fn new(endpoint: &Url, model: &str, max_tokens: u32) -> Result<Self> {
273 let mut base = endpoint.clone();
274 if !base.path().ends_with('/') {
275 base.set_path(&format!("{}/", base.path()));
276 }
277 Ok(Self {
278 client: reqwest::Client::new(),
279 provider: LazyAccountsProvider::new(endpoint.to_string()),
280 url: base.join("v1/chat/completions")?,
281 model: model.to_string(),
282 max_tokens,
283 })
284 }
285
286 async fn complete(&self, messages: &[Value]) -> Result<String> {
287 let body = json!({
288 "model": self.model,
289 "messages": messages,
290 "max_tokens": self.max_tokens,
291 });
292 let response = self
293 .client
294 .post(self.url.clone())
295 .json(&body)
296 .send_with_payment(&self.provider)
297 .await
298 .map_err(|e| eyre!("request to {} failed: {e}", self.url))?;
299 let status = response.status();
300 let text = response.text().await?;
301 if !status.is_success() {
302 return Err(eyre!("{} answered {status}: {text}", self.url));
303 }
304 let value: Value =
305 serde_json::from_str(&text).wrap_err_with(|| format!("invalid response: {text}"))?;
306 let message = &value["choices"][0]["message"];
307 message["content"]
308 .as_str()
309 .filter(|content| !content.trim().is_empty())
310 .or_else(|| message["reasoning_content"].as_str())
311 .map(str::to_string)
312 .ok_or_else(|| eyre!("response has no message content: {text}"))
313 }
314}
315
316fn run_test(sandbox: &Sandbox<'_>, file: &Path, code: &str) -> Result<Run> {
318 let test = TestFile { path: file.to_path_buf(), content: code.to_string() };
319 let workspace = sandbox.workspace(&[test])?;
320 let mut command = sandbox.command(workspace.path());
321 command
322 .args(["test", "--json", "-vvv", "--no-network", "--restrict-inline-config"])
324 .arg("--match-path")
325 .arg(file)
326 .args(SELECT_ALL_TESTS)
327 .env("FOUNDRY_FUZZ_FAIL_ON_REVERT", "true");
329 let Some(output) = sandbox.output(&mut command)? else {
330 return Ok(Run::Rejected(
331 "The tests reached the timeout; they must not loop without end.".to_string(),
332 ));
333 };
334 if !output.status.success() && output.stdout.is_empty() {
335 return Ok(Run::Rejected(format!(
336 "The test file did not compile or could not run:\n{}",
337 stderr(&output)
338 )));
339 }
340 let results = suite_results(&output)?;
341 let proven = results
342 .iter()
343 .filter(|(_, name, result)| {
344 result.status == TestStatus::Success
345 && name.starts_with("test_exploit")
346 && changes_state(result)
347 })
348 .map(|(_, name, _)| name.clone())
349 .collect::<Vec<_>>();
350 if !proven.is_empty() {
351 return Ok(Run::Proven(proven));
352 }
353
354 let mut feedback = String::from("No test_exploit_* test passed, so nothing is proven:\n");
355 for (_, name, result) in &results {
356 let _ = write!(feedback, "\n{name}: {:?}", result.status);
357 if let Some(reason) = &result.reason {
358 let _ = write!(feedback, " ({reason})");
359 } else if result.status == TestStatus::Success && !changes_state(result) {
360 feedback.push_str(
361 " (makes no successful call that can change state into another contract, so it \
362 cannot show the exploit)",
363 );
364 }
365 for log in &result.decoded_logs {
366 let _ = write!(feedback, "\n {log}");
367 }
368 }
369 if results.is_empty() {
370 feedback.push_str("\nNo tests ran; name the functions test_exploit_*.");
371 }
372 Ok(Run::Rejected(shorten(&feedback)))
373}
374
375fn changes_state(result: &TestResult) -> bool {
381 result.traces.iter().filter(|(kind, _)| *kind == TraceKind::Execution).any(|(_, arena)| {
382 let nodes = arena.arena.nodes();
383 let test = &nodes[0].trace;
384 test.success
385 && nodes[1..].iter().map(|node| &node.trace).any(|trace| {
386 trace.success
387 && trace.kind == CallKind::Call
388 && ![
389 test.address,
390 CHEATCODE_ADDRESS,
391 MONAD_CHEATCODE_ADDRESS,
392 HARDHAT_CONSOLE_ADDRESS,
393 ]
394 .contains(&trace.address)
395 })
396 })
397}
398
399fn solidity_block(reply: &str) -> Option<&str> {
400 let start = reply.find("```solidity").or_else(|| reply.find("```sol"))?;
401 let rest = &reply[start..];
402 let body = &rest[rest.find('\n')? + 1..];
403 let end = body.find("```").unwrap_or(body.len());
404 let code = body[..end].trim();
405 (!code.is_empty()).then_some(code)
406}
407
408fn bug_tags(code: &str) -> Vec<String> {
409 code.lines()
410 .filter_map(|line| line.trim().strip_prefix("//"))
411 .map(str::trim)
412 .filter(|line| line.starts_with("@bug"))
413 .map(str::to_string)
414 .collect()
415}
416
417fn read_source(path: &Path) -> Result<String> {
418 let mut source = std::fs::read_to_string(path)
419 .wrap_err_with(|| format!("failed to read {}", path.display()))?;
420 if source.len() > MAX_SOURCE_BYTES {
421 source.truncate(source.floor_char_boundary(MAX_SOURCE_BYTES));
422 source.push_str("\n// [truncated]");
423 }
424 Ok(source)
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn extracts_solidity_block_and_bug_tags() {
433 let reply = "Here:\n```solidity\n// SPDX-License-Identifier: UNLICENSED\ncontract A {\n // @bug Vault.withdraw src/Vault.sol:12 - no reentrancy guard\n function test_exploit_reenter() public {}\n}\n```\ndone";
434 let code = solidity_block(reply).unwrap();
435 assert!(code.starts_with("// SPDX"));
436 assert!(code.ends_with('}'));
437 assert_eq!(bug_tags(code), ["@bug Vault.withdraw src/Vault.sol:12 - no reentrancy guard"]);
438 assert_eq!(solidity_block("NO_EXPLOIT"), None);
439 }
440}