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:
+29
-8
@@ -11,6 +11,7 @@ import argparse
|
||||
import datetime
|
||||
import getpass
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
@@ -43,6 +44,26 @@ def filetime_to_datetime(value):
|
||||
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace("|", "\\|")
|
||||
text = re.sub(r"\s*[\r\n]+\s*", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def rfc3339(dt):
|
||||
"""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("|---|---|---|---|")
|
||||
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("### Finding Detail Lists")
|
||||
@@ -416,10 +437,10 @@ def build_executive_summary(data, stats, args):
|
||||
for severity, title, notes, cols, rows in findings:
|
||||
a(f"#### [{severity}] {title}")
|
||||
a("")
|
||||
a(f"| {cols[0]} | {cols[1]} | {cols[2]} |")
|
||||
a(f"| {md_escape(cols[0])} | {md_escape(cols[1])} | {md_escape(cols[2])} |")
|
||||
a("|---|---|---|")
|
||||
for col0, col1, dn in rows:
|
||||
a(f"| {col0} | {col1} | {dn} |")
|
||||
a(f"| {md_escape(col0)} | {md_escape(col1)} | {md_escape(dn)} |")
|
||||
a("")
|
||||
else:
|
||||
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("|---|---|")
|
||||
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("## Organizational Units")
|
||||
@@ -465,7 +486,7 @@ def build_report(data, args):
|
||||
a("| OU DN | Depth | Description |")
|
||||
a("|---|---|---|")
|
||||
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("## Users")
|
||||
@@ -509,7 +530,7 @@ def build_report(data, args):
|
||||
a("| Operating System | Count |")
|
||||
a("|---|---|")
|
||||
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("## Groups")
|
||||
@@ -527,7 +548,7 @@ def build_report(data, args):
|
||||
a("| Scope | Count |")
|
||||
a("|---|---|")
|
||||
for scope, count in scope_counter.most_common():
|
||||
a(f"| {scope} | {count} |")
|
||||
a(f"| {md_escape(scope)} | {count} |")
|
||||
a("")
|
||||
|
||||
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("|---|---|---|---|")
|
||||
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("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user