From 45b3cdfb00bf8263fb2228d7c2359fa6e2dc349a Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 2 Aug 2026 16:22:38 +1000 Subject: [PATCH] ret2libc, and a climb that is a final exam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMASHING THE STACK: NX now arrives at the exact moment you reach the shellcode. Your sixteen bytes are still there, every one of them correct, and none of them will ever run. Then libc appears — already mapped, already executable, already somebody else's — and the only way on is to land on four gadgets in the right order and walk the bridges the chain builds behind you. The chain is the real one: ret, pop rdi, "/bin/sh", system. The four decoys are real gadgets that are genuinely no use to you here, which is what building one of these is actually like. THE CLIMB: each era now hands back the verb it taught you, once. Seize the trunk. Mind the wiper. Count the 450 ms reversal. Take the ANSI bomb and lose your steering for six seconds. Dodge what is still infectious. Ride the live half. Do not stand on the red. Plus embers, and Log4Shell finally gets its beat of silence — the world stops and the fortress walks over and reads your sign. Balance fix found while testing: the climb is hop 63, so you arrive with TTL 2 by design, and it had seven gates that each cost a TTL. Two failures killed you. Nothing in the climb spends TTL now — the ascent is graded, not lethal, and ROOT's receipt reads the grade. That keeps "TTL only goes down" intact instead of inventing a heal. All 13 hops swept: clean. Co-Authored-By: Claude Opus 5 --- src/levels/climb.js | 313 +++++++++++++++++++++++++++++++++++++------- src/levels/root.js | 2 + src/levels/smash.js | 170 +++++++++++++++++++++++- src/philes.js | 41 ++++++ 4 files changed, 472 insertions(+), 54 deletions(-) diff --git a/src/levels/climb.js b/src/levels/climb.js index 0bf0559..e14184d 100644 --- a/src/levels/climb.js +++ b/src/levels/climb.js @@ -1,26 +1,47 @@ // THE CLIMB — hops 63 → 1, in one take, at speed, on fire. -// Everything you walked through is now a corridor you are sprinting up, and each -// one lasts about fifty seconds, and the modern era gets the three funny toys -// because the modern era is the funny era. +// +// Everything you walked through is now a corridor you are sprinting up. Each era +// gets one gate, and each gate hands you back a verb you learned there for exactly +// one more use. That is the entire reason the sequence exists: not a highlight reel, +// a final exam you did not know you had been sitting. import * as THREE from '../../vendor/three.module.js'; import * as A from '../audio.js'; import * as In from '../input.js'; import { sprite, panel, glow, rng, clamp, damp } from '../kit.js'; -// in reverse, because that is the point +// in reverse, because that is the point. `gate` is the verb that era taught you. const ZONES = [ - { at: 0, name: '1971 · the trunk', bg: 0x030a14, fog: 0x0a1930, col: 0x2a5a8a, note: 'you came down through here without knowing what it was' }, - { at: 220, name: 'the copper', bg: 0x0a0704, fog: 0x1a1008, col: 0xff9a3c, note: null }, - { at: 440, name: 'the handshake', bg: 0x03060a, fog: 0x061219, col: 0x2ad8ff, note: null }, - { at: 660, name: 'the board', bg: 0x0d0700, fog: 0x2a1400, col: 0xffb648, note: 'the board is still up. it will be up until the line is disconnected.' }, - { at: 880, name: '1988', bg: 0x04060a, fog: 0x0a1014, col: 0x63ffa2, note: null }, - { at: 1100, name: 'efnet', bg: 0x0b0716, fog: 0x150e28, col: 0xff55ff, note: null }, - { at: 1320, name: 'the stack', bg: 0x05080b, fog: 0x0b141c, col: 0x9ff5ff, note: null }, - { at: 1540, name: 'modern', bg: 0xf2f7fa, fog: 0xfbfdff, col: 0x1a6d8e, note: 'and this is the part they armoured' }, + { at: 0, name: '1971 · the trunk', bg: 0x030a14, fog: 0x0a1930, col: 0x2a5a8a, + gate: 'seize', note: 'you came down through here without knowing what any of it was' }, + { at: 240, name: 'the copper', bg: 0x0a0704, fog: 0x1a1008, col: 0xff9a3c, + gate: 'beat', note: null }, + { at: 480, name: 'the handshake', bg: 0x03060a, fog: 0x061219, col: 0x2ad8ff, + gate: 'phase', note: null }, + { at: 720, name: 'the board', bg: 0x0d0700, fog: 0x2a1400, col: 0xffb648, + gate: 'bomb', note: 'the board is still up. it will be up until the line is disconnected.' }, + { at: 960, name: '1988', bg: 0x04060a, fog: 0x0a1014, col: 0x63ffa2, + gate: 'worm', note: null }, + { at: 1200, name: 'efnet', bg: 0x0b0716, fog: 0x150e28, col: 0xff55ff, + gate: 'split', note: null }, + { at: 1440, name: 'the stack', bg: 0x05080b, fog: 0x0b141c, col: 0x9ff5ff, + gate: 'nx', note: null }, + { at: 1680, name: 'modern', bg: 0xf2f7fa, fog: 0xfbfdff, col: 0x1a6d8e, + gate: null, note: 'and this is the part they armoured' }, ]; -const SPECTRE_AT = 1620, ROWHAMMER_AT = 1780, LOG4_AT = 1940, END_AT = 2080; +const GATE_AT = 150; // how far into a zone its gate sits +const SPECTRE_AT = 1760, ROWHAMMER_AT = 1900, LOG4_AT = 2040, END_AT = 2200; + +const HINT = { + seize: 'hold SPACE — 2600 Hz', + beat: 'through the gap', + phase: 'it flips every 450 ms', + bomb: '!!!! K-RAD ANSI !!!!', + worm: 'they are still falling over', + split: 'the lit half', + nx: 'you may not stand there', +}; let S = {}; @@ -30,8 +51,10 @@ export default { async mount(ctx) { const { scene, camera } = ctx; - S = { t: 0, z: 0, x: 0, speed: 46, zone: -1, rings: [], done: false, - spec: null, hammer: 0, hammered: false, log4: false, gateOpen: false }; + S = { t: 0, z: 0, x: 0, speed: 44, zone: -1, rings: [], done: false, + spec: null, hammer: 0, hammered: false, log4: false, gateOpen: false, + gates: [], invert: 0, embers: [], stall: 0, passed: 0, failed: 0 }; + window.__S = S; // debug handle scene.fog = new THREE.FogExp2(0x0a1930, 0.012); scene.background = new THREE.Color(0x030a14); @@ -48,7 +71,6 @@ export default { scene.add(r); S.rings.push(r); } - // debris, so speed reads const rand = rng(64); S.bits = []; for (let i = 0; i < 160; i++) { @@ -56,12 +78,24 @@ export default { m.userData = { a: rand() * 6.28, r: 4 + rand() * 8, z: -rand() * 900, s: 0.4 + rand() * 1.4 }; scene.add(m); S.bits.push(m); } + // ── it is on fire. the doc says so and it was not, and it should be. + for (let i = 0; i < 90; i++) { + const m = new THREE.Mesh(new THREE.TetrahedronGeometry(0.22 + rand() * 0.4), glow(0xff7a2a, 1.4)); + m.userData = { a: rand() * 6.28, r: 2 + rand() * 10, z: -rand() * 900, s: 1.4 + rand() * 2.2, w: rand() * 6 }; + m.visible = false; + scene.add(m); S.embers.push(m); + } S.zoneTag = sprite('', { color: '#ffffff', size: 1.6 }); S.zoneTag.position.set(0, 6.5, -34); scene.add(S.zoneTag); + S.gateTag = sprite('', { color: '#ffd9a8', size: 1.1 }); + S.gateTag.position.set(0, -6.0, -30); scene.add(S.gateTag); + S.gateTag.visible = false; - // ── SPECTRE. you run down both sides of the branch at once. one of them never - // happened, and you carry something out of it anyway. + // ── one gate per era, built from that era's furniture + ZONES.forEach(Z => { if (Z.gate) S.gates.push(this.makeGate(ctx, Z)); }); + + // ── SPECTRE S.specGroup = new THREE.Group(); scene.add(S.specGroup); S.ghostMe = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1), new THREE.MeshBasicMaterial({ color: 0xff8ad8, transparent: true, opacity: 0.55 })); @@ -70,7 +104,7 @@ export default { S.specGroup.add(S.specToken); S.specGroup.visible = false; - // ── ROWHAMMER. a wall you hit until the room next door changes. + // ── ROWHAMMER S.wall = new THREE.Mesh(new THREE.BoxGeometry(26, 22, 1.2), new THREE.MeshStandardMaterial({ color: 0x1b2630, roughness: 0.9 })); S.wall.position.set(0, 0, -(ROWHAMMER_AT + 40)); scene.add(S.wall); @@ -78,12 +112,21 @@ export default { S.wallTag.position.set(0, 4, -(ROWHAMMER_AT + 38)); scene.add(S.wallTag); S.wallTag.visible = false; - // ── LOG4SHELL. you write it on a piece of card and hold it up. + // ── LOG4SHELL S.sign = panel('${jndi:ldap://x/}', { color: '#63ffa2', size: 2.0 }); S.sign.position.set(0, 0, -(LOG4_AT + 40)); scene.add(S.sign); S.signBack = new THREE.Mesh(new THREE.BoxGeometry(22, 12, 0.5), new THREE.MeshStandardMaterial({ color: 0x0a1a12, roughness: 1 })); S.signBack.position.set(0, 0, -(LOG4_AT + 41)); scene.add(S.signBack); + // the fortress, which will walk over and read it + S.fortress = new THREE.Group(); + for (let i = 0; i < 5; i++) { + const b = new THREE.Mesh(new THREE.BoxGeometry(3.4, 9 + i * 2, 3.4), + new THREE.MeshStandardMaterial({ color: 0xdfe8f2, roughness: 0.5 })); + b.position.set((i - 2) * 5.2, 0, 0); S.fortress.add(b); + } + S.fortress.position.set(0, 0, -(LOG4_AT + 90)); S.fortress.visible = false; + scene.add(S.fortress); S.gate = new THREE.Mesh(new THREE.PlaneGeometry(24, 24), new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.45, side: THREE.DoubleSide })); @@ -95,19 +138,82 @@ export default { A.pad([55, 82.41, 110, 164.81, 220], { gain: 0.05, type: 'sawtooth' }); S.hiss = A.carrier(0.05); - ctx.hint('A Dsteer   Euse'); + ctx.hint('A Dsteer   SPACEseize   Euse'); await ctx.card('THE CLIMB', 'everything, in reverse, at speed', 2800); + ctx.say('every one of these taught you something. you get to use each of them once more.', 5600); + setTimeout(() => ctx.say('nothing up here can kill you. you already got this far.
it is only whether you take it clean.', 5600), 6000); + }, + + // ── the gates. Each is built out of the era it belongs to and tests its verb. + makeGate(ctx, Z) { + const z = -(Z.at + GATE_AT); + const g = { kind: Z.gate, z, col: Z.col, group: new THREE.Group(), done: false, ok: false }; + ctx.scene.add(g.group); + g.group.position.z = z; + + if (Z.gate === 'seize') { + // a trunk that will not let you past unless you tell it it is idle + const m = new THREE.Mesh(new THREE.TorusGeometry(9, 1.1, 8, 32), glow(0xff4a5e, 0.9)); + g.group.add(m); g.ring = m; + } + if (Z.gate === 'beat') { + // a Strowger wiper. it steps. you go through the gap on the step. + const bar = new THREE.Mesh(new THREE.BoxGeometry(22, 2.2, 1.0), glow(0xff9a3c, 1.0)); + g.group.add(bar); g.bar = bar; + } + if (Z.gate === 'phase') { + // ANSam. two panels, and which one is open flips every 450 ms. + [-1, 1].forEach(sgn => { + const p = new THREE.Mesh(new THREE.BoxGeometry(10, 22, 0.8), + new THREE.MeshBasicMaterial({ color: 0x2ad8ff, transparent: true, opacity: 0.5 })); + p.position.x = sgn * 5.6; g.group.add(p); + (g.panels = g.panels || []).push({ mesh: p, sgn }); + }); + } + if (Z.gate === 'bomb') { + const m = new THREE.Mesh(new THREE.TorusGeometry(9, 1.4, 6, 16), glow(0xffb648, 1.0)); + g.group.add(m); g.ring = m; + const t = sprite('!!!! K-RAD ANSI !!!!', { color: '#ffb648', size: 1.0 }); + t.position.y = 5; g.group.add(t); + } + if (Z.gate === 'worm') { + for (let i = 0; i < 4; i++) { + const m = new THREE.Mesh(new THREE.SphereGeometry(2.0, 12, 9), glow(0x63ffa2, 1.0)); + m.position.x = -9 + i * 6; + g.group.add(m); + (g.nodes = g.nodes || []).push(m); + } + } + if (Z.gate === 'split') { + const dark = new THREE.Mesh(new THREE.BoxGeometry(12, 24, 0.6), + new THREE.MeshBasicMaterial({ color: 0x000000 })); + dark.position.x = -6; g.group.add(dark); g.dark = dark; + g.side = -1; + } + if (Z.gate === 'nx') { + const bad = new THREE.Mesh(new THREE.BoxGeometry(11, 24, 0.7), + new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.42 })); + bad.position.x = 5.5; g.group.add(bad); g.bad = bad; + const t = sprite('NX', { color: '#ff4a5e', size: 1.2 }); + t.position.set(5.5, 5, 0); g.group.add(t); + } + return g; }, update(dt, t, ctx) { S.t += dt; + + // the fortress reading a sign is worth stopping the world for + if (S.stall > 0) { S.stall -= dt; dt = 0; } S.z += S.speed * dt; - S.speed = Math.min(120, S.speed + dt * 2.2); - S.x = clamp(S.x + In.axis().x * dt * 22, -8, 8); + S.speed = Math.min(120, S.speed + dt * 2.0); + S.invert = Math.max(0, S.invert - dt); + const steer = In.axis().x * (S.invert > 0 ? -1 : 1); + S.x = clamp(S.x + steer * dt * 22, -9, 9); const cam = ctx.camera; cam.position.set(S.x, Math.sin(S.t * 0.7) * 0.6, 0); - cam.rotation.z = damp(cam.rotation.z, -In.axis().x * 0.12, 4, dt); + cam.rotation.z = damp(cam.rotation.z, -steer * 0.12, 4, dt); S.light.position.set(S.x, 0, -14); // ── zone crossfade @@ -138,19 +244,126 @@ export default { m.material.color.setHex(Z.col); m.rotation.x += dt * 2; m.rotation.y += dt * 1.4; }); + // embers, thicker the higher you get + const heat = clamp(S.z / END_AT, 0, 1); + S.embers.forEach((m, i) => { + const u = m.userData; + m.visible = i / S.embers.length < heat * 1.2; + if (!m.visible) return; + u.z += S.speed * dt * u.s; + if (u.z > 6) { u.z = -900; u.a = Math.random() * 6.28; } + m.position.set(Math.cos(u.a + S.t * 0.3) * u.r, Math.sin(u.a + S.t * 0.3) * u.r, u.z); + m.rotation.x += dt * u.w; m.rotation.z += dt * u.w * 0.6; + m.material.color.setRGB(1, 0.35 + Math.sin(S.t * 6 + i) * 0.16, 0.1); + }); - // ── SPECTRE + this.gates(dt, ctx); + this.modern(dt, ctx); + + if (S.z > END_AT && !S.done) { + S.done = true; + (async () => { + S.hiss.stop(); A.stopPad(); + ctx.game.flags.climbClean = S.passed; + ctx.game.flags.climbGates = S.gates.length; + if (S.passed >= 6) ctx.phile('climb'); + await ctx.card('ROOT', `${S.passed} of ${S.gates.length} clean · one hop remaining`, 3600); + ctx.next(); + })(); + } + }, + + // ── each gate, tested at the moment you cross its plane + gates(dt, ctx) { + for (const g of S.gates) { + const zz = g.z + S.z; // where it is relative to you + g.group.position.z = zz; + const near = zz > -170 && zz < 12; + g.group.visible = near; + if (!near) continue; + + // telegraph + if (zz > -120 && !g.told) { + g.told = true; + S.gateTag.visible = true; + S.gateTag.userData.setText(HINT[g.kind] || '', '#ffd9a8'); + } + + // per-kind motion + if (g.kind === 'beat') { + g.bar.position.x = Math.sin(S.t * 2.1) * 9; + g.bar.material.color.setHex(0xff9a3c); + } + if (g.kind === 'phase') { + const open = Math.floor(S.t / 0.45) % 2 === 0 ? -1 : 1; // the real 450 ms + g.open = open; + g.panels.forEach(p => { p.mesh.material.opacity = p.sgn === open ? 0.05 : 0.55; }); + } + if (g.kind === 'worm') { + g.nodes.forEach((m, i) => { + m.position.y = Math.sin(S.t * 1.6 + i * 1.4) * 5; + m.scale.setScalar(1 + Math.sin(S.t * 5 + i) * 0.12); + }); + } + if (g.kind === 'split') { + if (!g.flipped && zz > -90) { g.flipped = true; g.side = Math.random() < 0.5 ? -1 : 1; } + g.dark.position.x = g.side * 6; + } + if (g.kind === 'seize') { + const held = In.down('Space'); + g.ring.material.color.setHex(held ? 0x63ffa2 : 0xff4a5e); + if (held && !g.seizing) { g.seizing = A.seize(0.13); } + if (!held && g.seizing) { g.seizing.stop(0.05); g.seizing = null; } + } + + // the moment of truth + if (!g.done && zz > -2) { + g.done = true; + S.gateTag.visible = false; + if (g.seizing) { g.seizing.stop(0.08); g.seizing = null; } + let ok = true, why = ''; + switch (g.kind) { + case 'seize': ok = In.down('Space'); why = 'the trunk never went idle'; break; + case 'beat': ok = Math.abs(S.x - g.bar.position.x) > 6.0; why = 'the wiper had you'; break; + case 'phase': ok = Math.sign(S.x || 1) === g.open; why = 'wrong side of the reversal'; break; + case 'bomb': ok = true; break; + case 'worm': ok = g.nodes.every(n => Math.hypot(S.x - n.position.x, n.position.y) > 3.2); why = 'still infectious'; break; + case 'split': ok = Math.sign(S.x || 1) !== g.side; why = 'you rode the dead half'; break; + case 'nx': ok = S.x < 0; why = 'you may not stand there'; break; + } + if (g.kind === 'bomb') { + // no fail state. it just ruins you for six seconds, exactly as it did then. + S.invert = 6; + A.burst(2400, 3100, 0.3, 0.16); + ctx.say('your steering is inverted for six seconds', 4000); + S.passed++; + } else if (ok) { + S.passed++; + A.burst(880 + S.passed * 200, 1320 + S.passed * 200, 0.16, 0.16); + ctx.say(`clean ${S.passed}/${S.gates.length}`, 1600); + } else { + S.failed++; + A.burst(300, 220, 0.32, 0.2); + ctx.link.glitch = 0.4; + setTimeout(() => { ctx.link.glitch = 0; }, 400); + ctx.say(`${why}`, 2400); + } + } + } + }, + + // ── the three modern toys + modern(dt, ctx) { if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 120) { if (!S.spec) { S.spec = { got: false, squashed: false }; S.specGroup.visible = true; ctx.say('branch you are going to run down both sides of this. one of them is not going to have happened.', 5600); } - // the speculative you, mirrored — the timeline that gets thrown away S.ghostMe.position.set(-S.x, 0, -8); S.specToken.position.set(-4.5, 0, -26 + Math.sin(S.t * 2) * 3); S.specToken.rotation.y += dt * 3; - if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.2 && S.z > SPECTRE_AT + 40) { + if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.4 && S.z > SPECTRE_AT + 40) { S.spec.got = true; S.specToken.visible = false; A.burst(1760, 2640, 0.24, 0.18); @@ -163,10 +376,10 @@ export default { if (S.spec.got) { S.gateOpen = true; ctx.say('the branch was squashed. it never happened. but it left a trace in the cache,
and you can read the cache.
', 6400); - ctx.phile('spectre', 'speculative execution', 'the timeline that was thrown away still warmed a cache line, and a cache line is a message.'); + ctx.phile('spectre'); } else { ctx.say('the branch was squashed and you brought nothing out of it.', 4000); - ctx.spendTTL(1, 'mispredicted'); + S.failed++; } } if (S.gate) { @@ -176,12 +389,11 @@ export default { S.gate.material.opacity = S.gateOpen ? 0.08 : 0.45; if (!S.gateOpen && Math.abs(gz) < 3 && !S.bounced) { S.bounced = true; - ctx.spendTTL(1, 'closed'); + S.failed++; A.burst(300, 220, 0.35, 0.2); } } - // ── ROWHAMMER const wz = -(ROWHAMMER_AT + 40) + S.z; S.wall.position.z = wz; S.wallTag.position.z = wz + 2; @@ -201,44 +413,51 @@ export default { ctx.link.glitch = 0; A.burst(1320, 1980, 0.4, 0.2); ctx.say('a bit flipped in a row you were never allowed to touch. you did not read it. you shouted next to it.', 6000); - ctx.phile('rowhammer', 'rowhammer', 'you are not allowed in that row. nobody said you could not lean on the wall.'); + ctx.phile('rowhammer'); } } } if (Math.abs(wz) < 2 && !S.hammered && !S.wallHit) { S.wallHit = true; - ctx.spendTTL(1, 'wall'); + S.failed++; S.hammered = true; S.wall.visible = false; S.wallTag.visible = false; } - // ── LOG4SHELL. the biggest laugh in the game, sixty seconds before the end. + // ── LOG4SHELL. it gets its beat of silence. const lz = -(LOG4_AT + 40) + S.z; S.sign.position.z = lz + 0.6; S.signBack.position.z = lz; + S.fortress.position.z = lz - 50 + (S.log4 ? S.fortWalk : 0); if (Math.abs(lz) < 70 && !S.log4) { if (!S.log4Said) { S.log4Said = true; ctx.say('you write it on a piece of card.', 3200); } if (In.useHit() || Math.abs(lz) < 30) { S.log4 = true; + S.fortWalk = 0; + S.fortress.visible = true; + S.stall = 4.6; // everything stops. it comes over to read. A.burst(660, 990, 0.4, 0.2); - setTimeout(() => A.burst(1320, 1980, 0.5, 0.2), 300); - S.signBack.visible = false; - ctx.say('and the entire fortress walks over, reads it, and goes and fetches whatever you asked for.', 6000); - setTimeout(() => ctx.say('you did nothing. you held up a sign.', 4600), 6200); - ctx.phile('log4shell', '${jndi:ldap://}', 'it went and got it. it was very helpful. that was the bug.'); + ctx.say('and you hold it up.', 3000); + (async () => { + await ctx.sleep(1800); + ctx.say('...', 1600); + await ctx.sleep(1800); + A.burst(1320, 1980, 0.5, 0.2); + ctx.say('and the entire fortress walks over, reads it, and goes and fetches
whatever you asked for.', 5600); + await ctx.sleep(5800); + ctx.say('you did nothing. you held up a sign.', 4600); + ctx.phile('log4shell'); + S.signBack.visible = false; + })(); } } - - if (S.z > END_AT && !S.done) { - S.done = true; - (async () => { - S.hiss.stop(); A.stopPad(); - await ctx.card('ROOT', 'one hop remaining', 3200); - ctx.next(); - })(); + if (S.log4) { + S.fortWalk = (S.fortWalk || 0) + dt * 12; + S.fortress.children.forEach((b, i) => { b.position.y = Math.abs(Math.sin(S.t * 3 + i)) * 1.2; }); } }, async unmount(ctx) { S.hiss?.stop(); + S.gates?.forEach(g => g.seizing?.stop(0.05)); A.stopPad(); ctx.link.glitch = 0; document.body.classList.remove('ink-dark'); diff --git a/src/levels/root.js b/src/levels/root.js index edfe146..a5c7bd6 100644 --- a/src/levels/root.js +++ b/src/levels/root.js @@ -229,6 +229,8 @@ export default { : f.baudEarned <= 1 ? 'you saw most of this in letters' : `you negotiated ${BAUDS[f.baudEarned].label} and lived with it`); } + if (f.climbGates) lines.push(f.climbClean >= f.climbGates ? 'you took the whole climb clean' + : `${f.climbClean} of ${f.climbGates} on the way up`); if (f.elite) lines.push('somebody vouched for you'); if (f.knowsTheTrunk) lines.push('you hung up without saying goodbye'); if (g.cargo.length) lines.push(`still carrying ${g.cargo.length}`); diff --git a/src/levels/smash.js b/src/levels/smash.js index 4f1f531..19baa0e 100644 --- a/src/levels/smash.js +++ b/src/levels/smash.js @@ -10,6 +10,23 @@ import { Body, sprite, panel, glow, damp, clamp } from '../kit.js'; const CELL = 2.2; // one byte, in metres const WANT = ['A0', 'F7', 'FF', 'BF']; // 0xBFFFF7A0, little-endian, as you write it +// ── ret2libc. +// The chain is the real one: a bare ret for stack alignment, pop rdi to load the +// argument, the address of the string, then system. Everything else on the shelf is +// a genuine gadget that is genuinely no use to you here, which is exactly what +// building one of these is actually like. +const CHAIN = ['ret', 'pop rdi ; ret', '"/bin/sh"', 'system@plt']; +const GADGETS = [ + { sym: 'ret', x: 122, y: 5.6 }, + { sym: 'xor rax,rax ; ret', x: 130, y: 8.4 }, + { sym: 'pop rdi ; ret', x: 138, y: 6.2 }, + { sym: 'leave ; ret', x: 146, y: 9.6 }, + { sym: '"/bin/sh"', x: 154, y: 7.0 }, + { sym: 'pop rsi ; ret', x: 162, y: 10.4 }, + { sym: 'system@plt', x: 170, y: 7.8 }, + { sym: 'mov [rdi],rsi', x: 132, y: 12.2 }, +]; + let S = {}; const TILE = { @@ -17,7 +34,9 @@ const TILE = { ebp: { face: 0x4a3a5e, edge: 0xc89fff, ink: '#d8b8ff' }, ret: { face: 0x6a4a12, edge: 0xffb648, ink: '#ffd9a8' }, nop: { face: 0x1e5c38, edge: 0x63ffa2, ink: '#9fffc8' }, - shell: { face: 0x6a1030, edge: 0xff6a8a, ink: '#ff9fb8' }, + shell: { face: 0x6a1030, edge: 0xff6a8a, ink: '#ff9fb8' }, + gadget: { face: 0x2a2438, edge: 0x8f7fc0, ink: '#c8b8f0' }, + exit: { face: 0x0d2a18, edge: 0x63ffa2, ink: '#9fffc8' }, }; function tile(ctx, ix, y, w, kind, label, colour) { @@ -48,7 +67,9 @@ export default { async mount(ctx) { const { scene, camera } = ctx; - S = { t: 0, written: [], hasCanary: false, canaryAngry: false, phase: 'buf', slid: 0, done: false }; + S = { t: 0, written: [], hasCanary: false, canaryAngry: false, phase: 'buf', slid: 0, done: false, + nx: false, ropAt: 0, bridges: [], gadgets: [] }; + window.__S = S; // debug handle scene.background = new THREE.Color(0x080d14); scene.fog = new THREE.Fog(0x080d14, 60, 220); @@ -113,12 +134,39 @@ export default { S.sled.push(b); } // ── and the shellcode, on the far side of it - tile(ctx, 114, 0.8, 8, 'shell', 'shellcode', 0x3a0d1a); - const sc = panel('\\x31\\xc0\\x50\\x68//sh\\x68/bin\\x89\\xe3\\x50\\x53\\x89\\xe1\\xb0\\x0b\\xcd\\x80', + S.shellTile = tile(ctx, 114, 0.8, 8, 'shell', 'shellcode', 0x3a0d1a); + S.shellPanel = panel('\\x31\\xc0\\x50\\x68//sh\\x68/bin\\x89\\xe3\\x50\\x53\\x89\\xe1\\xb0\\x0b\\xcd\\x80', { color: '#ff6a8a', size: 0.55 }); - sc.position.set(117.5 * CELL, 4.2, 0); scene.add(sc); + S.shellPanel.position.set(117.5 * CELL, 4.2, 0); scene.add(S.shellPanel); S.shellX = 114 * CELL; + // ── libc. It is already mapped. It is already executable. It is full of small + // useful endings that somebody else wrote for entirely different reasons. + // You do not get to write code any more. You get to put theirs in an order. + S.gadgets = []; + GADGETS.forEach((g, i) => { + const b = tile(ctx, g.x, g.y, 2, 'gadget', null, 0x2a2438); + b.gad = g.sym; + b.decoy = !CHAIN.includes(g.sym); + const s = sprite(g.sym, { color: '#8f7fc0', size: 0.62 }); + s.position.set(b.mesh.position.x, g.y + 1.45, 0); + scene.add(s); + b.tag = s; + b.mesh.visible = false; b.edge.visible = false; s.visible = false; + b.disabled = true; // not there until NX turns on + S.gadgets.push(b); + }); + S.ropReadout = sprite('', { color: '#c89fff', size: 0.8 }); + S.ropReadout.position.set(150 * CELL, 12, 0); scene.add(S.ropReadout); + S.ropReadout.visible = false; + + // the far side, past the chain + S.exitTile = tile(ctx, 176, 5.0, 6, 'exit', 'execve', 0x0d2a18); + S.exitTile.mesh.visible = false; S.exitTile.edge.visible = false; + S.exitTile.label.visible = false; + S.exitTile.disabled = true; + S.exitX = 176 * CELL; + // a ghost who chain-smokes and is credited only as Aleph One S.ghost = new THREE.Group(); const gb = new THREE.Mesh(new THREE.SphereGeometry(0.5, 14, 10), @@ -232,16 +280,124 @@ export default { if (Math.random() < 0.25) A.blip(1400 + Math.random() * 200, 0.008, 0.02); } - if (b.pos.x > S.shellX + 2 && !S.done) { + // ── you reach the shellcode, and that is the exact moment NX arrives + if (b.pos.x > S.shellX + 2 && !S.nx) { S.nx = true; this.noExecute(ctx); } + + // ── the chain + if (S.nx && b.grounded && b.ground && b.ground.kind === 'gadget') this.gadget(ctx, b.ground); + if (S.gadgets) S.gadgets.forEach(g => { + if (!g.tag.visible) return; + const lit = S.ropAt > 0 && CHAIN.indexOf(g.gad) > -1 && CHAIN.indexOf(g.gad) < S.ropAt; + g.tag.userData.setText(g.gad, lit ? '#63ffa2' : g.armed ? '#ffd9a8' : '#8f7fc0'); + }); + + if (S.nx && b.pos.x > S.exitX + 2 && !S.done) { S.done = true; (async () => { A.burst(220, 330, 0.6, 0.2); - await ctx.card('SHELLCODE', 'execve("/bin/sh", ...)', 3200); + await ctx.card('execve("/bin/sh")', 'you did not write a single instruction', 3600); ctx.next(); })(); } }, + // ── W^X. The page is writable or it is executable and it is never both. Your + // beautiful sixteen bytes are still sitting there, intact, and completely inert. + async noExecute(ctx) { + A.thud(60, 0.9, 0.35); + ctx.link.glitch = 0.6; + // everything you built goes dead under your feet + [S.shellTile, ...S.sled].forEach(t => { + t.tag = 'nx'; + t.mesh.material.color.setHex(0x3a0d14); + if (t.mesh.material.emissive) t.mesh.material.emissive.setHex(0x000000); + t.edge.material.color.setHex(0xff4a5e); + }); + S.shellPanel.material.opacity = 0.25; S.shellPanel.material.transparent = true; + ctx.say('*** NX ***', 2400); + await ctx.sleep(1600); + ctx.link.glitch = 0; + ctx.say('the page is writable or it is executable. it is never both.', 4600); + await ctx.sleep(4800); + ctx.say('your sixteen bytes are still there. every one of them is correct.
none of them will ever run.', 5600); + await ctx.sleep(5800); + + // libc arrives — already mapped, already executable, already somebody else's + S.gadgets.forEach((g, i) => { + g.disabled = false; + g.mesh.visible = true; g.edge.visible = true; g.tag.visible = true; + setTimeout(() => A.blip(600 + i * 90, 0.02, 0.04), i * 90); + }); + S.exitTile.mesh.visible = true; S.exitTile.edge.visible = true; S.exitTile.label.visible = true; + S.exitTile.disabled = false; + S.ropReadout.visible = true; + S.ropAt = 0; + this.ropUI(); + + ctx.say('but libc is already mapped, and libc is already executable,
and libc is full of small useful endings.', 5600); + await ctx.sleep(5800); + ctx.say('you do not get to write code any more. you get to put somebody else\'s in an order.', 5600); + ctx.phile('nx'); + ctx.hint('A Dmove
SPACEjump
land on the gadgets in order'); + }, + + ropUI() { + S.ropReadout.userData.setText( + CHAIN.map((c, i) => i < S.ropAt ? c : '·'.repeat(Math.min(12, c.length))).join(' → '), + S.ropAt >= CHAIN.length ? '#63ffa2' : '#c89fff'); + }, + + // ── landing on a gadget. In order it extends the chain and lays a bridge behind + // you out of the two you have joined. Out of order it all comes apart, because + // a ROP chain is not a set of moves, it is one move with several parts. + gadget(ctx, g) { + if (g.gad === CHAIN[S.ropAt - 1]) return; // still standing on the last one + if (g.gad !== CHAIN[S.ropAt]) { + if (!g.armed && S.ropAt > 0) { + A.burst(300, 220, 0.4, 0.2); + ctx.say(`${g.gad} — real gadget. no use to you here.
the chain comes apart.`, 3600); + ctx.spendTTL(1, 'chain broke'); + S.ropAt = 0; + S.gadgets.forEach(x => { x.armed = false; x.edge.material.color.setHex(0x8f7fc0); }); + S.bridges.forEach(b => { ctx.scene.remove(b.mesh); b.box.disabled = true; }); + S.bridges.length = 0; + this.ropUI(); + } + return; + } + // correct link + const prev = S.ropAt > 0 ? S.gadgets.find(x => x.gad === CHAIN[S.ropAt - 1]) : null; + g.armed = true; + g.edge.material.color.setHex(0x63ffa2); + A.burst(880 + S.ropAt * 260, 1320 + S.ropAt * 260, 0.16, 0.18); + S.ropAt++; + this.ropUI(); + if (prev) this.bridge(ctx, prev, g); + if (S.ropAt >= CHAIN.length) { + // and the last link reaches the far side + this.bridge(ctx, g, S.exitTile); + ctx.say('ret → pop rdi → "/bin/sh" → system', 4200); + ctx.phile('ret2libc'); + ctx.spendTTL(1); + } + }, + + // a bridge is literally the two ends you joined, made walkable + bridge(ctx, a, b) { + const ax = (a.min.x + a.max.x) / 2, ay = a.max.y; + const bx = (b.min.x + b.max.x) / 2, by = b.max.y; + const len = Math.hypot(bx - ax, by - ay); + const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.22, 3.4), glow(0x63ffa2, 0.9)); + m.position.set((ax + bx) / 2, (ay + by) / 2 + 0.11, 0); + m.rotation.z = Math.atan2(by - ay, bx - ax); + ctx.scene.add(m); + // the collision is a flat run at the higher end — sloped AABBs are a lie you + // do not need to tell here, the player reads the beam and walks the shelf + const box = ctx.world.add((ax + bx) / 2, Math.min(ay, by), 0, Math.abs(bx - ax), Math.abs(by - ay) + 0.6, 3.4, 'plat'); + S.bridges.push({ mesh: m, box }); + A.blip(1600, 0.05, 0.05); + }, + async unmount(ctx) { A.stopPad(); ctx.link.glitch = 0; diff --git a/src/philes.js b/src/philes.js index c20b30c..fa51605 100644 --- a/src/philes.js +++ b/src/philes.js @@ -180,6 +180,47 @@ it is the single most forgiving thing in the whole field. everything else demands you be exactly right. the sled says: near enough.`, }, + 'nx': { + title: 'W^X', + from: 'the page is writable or it is executable', + body: `your shellcode is still there. every byte of it is correct. the return +address points at it exactly. and the processor will not run it, because the +page it is on is marked writable, and a page that is writable is not +executable, and it is never both. + +nothing you did was wrong. the rules changed underneath a thing that was +already finished.`, + }, + + 'ret2libc': { + title: 'return-oriented programming', + from: 'what you do when you are not allowed to write code', + body: `you cannot introduce a single new instruction. fine. + +the program is already full of instructions. so is every library it loaded. +and scattered through all of that are thousands of tiny fragments that +happen to end in a return — two or three useful operations and then a jump +back to whatever address is next on the stack. + +so you do not write a program. you write a *list of addresses*, and the +machine walks the list, and out the other end comes a program that nobody +ever compiled and that exists nowhere on disk. + +you did not smuggle in code. you rearranged theirs.`, + }, + + 'climb': { + title: 'the exam you did not know you were sitting', + from: 'the way back up', + body: `everything on the way down taught you a verb. + +seize the trunk. mind the wiper. count the reversal. do not read the ansi. +ride the live half. do not stand on the red. + +none of it was ever explained twice. you were simply expected to have been +paying attention, once, a long time ago, on the way past.`, + }, + // ── hop 21–34 ────────────────────────────────────────────────────────── 'morris': {