ret2libc, and a climb that is a final exam

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 16:22:38 +10:00
parent df02345a61
commit 45b3cdfb00
4 changed files with 472 additions and 54 deletions

View File

@ -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('<k>A</k> <k>D</k>steer &nbsp; <k>E</k>use');
ctx.hint('<k>A</k> <k>D</k>steer &nbsp; <k>SPACE</k>seize &nbsp; <k>E</k>use');
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.<br><i>it is only whether you take it clean.</i>', 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('<b>your steering is inverted for six seconds</b>', 4000);
S.passed++;
} else if (ok) {
S.passed++;
A.burst(880 + S.passed * 200, 1320 + S.passed * 200, 0.16, 0.16);
ctx.say(`<b>clean</b> ${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(`<b>${why}</b>`, 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('<b>branch</b> 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. <b>but it left a trace in the cache,<br>and you can read the cache.</b>', 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<br>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');

View File

@ -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}`);

View File

@ -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('<b>*** NX ***</b>', 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.<br>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,<br>and libc is <i>full</i> 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('<k>A</k> <k>D</k>move<br><k>SPACE</k>jump<br>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(`<b>${g.gad}</b> — real gadget. no use to you here.<br>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('<b>ret → pop rdi → "/bin/sh" → system</b>', 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;

View File

@ -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 2134 ──────────────────────────────────────────────────────────
'morris': {