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': {