From 78b561537f80bc8b86ca90dd91f0438ec149de5a Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Thu, 25 Jun 2026 10:30:58 +0400 Subject: [PATCH 1/4] fix(dns): validate enrtree-root fields and skip malformed records --- .../EnrTreeParserTests.cs | 11 ++++ .../Nethermind.Network.Dns/EnrTreeCrawler.cs | 13 ++++- .../Nethermind.Network.Dns/EnrTreeParser.cs | 53 ++++++++++++++----- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs index f470fd3e01e3..e9a745b713e5 100644 --- a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs +++ b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using NUnit.Framework; namespace Nethermind.Network.Dns.Test; @@ -41,6 +42,16 @@ public void Can_parse_leaf() Assert.That(leaf.NodeRecord, Is.EqualTo(enr.Substring(4))); } + // A malformed enrtree-root from an untrusted DNS record must surface as a FormatException + // (caught and skipped by the crawler), never an unhandled ArgumentOutOfRangeException that aborts the crawl. + [TestCase("enrtree-root:v1", TestName = "all fields missing")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "no space after seq value")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "seq field missing")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=SHORT", TestName = "truncated sig field")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=notanumber sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "non-numeric seq value")] + public void Malformed_enrtree_root_throws_FormatException(string malformedRoot) + => Assert.That(() => EnrTreeParser.ParseEnrRoot(malformedRoot), Throws.TypeOf()); + [TestCase("enrtree://all.mainnet.ethdisco.net")] [TestCase("enrtree://AKA3AM6LPBYEUDMVNU3BSVQJ5AD45Y7YPOHJLEF6W26QOE4VTUDPE@all.mainnet.ethdisco.net")] public void Can_parse_linked_tree(string text) diff --git a/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs b/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs index e3dbfc56ae27..db947fec4f83 100644 --- a/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs +++ b/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs @@ -53,7 +53,18 @@ private async IAsyncEnumerable SearchNode(IDnsClient client, string quer IEnumerable lookupResult = await client.Lookup(query, cancellationToken); foreach (string node in lookupResult) { - EnrTreeNode treeNode = EnrTreeParser.ParseNode(node); + EnrTreeNode treeNode; + try + { + treeNode = EnrTreeParser.ParseNode(node); + } + catch (Exception e) when (e is FormatException or NotSupportedException or ArgumentException) + { + // A single malformed record from an untrusted DNS server must not abort the whole crawl. + if (_logger.IsDebug) _logger.Debug($"Skipping malformed ENR tree record from DNS query '{query}': {e.Message}"); + continue; + } + foreach (string link in treeNode.Links) { DnsClient linkedTreeLookup = new(link); diff --git a/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs b/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs index 1c37cdcdc587..5fafa1bd6f8e 100644 --- a/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs +++ b/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs @@ -63,21 +63,50 @@ public static EnrTreeRoot ParseEnrRoot(string enrTreeRootText) { ArgumentNullException.ThrowIfNull(enrTreeRootText); - EnrTreeRoot enrTreeRoot = new(); - - int ensRootIndex = enrTreeRootText.IndexOf("e=", StringComparison.Ordinal); - enrTreeRoot.EnrRoot = enrTreeRootText.Substring(ensRootIndex + 2, HashLengthBase32); + // enrTreeRootText comes from an untrusted DNS TXT record (parsed before any signature check), + // so validate every field's presence and length before slicing to avoid an unhandled exception. + EnrTreeRoot enrTreeRoot = new() + { + EnrRoot = ExtractFixedField(enrTreeRootText, "e=", HashLengthBase32), + LinkRoot = ExtractFixedField(enrTreeRootText, "l=", HashLengthBase32), + Sequence = ExtractSequence(enrTreeRootText), + Signature = ExtractFixedField(enrTreeRootText, "sig=", SigLengthBase32), + }; - int linkRootIndex = enrTreeRootText.IndexOf("l=", StringComparison.Ordinal); - enrTreeRoot.LinkRoot = enrTreeRootText.Substring(linkRootIndex + 2, HashLengthBase32); + return enrTreeRoot; - int seqIndex = enrTreeRootText.IndexOf("seq=", StringComparison.Ordinal); - int seqLength = enrTreeRootText.IndexOf(' ', seqIndex) - (seqIndex + 4); - enrTreeRoot.Sequence = int.Parse(enrTreeRootText.AsSpan(seqIndex + 4, seqLength)); + static string ExtractFixedField(string text, string key, int length) + { + int index = text.IndexOf(key, StringComparison.Ordinal); + if (index < 0 || index + key.Length + length > text.Length) + { + throw new FormatException($"Malformed enrtree-root: '{key}' field is missing or too short."); + } - int sigIndex = enrTreeRootText.IndexOf("sig=", StringComparison.Ordinal); - enrTreeRoot.Signature = enrTreeRootText.Substring(sigIndex + 4, SigLengthBase32); + return text.Substring(index + key.Length, length); + } - return enrTreeRoot; + static int ExtractSequence(string text) + { + int index = text.IndexOf("seq=", StringComparison.Ordinal); + if (index < 0) + { + throw new FormatException("Malformed enrtree-root: 'seq=' field is missing."); + } + + int start = index + "seq=".Length; + int end = text.IndexOf(' ', start); + if (end < 0) + { + throw new FormatException("Malformed enrtree-root: 'seq=' field is not terminated."); + } + + if (!int.TryParse(text.AsSpan(start, end - start), out int sequence)) + { + throw new FormatException("Malformed enrtree-root: 'seq=' value is not a valid number."); + } + + return sequence; + } } } From b74344826715047e8a355c2862dd223692eb29aa Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Thu, 25 Jun 2026 11:58:10 +0400 Subject: [PATCH 2/4] refactor(dns): narrow crawler catch to FormatException/NotSupportedException and cover e=/l= cases --- .../Nethermind.Network.Dns.Test/EnrTreeParserTests.cs | 2 ++ src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs index e9a745b713e5..39575d93f2c1 100644 --- a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs +++ b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs @@ -49,6 +49,8 @@ public void Can_parse_leaf() [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "seq field missing")] [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=SHORT", TestName = "truncated sig field")] [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=notanumber sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "non-numeric seq value")] + [TestCase("enrtree-root:v1 l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "e field missing")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "l field missing")] public void Malformed_enrtree_root_throws_FormatException(string malformedRoot) => Assert.That(() => EnrTreeParser.ParseEnrRoot(malformedRoot), Throws.TypeOf()); diff --git a/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs b/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs index db947fec4f83..e1bc04ee36c1 100644 --- a/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs +++ b/src/Nethermind/Nethermind.Network.Dns/EnrTreeCrawler.cs @@ -58,7 +58,7 @@ private async IAsyncEnumerable SearchNode(IDnsClient client, string quer { treeNode = EnrTreeParser.ParseNode(node); } - catch (Exception e) when (e is FormatException or NotSupportedException or ArgumentException) + catch (Exception e) when (e is FormatException or NotSupportedException) { // A single malformed record from an untrusted DNS server must not abort the whole crawl. if (_logger.IsDebug) _logger.Debug($"Skipping malformed ENR tree record from DNS query '{query}': {e.Message}"); From 5406fd1cd526f1765b87f7af48d2acced5d203b1 Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Thu, 25 Jun 2026 13:27:01 +0400 Subject: [PATCH 3/4] test: condense verbose comment to one line --- .../Nethermind.Network.Dns.Test/EnrTreeParserTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs index 39575d93f2c1..31e4f8015229 100644 --- a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs +++ b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs @@ -42,8 +42,7 @@ public void Can_parse_leaf() Assert.That(leaf.NodeRecord, Is.EqualTo(enr.Substring(4))); } - // A malformed enrtree-root from an untrusted DNS record must surface as a FormatException - // (caught and skipped by the crawler), never an unhandled ArgumentOutOfRangeException that aborts the crawl. + // Malformed input must surface as a FormatException the crawler can catch, not an unhandled exception that aborts the crawl. [TestCase("enrtree-root:v1", TestName = "all fields missing")] [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "no space after seq value")] [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "seq field missing")] From 333cd385d9bb7b048bfa090757e87a0e65bc0475 Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Mon, 29 Jun 2026 15:13:34 +0400 Subject: [PATCH 4/4] fix(dns): reject duplicate fields in enrtree-root --- .../Nethermind.Network.Dns.Test/EnrTreeParserTests.cs | 4 ++++ src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs index 31e4f8015229..8f14dda6dd51 100644 --- a/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs +++ b/src/Nethermind/Nethermind.Network.Dns.Test/EnrTreeParserTests.cs @@ -50,6 +50,10 @@ public void Can_parse_leaf() [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=notanumber sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "non-numeric seq value")] [TestCase("enrtree-root:v1 l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "e field missing")] [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "l field missing")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "duplicate e field")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "duplicate l field")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "duplicate seq field")] + [TestCase("enrtree-root:v1 e=TPLRUM3FAKJZIRMXADWOHSU3PM l=FDXN3SN67NA5DKA4J2GOK7BVQI seq=2779 sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE sig=CNoJofW_lNh7QFQkaVGhEX2ifbEZ3UkiBQCVyZCkM_I-72cEh8Bfd21cSS9BP5tyAqWF3jMVov8duUCdSByEQAE", TestName = "duplicate sig field")] public void Malformed_enrtree_root_throws_FormatException(string malformedRoot) => Assert.That(() => EnrTreeParser.ParseEnrRoot(malformedRoot), Throws.TypeOf()); diff --git a/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs b/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs index 5fafa1bd6f8e..775ebbf5785b 100644 --- a/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs +++ b/src/Nethermind/Nethermind.Network.Dns/EnrTreeParser.cs @@ -83,6 +83,11 @@ static string ExtractFixedField(string text, string key, int length) throw new FormatException($"Malformed enrtree-root: '{key}' field is missing or too short."); } + if (text.IndexOf(key, index + key.Length, StringComparison.Ordinal) >= 0) + { + throw new FormatException($"Malformed enrtree-root: '{key}' field appears more than once."); + } + return text.Substring(index + key.Length, length); } @@ -94,6 +99,11 @@ static int ExtractSequence(string text) throw new FormatException("Malformed enrtree-root: 'seq=' field is missing."); } + if (text.IndexOf("seq=", index + "seq=".Length, StringComparison.Ordinal) >= 0) + { + throw new FormatException("Malformed enrtree-root: 'seq=' field appears more than once."); + } + int start = index + "seq=".Length; int end = text.IndexOf(' ', start); if (end < 0)