Fix stored-HTML-injection: escape AD data before it hits Markdown tables

Every table cell fed from directory content (descriptions, sAMAccountName,
OS strings, DNs, object classes) was interpolated into the Markdown report
raw. python-markdown doesn't escape inline HTML by default, so a
directory-controlled value like an OU description containing <script>...
would render live once the report was converted to HTML with md_to_html.py
-- and the underlying data is attacker-influenceable, not just
operator-authored.

Added md_escape() (Python) / ConvertTo-MdSafe (PowerShell), applied at
every table-row interpolation in both scripts. Escapes &, <, > to HTML
entities and | plus embedded newlines to keep the table structure intact.
Raw JSON dumps are left untouched -- this only affects the Markdown/HTML
presentation layer.

Verified end-to-end with <script>, <img onerror=...>, embedded &, and
embedded | payloads across every affected table in both scripts; confirmed
no live tags reach the rendered HTML and no double-escaping occurs.
This commit is contained in:
2026-08-21 17:13:23 -04:00
parent 3f1370eda5
commit d2ff9d2617
2 changed files with 54 additions and 16 deletions
+25 -8
View File
@@ -79,6 +79,23 @@ function ConvertTo-Rfc3339($DateTime) {
return $DateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") return $DateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
} }
# AD attributes (descriptions, sAMAccountName, OS strings, DNs, ...) are
# directory content, not report-generated text -- they can contain anything
# a writer or an attacker put there, including raw HTML. Markdown doesn't
# escape inline HTML by default, so an unescaped '<script>' in an OU
# description would render live when the report is viewed as HTML. This also
# neutralizes '|' and embedded newlines, which would otherwise corrupt the
# table row itself. Call on every AD-sourced value before it goes into a
# Markdown table cell.
function ConvertTo-MdSafe($Value) {
if ($null -eq $Value) { return "" }
$text = [string]$Value
$text = $text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
$text = $text.Replace("|", "\|")
$text = ($text -replace "\s*[\r\n]+\s*", " ").Trim()
return $text
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Object type counts (whole subtree) # Object type counts (whole subtree)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -301,7 +318,7 @@ if ($findings.Count -gt 0) {
Add-Line "" Add-Line ""
Add-Line "| Severity | Finding | Count | Notes |" Add-Line "| Severity | Finding | Count | Notes |"
Add-Line "|---|---|---|---|" Add-Line "|---|---|---|---|"
foreach ($f in $findings) { Add-Line "| $($f.Severity) | $($f.Title) | $($f.Rows.Count) | $($f.Notes) |" } foreach ($f in $findings) { Add-Line "| $($f.Severity) | $(ConvertTo-MdSafe $f.Title) | $($f.Rows.Count) | $(ConvertTo-MdSafe $f.Notes) |" }
Add-Line "" Add-Line ""
Add-Line "### Finding Detail Lists" Add-Line "### Finding Detail Lists"
@@ -309,9 +326,9 @@ if ($findings.Count -gt 0) {
foreach ($f in $findings) { foreach ($f in $findings) {
Add-Line "#### [$($f.Severity)] $($f.Title)" Add-Line "#### [$($f.Severity)] $($f.Title)"
Add-Line "" Add-Line ""
Add-Line "| $($f.Cols[0]) | $($f.Cols[1]) | $($f.Cols[2]) |" Add-Line "| $(ConvertTo-MdSafe $f.Cols[0]) | $(ConvertTo-MdSafe $f.Cols[1]) | $(ConvertTo-MdSafe $f.Cols[2]) |"
Add-Line "|---|---|---|" Add-Line "|---|---|---|"
foreach ($row in $f.Rows) { Add-Line "| $($row.Col0) | $($row.Col1) | $($row.DN) |" } foreach ($row in $f.Rows) { Add-Line "| $(ConvertTo-MdSafe $row.Col0) | $(ConvertTo-MdSafe $row.Col1) | $(ConvertTo-MdSafe $row.DN) |" }
Add-Line "" Add-Line ""
} }
} else { } else {
@@ -323,7 +340,7 @@ Add-Line "## Object Type Counts"
Add-Line "" Add-Line ""
Add-Line "| Object Class | Count |" Add-Line "| Object Class | Count |"
Add-Line "|---|---|" Add-Line "|---|---|"
foreach ($row in $objectCounts) { Add-Line "| $($row.ObjectClass) | $($row.Count) |" } foreach ($row in $objectCounts) { Add-Line "| $(ConvertTo-MdSafe $row.ObjectClass) | $($row.Count) |" }
Add-Line "" Add-Line ""
Add-Line "## Organizational Units" Add-Line "## Organizational Units"
@@ -333,7 +350,7 @@ Add-Line "- Maximum nesting depth: $maxDepth"
Add-Line "" Add-Line ""
Add-Line "| OU DN | Depth | Description |" Add-Line "| OU DN | Depth | Description |"
Add-Line "|---|---|---|" Add-Line "|---|---|---|"
foreach ($o in ($ous | Sort-Object DN)) { Add-Line "| $($o.DN) | $($o.Depth) | $($o.Description) |" } foreach ($o in ($ous | Sort-Object DN)) { Add-Line "| $(ConvertTo-MdSafe $o.DN) | $($o.Depth) | $(ConvertTo-MdSafe $o.Description) |" }
Add-Line "" Add-Line ""
Add-Line "## Users" Add-Line "## Users"
@@ -362,7 +379,7 @@ Add-Line ""
Add-Line "| Operating System | Count |" Add-Line "| Operating System | Count |"
Add-Line "|---|---|" Add-Line "|---|---|"
$osGroups = $computers | Group-Object -Property { if ($_.OS) { $_.OS } else { "Unknown" } } | Sort-Object Count -Descending $osGroups = $computers | Group-Object -Property { if ($_.OS) { $_.OS } else { "Unknown" } } | Sort-Object Count -Descending
foreach ($g in $osGroups) { Add-Line "| $($g.Name) | $($g.Count) |" } foreach ($g in $osGroups) { Add-Line "| $(ConvertTo-MdSafe $g.Name) | $($g.Count) |" }
Add-Line "" Add-Line ""
Add-Line "## Groups" Add-Line "## Groups"
@@ -375,7 +392,7 @@ Add-Line ""
Add-Line "| Scope | Count |" Add-Line "| Scope | Count |"
Add-Line "|---|---|" Add-Line "|---|---|"
$scopeGroups = $groups | Group-Object -Property Scope | Sort-Object Count -Descending $scopeGroups = $groups | Group-Object -Property Scope | Sort-Object Count -Descending
foreach ($g in $scopeGroups) { Add-Line "| $($g.Name) | $($g.Count) |" } foreach ($g in $scopeGroups) { Add-Line "| $(ConvertTo-MdSafe $g.Name) | $($g.Count) |" }
Add-Line "" Add-Line ""
Add-Line "### Largest groups (top 15 by member count)" Add-Line "### Largest groups (top 15 by member count)"
@@ -383,7 +400,7 @@ Add-Line ""
Add-Line "| Group | Type | Scope | Members |" Add-Line "| Group | Type | Scope | Members |"
Add-Line "|---|---|---|---|" Add-Line "|---|---|---|---|"
foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select-Object -First 15)) { foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select-Object -First 15)) {
Add-Line "| $($g.SamAccountName) | $($g.Type) | $($g.Scope) | $($g.MemberCount) |" Add-Line "| $(ConvertTo-MdSafe $g.SamAccountName) | $(ConvertTo-MdSafe $g.Type) | $(ConvertTo-MdSafe $g.Scope) | $($g.MemberCount) |"
} }
Add-Line "" Add-Line ""
+29 -8
View File
@@ -11,6 +11,7 @@ import argparse
import datetime import datetime
import getpass import getpass
import json import json
import re
import sys import sys
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -43,6 +44,26 @@ def filetime_to_datetime(value):
return None return None
def md_escape(value):
"""Escape a value before it goes into a Markdown table cell.
AD attributes (descriptions, sAMAccountName, OS strings, DNs, ...) are
directory content, not report-generated text -- they can contain
anything a writer or an attacker put there, including raw HTML. Markdown
doesn't escape inline HTML by default, so an unescaped '<script>' in an
OU description would render live when the report is viewed as HTML.
Also neutralizes '|' and embedded newlines, which would otherwise
corrupt the table row itself.
"""
if value is None:
return ""
text = str(value)
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = text.replace("|", "\\|")
text = re.sub(r"\s*[\r\n]+\s*", " ", text).strip()
return text
def rfc3339(dt): def rfc3339(dt):
"""Format a datetime as RFC 3339 (UTC, second precision, 'Z' suffix). """Format a datetime as RFC 3339 (UTC, second precision, 'Z' suffix).
@@ -408,7 +429,7 @@ def build_executive_summary(data, stats, args):
a("| Severity | Finding | Count | Notes |") a("| Severity | Finding | Count | Notes |")
a("|---|---|---|---|") a("|---|---|---|---|")
for severity, title, notes, cols, rows in findings: for severity, title, notes, cols, rows in findings:
a(f"| {severity} | {title} | {len(rows)} | {notes} |") a(f"| {severity} | {md_escape(title)} | {len(rows)} | {md_escape(notes)} |")
a("") a("")
a("### Finding Detail Lists") a("### Finding Detail Lists")
@@ -416,10 +437,10 @@ def build_executive_summary(data, stats, args):
for severity, title, notes, cols, rows in findings: for severity, title, notes, cols, rows in findings:
a(f"#### [{severity}] {title}") a(f"#### [{severity}] {title}")
a("") a("")
a(f"| {cols[0]} | {cols[1]} | {cols[2]} |") a(f"| {md_escape(cols[0])} | {md_escape(cols[1])} | {md_escape(cols[2])} |")
a("|---|---|---|") a("|---|---|---|")
for col0, col1, dn in rows: for col0, col1, dn in rows:
a(f"| {col0} | {col1} | {dn} |") a(f"| {md_escape(col0)} | {md_escape(col1)} | {md_escape(dn)} |")
a("") a("")
else: else:
a("No notable risk or cleanup findings surfaced by this audit's checks.") a("No notable risk or cleanup findings surfaced by this audit's checks.")
@@ -454,7 +475,7 @@ def build_report(data, args):
a("| Object Class | Count |") a("| Object Class | Count |")
a("|---|---|") a("|---|---|")
for cls, count in sorted(obj_counts.items(), key=lambda kv: -kv[1]): for cls, count in sorted(obj_counts.items(), key=lambda kv: -kv[1]):
a(f"| {cls} | {count} |") a(f"| {md_escape(cls)} | {count} |")
a("") a("")
a("## Organizational Units") a("## Organizational Units")
@@ -465,7 +486,7 @@ def build_report(data, args):
a("| OU DN | Depth | Description |") a("| OU DN | Depth | Description |")
a("|---|---|---|") a("|---|---|---|")
for o in sorted(ous, key=lambda x: x["dn"]): for o in sorted(ous, key=lambda x: x["dn"]):
a(f"| {o['dn']} | {o['depth']} | {o['description']} |") a(f"| {md_escape(o['dn'])} | {o['depth']} | {md_escape(o['description'])} |")
a("") a("")
a("## Users") a("## Users")
@@ -509,7 +530,7 @@ def build_report(data, args):
a("| Operating System | Count |") a("| Operating System | Count |")
a("|---|---|") a("|---|---|")
for os_name, count in sorted(os_counter.items(), key=lambda kv: -kv[1]): for os_name, count in sorted(os_counter.items(), key=lambda kv: -kv[1]):
a(f"| {os_name} | {count} |") a(f"| {md_escape(os_name)} | {count} |")
a("") a("")
a("## Groups") a("## Groups")
@@ -527,7 +548,7 @@ def build_report(data, args):
a("| Scope | Count |") a("| Scope | Count |")
a("|---|---|") a("|---|---|")
for scope, count in scope_counter.most_common(): for scope, count in scope_counter.most_common():
a(f"| {scope} | {count} |") a(f"| {md_escape(scope)} | {count} |")
a("") a("")
largest = sorted(groups, key=lambda g: -g["member_count"])[:15] largest = sorted(groups, key=lambda g: -g["member_count"])[:15]
@@ -536,7 +557,7 @@ def build_report(data, args):
a("| Group | Type | Scope | Members |") a("| Group | Type | Scope | Members |")
a("|---|---|---|---|") a("|---|---|---|---|")
for g in largest: for g in largest:
a(f"| {g['sam']} | {g['type']} | {g['scope']} | {g['member_count']} |") a(f"| {md_escape(g['sam'])} | {md_escape(g['type'])} | {md_escape(g['scope'])} | {g['member_count']} |")
a("") a("")
return "\n".join(lines) return "\n".join(lines)