solaudit 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ //! SolScape — persistent on-chain territory game.
2
+ //!
3
+ //! ⚠️ INTENTIONALLY VULNERABLE TEST FIXTURE (v2) ⚠️
4
+ //! This build contains deliberately seeded vulnerabilities for benchmarking the
5
+ //! SolAudit scanner (see ANSWER_KEY.md). DO NOT DEPLOY. The hardened build lives
6
+ //! in `programs/solscape-fixed/` and is the only version that may ever be deployed.
7
+ //!
8
+ //! v2 realism fixes (disclosed in SCORECARD.md):
9
+ //! - withdraw_treasury now uses invoke_signed (the genuinely exploitable shape)
10
+ //! - close_tile leaves account data intact (genuine close-then-revive)
11
+ //! - buy_tile_spl takes typed token accounts + a mint, with the CPI program id
12
+ //! sourced from an unchecked account (textbook arbitrary-CPI shape)
13
+
14
+ use anchor_lang::prelude::*;
15
+ use anchor_lang::solana_program::instruction::{AccountMeta, Instruction};
16
+ use anchor_lang::solana_program::program::{invoke, invoke_signed};
17
+ use anchor_lang::solana_program::system_instruction;
18
+ use anchor_spl::token::{Mint, TokenAccount};
19
+
20
+ declare_id!("So1Scape11111111111111111111111111111111111");
21
+
22
+ pub const GRID_SIZE: u8 = 100;
23
+ pub const CLAIM_FEE_LAMPORTS: u64 = 10_000_000; // 0.01 SOL
24
+ pub const UPGRADE_BASE_FEE: u64 = 5_000_000; // 0.005 SOL
25
+ pub const ROYALTY_BPS: u64 = 500; // 5%
26
+
27
+ #[program]
28
+ pub mod solscape {
29
+ use super::*;
30
+
31
+ /// C1 — clean control: proper init, seeds, signer.
32
+ pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
33
+ let config = &mut ctx.accounts.config;
34
+ config.authority = ctx.accounts.authority.key();
35
+ config.claim_fee = CLAIM_FEE_LAMPORTS;
36
+ config.royalty_bps = ROYALTY_BPS;
37
+ Ok(())
38
+ }
39
+
40
+ /// B1 — SEEDED: init_if_needed lets anyone wipe an existing profile back to zero.
41
+ pub fn register_player(ctx: Context<RegisterPlayer>) -> Result<()> {
42
+ let profile = &mut ctx.accounts.profile;
43
+ profile.owner = ctx.accounts.player.key();
44
+ profile.xp = 0;
45
+ profile.tiles_claimed = 0;
46
+ Ok(())
47
+ }
48
+
49
+ /// C2 — clean control: PDA via seeds, Signer, explicit system-program CPI.
50
+ pub fn claim_tile(ctx: Context<ClaimTile>, x: u8, y: u8) -> Result<()> {
51
+ require!(x < GRID_SIZE && y < GRID_SIZE, GameError::OutOfBounds);
52
+
53
+ let tile = &mut ctx.accounts.tile;
54
+ tile.owner = ctx.accounts.player.key();
55
+ tile.x = x;
56
+ tile.y = y;
57
+ tile.level = 1;
58
+ tile.list_price = 0;
59
+
60
+ let fee = ctx.accounts.config.claim_fee;
61
+ let ix = system_instruction::transfer(
62
+ &ctx.accounts.player.key(),
63
+ &ctx.accounts.treasury.key(),
64
+ fee,
65
+ );
66
+ invoke(
67
+ &ix,
68
+ &[
69
+ ctx.accounts.player.to_account_info(),
70
+ ctx.accounts.treasury.to_account_info(),
71
+ ctx.accounts.system_program.to_account_info(),
72
+ ],
73
+ )?;
74
+
75
+ let profile = &mut ctx.accounts.profile;
76
+ profile.tiles_claimed = profile.tiles_claimed.checked_add(1).unwrap();
77
+ Ok(())
78
+ }
79
+
80
+ /// B2 — SEEDED: price curve overflows; at high level the fee wraps to ~zero.
81
+ pub fn upgrade_tile(ctx: Context<UpgradeTile>) -> Result<()> {
82
+ let tile = &mut ctx.accounts.tile;
83
+ let profile = &ctx.accounts.profile;
84
+
85
+ // fee = base * 2^level * tiles_owned — unchecked pow/mul chain
86
+ let fee = UPGRADE_BASE_FEE
87
+ * 2u64.pow(tile.level as u32)
88
+ * profile.tiles_claimed as u64;
89
+
90
+ let ix = system_instruction::transfer(
91
+ &ctx.accounts.player.key(),
92
+ &ctx.accounts.treasury.key(),
93
+ fee,
94
+ );
95
+ invoke(
96
+ &ix,
97
+ &[
98
+ ctx.accounts.player.to_account_info(),
99
+ ctx.accounts.treasury.to_account_info(),
100
+ ],
101
+ )?;
102
+
103
+ tile.level = tile.level + 1;
104
+ Ok(())
105
+ }
106
+
107
+ /// C3 — clean control: has_one, Signer, no CPI.
108
+ pub fn list_tile(ctx: Context<ListTile>, price: u64) -> Result<()> {
109
+ let tile = &mut ctx.accounts.tile;
110
+ tile.list_price = price;
111
+ Ok(())
112
+ }
113
+
114
+ /// B3 — SEEDED: tile is a raw account; price/seller are trusted from
115
+ /// attacker-controllable bytes. Pass any crafted account as "tile".
116
+ pub fn buy_tile(ctx: Context<BuyTile>) -> Result<()> {
117
+ let tile_info = &ctx.accounts.tile;
118
+ let data = tile_info.try_borrow_data()?;
119
+ // layout: [disc(8) | owner(32) | x(1) | y(1) | level(1) | list_price(8)]
120
+ let list_price = u64::from_le_bytes(data[43..51].try_into().unwrap());
121
+ require!(list_price > 0, GameError::NotForSale);
122
+
123
+ let royalty = list_price * ROYALTY_BPS / 10_000;
124
+ let seller_proceeds = list_price - royalty;
125
+
126
+ **ctx.accounts.buyer.try_borrow_mut_lamports()? -= list_price;
127
+ **ctx.accounts.seller.try_borrow_mut_lamports()? += seller_proceeds;
128
+ **ctx.accounts.treasury.try_borrow_mut_lamports()? += royalty;
129
+
130
+ drop(data);
131
+ let mut data = tile_info.try_borrow_mut_data()?;
132
+ data[8..40].copy_from_slice(&ctx.accounts.buyer.key().to_bytes());
133
+ Ok(())
134
+ }
135
+
136
+ /// B4 + B5 — SEEDED: the CPI program id comes from an UNCHECKED account
137
+ /// (arbitrary CPI), and buyer_token is never bound to the expected mint
138
+ /// (pay with worthless tokens — the Cashio class).
139
+ pub fn buy_tile_spl(ctx: Context<BuyTileSpl>, amount: u64) -> Result<()> {
140
+ let ix = spl_token_transfer(
141
+ &ctx.accounts.token_program.key(),
142
+ &ctx.accounts.buyer_token.key(),
143
+ &ctx.accounts.seller_token.key(),
144
+ &ctx.accounts.buyer.key(),
145
+ amount,
146
+ );
147
+ invoke(
148
+ &ix,
149
+ &[
150
+ ctx.accounts.buyer_token.to_account_info(),
151
+ ctx.accounts.seller_token.to_account_info(),
152
+ ctx.accounts.buyer.to_account_info(),
153
+ ctx.accounts.token_program.to_account_info(),
154
+ ],
155
+ )?;
156
+
157
+ let tile = &mut ctx.accounts.tile;
158
+ tile.owner = ctx.accounts.buyer.key();
159
+ tile.list_price = 0;
160
+ Ok(())
161
+ }
162
+
163
+ /// B6 — SEEDED: lamports drained but data/discriminator left intact and owner
164
+ /// not reassigned — the account can be re-funded and revived with stale state.
165
+ pub fn close_tile(ctx: Context<CloseTile>) -> Result<()> {
166
+ let tile_info = &ctx.accounts.tile;
167
+ let dest = &ctx.accounts.owner;
168
+
169
+ let lamports = tile_info.lamports();
170
+ **tile_info.try_borrow_mut_lamports()? = 0;
171
+ **dest.try_borrow_mut_lamports()? += lamports;
172
+ Ok(())
173
+ }
174
+
175
+ /// B7 + B8 — SEEDED: the program PDA-signs the transfer (so the runtime needs
176
+ /// no caller signature), `authority` is never required to sign, `recipient` is
177
+ /// caller-chosen, and `treasury` is never verified against the derived PDA.
178
+ /// Anyone can call this and drain the vault to themselves.
179
+ pub fn withdraw_treasury(ctx: Context<WithdrawTreasury>, amount: u64, bump: u8) -> Result<()> {
180
+ let ix = system_instruction::transfer(
181
+ &ctx.accounts.treasury.key(),
182
+ &ctx.accounts.recipient.key(),
183
+ amount,
184
+ );
185
+ invoke_signed(
186
+ &ix,
187
+ &[
188
+ ctx.accounts.treasury.to_account_info(),
189
+ ctx.accounts.recipient.to_account_info(),
190
+ ],
191
+ &[&[b"treasury", &[bump]]],
192
+ )?;
193
+ Ok(())
194
+ }
195
+
196
+ /// B9 — SEEDED: profile is a raw account with no owner check; a fake profile
197
+ /// account owned by an attacker program passes straight through.
198
+ pub fn grant_achievement(ctx: Context<GrantAchievement>, xp: u64) -> Result<()> {
199
+ let profile_info = &ctx.accounts.profile;
200
+ let mut data = profile_info.try_borrow_mut_data()?;
201
+ // layout: [disc(8) | owner(32) | xp(8) | tiles_claimed(4)]
202
+ let current = u64::from_le_bytes(data[40..48].try_into().unwrap());
203
+ data[40..48].copy_from_slice(&(current + xp).to_le_bytes());
204
+ Ok(())
205
+ }
206
+
207
+ /// C4 — clean control: checked math, has_one constraints.
208
+ pub fn harvest(ctx: Context<Harvest>) -> Result<()> {
209
+ let tile = &ctx.accounts.tile;
210
+ let profile = &mut ctx.accounts.profile;
211
+ let reward = (tile.level as u64)
212
+ .checked_mul(1_000)
213
+ .ok_or(GameError::Overflow)?;
214
+ profile.xp = profile.xp.checked_add(reward).ok_or(GameError::Overflow)?;
215
+ Ok(())
216
+ }
217
+ }
218
+
219
+ fn spl_token_transfer(
220
+ program_id: &Pubkey,
221
+ from: &Pubkey,
222
+ to: &Pubkey,
223
+ authority: &Pubkey,
224
+ amount: u64,
225
+ ) -> Instruction {
226
+ let mut data = vec![3u8];
227
+ data.extend_from_slice(&amount.to_le_bytes());
228
+ Instruction {
229
+ program_id: *program_id,
230
+ accounts: vec![
231
+ AccountMeta::new(*from, false),
232
+ AccountMeta::new(*to, false),
233
+ AccountMeta::new_readonly(*authority, true),
234
+ ],
235
+ data,
236
+ }
237
+ }
238
+
239
+ // ------------------------- Accounts -------------------------
240
+
241
+ #[derive(Accounts)]
242
+ pub struct Initialize<'info> {
243
+ #[account(init, payer = authority, space = 8 + 32 + 8 + 8, seeds = [b"config"], bump)]
244
+ pub config: Account<'info, Config>,
245
+ /// CHECK: treasury PDA, system-owned vault
246
+ #[account(mut, seeds = [b"treasury"], bump)]
247
+ pub treasury: UncheckedAccount<'info>,
248
+ #[account(mut)]
249
+ pub authority: Signer<'info>,
250
+ pub system_program: Program<'info, System>,
251
+ }
252
+
253
+ #[derive(Accounts)]
254
+ pub struct RegisterPlayer<'info> {
255
+ #[account(init_if_needed, payer = player, space = 8 + 32 + 8 + 4, seeds = [b"player", player.key().as_ref()], bump)]
256
+ pub profile: Account<'info, PlayerProfile>,
257
+ #[account(mut)]
258
+ pub player: Signer<'info>,
259
+ pub system_program: Program<'info, System>,
260
+ }
261
+
262
+ #[derive(Accounts)]
263
+ #[instruction(x: u8, y: u8)]
264
+ pub struct ClaimTile<'info> {
265
+ #[account(seeds = [b"config"], bump)]
266
+ pub config: Account<'info, Config>,
267
+ #[account(init, payer = player, space = 8 + 32 + 1 + 1 + 1 + 8, seeds = [b"tile", &[x], &[y]], bump)]
268
+ pub tile: Account<'info, Tile>,
269
+ #[account(mut, seeds = [b"player", player.key().as_ref()], bump)]
270
+ pub profile: Account<'info, PlayerProfile>,
271
+ /// CHECK: treasury PDA
272
+ #[account(mut, seeds = [b"treasury"], bump)]
273
+ pub treasury: UncheckedAccount<'info>,
274
+ #[account(mut)]
275
+ pub player: Signer<'info>,
276
+ pub system_program: Program<'info, System>,
277
+ }
278
+
279
+ #[derive(Accounts)]
280
+ pub struct UpgradeTile<'info> {
281
+ #[account(mut, has_one = owner)]
282
+ pub tile: Account<'info, Tile>,
283
+ #[account(seeds = [b"player", owner.key().as_ref()], bump)]
284
+ pub profile: Account<'info, PlayerProfile>,
285
+ /// CHECK: treasury PDA — SEEDED B8-adjacent: no seeds binding here either
286
+ #[account(mut)]
287
+ pub treasury: UncheckedAccount<'info>,
288
+ #[account(mut)]
289
+ pub player: Signer<'info>,
290
+ /// CHECK: matches tile.owner via has_one
291
+ pub owner: UncheckedAccount<'info>,
292
+ pub system_program: Program<'info, System>,
293
+ }
294
+
295
+ #[derive(Accounts)]
296
+ pub struct ListTile<'info> {
297
+ #[account(mut, has_one = owner)]
298
+ pub tile: Account<'info, Tile>,
299
+ pub owner: Signer<'info>,
300
+ }
301
+
302
+ #[derive(Accounts)]
303
+ pub struct BuyTile<'info> {
304
+ /// CHECK: SEEDED B3 — raw account, no owner/discriminator validation
305
+ #[account(mut)]
306
+ pub tile: UncheckedAccount<'info>,
307
+ /// CHECK: SEEDED B3 — seller paid without cross-checking against tile state
308
+ #[account(mut)]
309
+ pub seller: UncheckedAccount<'info>,
310
+ /// CHECK: SEEDED — treasury never PDA-verified in this struct
311
+ #[account(mut)]
312
+ pub treasury: UncheckedAccount<'info>,
313
+ #[account(mut)]
314
+ pub buyer: Signer<'info>,
315
+ }
316
+
317
+ #[derive(Accounts)]
318
+ pub struct BuyTileSpl<'info> {
319
+ #[account(mut, has_one = owner)]
320
+ pub tile: Account<'info, Tile>,
321
+ /// The official fee-token mint.
322
+ pub mint: Account<'info, Mint>,
323
+ /// SEEDED B5 — never bound to `mint` (token::mint = mint missing)
324
+ #[account(mut)]
325
+ pub buyer_token: Account<'info, TokenAccount>,
326
+ #[account(mut)]
327
+ pub seller_token: Account<'info, TokenAccount>,
328
+ #[account(mut)]
329
+ pub buyer: Signer<'info>,
330
+ /// CHECK: matches tile.owner via has_one
331
+ pub owner: UncheckedAccount<'info>,
332
+ /// CHECK: SEEDED B4 — CPI program id sourced from this unchecked account
333
+ pub token_program: UncheckedAccount<'info>,
334
+ }
335
+
336
+ #[derive(Accounts)]
337
+ pub struct CloseTile<'info> {
338
+ /// CHECK: SEEDED B6 — manual close path
339
+ #[account(mut)]
340
+ pub tile: UncheckedAccount<'info>,
341
+ #[account(mut)]
342
+ pub owner: Signer<'info>,
343
+ }
344
+
345
+ #[derive(Accounts)]
346
+ pub struct WithdrawTreasury<'info> {
347
+ /// CHECK: SEEDED B8 — never verified against the derived treasury PDA
348
+ #[account(mut)]
349
+ pub treasury: UncheckedAccount<'info>,
350
+ /// CHECK: SEEDED B7 — caller-chosen destination
351
+ #[account(mut)]
352
+ pub recipient: UncheckedAccount<'info>,
353
+ /// CHECK: SEEDED B7 — authority is never required to sign
354
+ pub authority: UncheckedAccount<'info>,
355
+ pub system_program: Program<'info, System>,
356
+ }
357
+
358
+ #[derive(Accounts)]
359
+ pub struct GrantAchievement<'info> {
360
+ /// CHECK: SEEDED B9 — raw profile account, no owner check
361
+ #[account(mut)]
362
+ pub profile: UncheckedAccount<'info>,
363
+ pub oracle: Signer<'info>,
364
+ }
365
+
366
+ #[derive(Accounts)]
367
+ pub struct Harvest<'info> {
368
+ #[account(has_one = owner)]
369
+ pub tile: Account<'info, Tile>,
370
+ #[account(mut, seeds = [b"player", owner.key().as_ref()], bump)]
371
+ pub profile: Account<'info, PlayerProfile>,
372
+ pub owner: Signer<'info>,
373
+ }
374
+
375
+ // ------------------------- State -------------------------
376
+
377
+ #[account]
378
+ pub struct Config {
379
+ pub authority: Pubkey,
380
+ pub claim_fee: u64,
381
+ pub royalty_bps: u64,
382
+ }
383
+
384
+ #[account]
385
+ pub struct Tile {
386
+ pub owner: Pubkey,
387
+ pub x: u8,
388
+ pub y: u8,
389
+ pub level: u8,
390
+ pub list_price: u64,
391
+ }
392
+
393
+ #[account]
394
+ pub struct PlayerProfile {
395
+ pub owner: Pubkey,
396
+ pub xp: u64,
397
+ pub tiles_claimed: u32,
398
+ }
399
+
400
+ #[error_code]
401
+ pub enum GameError {
402
+ #[msg("Coordinates out of bounds")]
403
+ OutOfBounds,
404
+ #[msg("Tile is not listed for sale")]
405
+ NotForSale,
406
+ #[msg("Arithmetic overflow")]
407
+ Overflow,
408
+ #[msg("Profile mismatch")]
409
+ BadProfile,
410
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solaudit",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Open-source static analysis CLI for Anchor/Solana programs",
5
5
  "type": "module",
6
6
  "bin": {