Fix #2040: escape BAML strings that XML cannot represent

Obfuscators put arbitrary characters into BAML strings, and XML 1.0 has no
representation for most control characters - a numeric character reference is invalid
for them too. Writing such a document threw ArgumentException from XmlWriter, which
loses the resource on project export and shows an exception instead of the page in the
UI. The escapes are spelled the way the C# output spells them, so one convention covers
both languages. Namespace URIs have to be escaped where the XNamespace is created rather
than in the final pass: the URI is baked into every element name built from it, so
patching only the xmlns declaration would desync the two. Characters XML can carry stay
untouched, so ordinary documents decompile byte-identically.

Every BAML stream of an assembly lives in one .resources container, and the recovery
around resource writing sat outside the loop over its entries, so a single page that
could not be written discarded every other page sharing the container with it.

Assisted-by: Claude:claude-opus-5:Claude Code
This commit is contained in:
Siegfried Pammer
2026-09-01 06:50:11 +02:00
parent f1bc6ab542
commit bb1d830ee6
10 changed files with 328 additions and 5 deletions
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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.
using System.Linq;
using System.Xml.Linq;
using ICSharpCode.BamlDecompiler.Xaml;
namespace ICSharpCode.BamlDecompiler.Rewrite
{
/// <summary>
/// Escapes attribute values, text and comments that carry characters XML cannot represent -
/// a string record from an obfuscated assembly may hold any byte sequence. Without this the
/// document builds fine and only throws when it is written, taking the resource with it.
/// Names and namespace URIs are escaped where they are built, so this pass only has to cover
/// the content.
/// </summary>
internal class EscapeInvalidXmlCharactersRewritePass : IRewritePass
{
public void Run(XamlContext ctx, XDocument document)
{
foreach (var element in document.Descendants())
{
foreach (var attribute in element.Attributes())
{
// Namespace declarations carry a URI that was escaped when the XNamespace was
// created; rewriting it here would desync it from the names using it.
if (!attribute.IsNamespaceDeclaration)
attribute.Value = XamlUtils.EscapeInvalidXmlCharacters(attribute.Value);
}
}
foreach (var node in document.DescendantNodes().ToList())
{
switch (node)
{
case XText text:
text.Value = XamlUtils.EscapeInvalidXmlCharacters(text.Value);
break;
case XComment comment:
comment.Value = XamlUtils.EscapeInvalidXmlCharacters(comment.Value);
break;
}
}
}
}
}
@@ -22,6 +22,7 @@
using System.IO;
using System.Text;
using System.Xml;
using System.Xml.Linq;
namespace ICSharpCode.BamlDecompiler.Xaml
@@ -37,6 +38,59 @@ namespace ICSharpCode.BamlDecompiler.Xaml
return value;
}
/// <summary>
/// Escapes the characters XML cannot carry - obfuscators put them into BAML strings, and
/// XML 1.0 has no representation for them at all, not even a numeric character reference.
/// The escapes are spelled the way the C# output spells them, so one convention covers
/// both languages: the short form where C# has one, "\uXXXX" otherwise.
/// Characters XML can carry - tab, newline, astral characters - are left untouched, and a
/// literal backslash is not doubled, because XAML itself has no escape syntax to undo.
/// </summary>
public static string EscapeInvalidXmlCharacters(string value)
{
if (string.IsNullOrEmpty(value))
return value;
StringBuilder escaped = null;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
{
escaped?.Append(c).Append(value[i + 1]);
i++;
continue;
}
if (XmlConvert.IsXmlChar(c))
{
escaped?.Append(c);
continue;
}
escaped ??= new StringBuilder(value.Length).Append(value, 0, i);
escaped.Append(EscapeChar(c));
}
return escaped?.ToString() ?? value;
}
static string EscapeChar(char c)
{
switch (c)
{
case '\0':
return "\\0";
case '\a':
return "\\a";
case '\b':
return "\\b";
case '\f':
return "\\f";
case '\v':
return "\\v";
default:
return "\\u" + ((int)c).ToString("x4");
}
}
public static string ToString(this XamlContext ctx, XElement elem, XamlType type)
{
type.ResolveNamespace(elem, ctx);
+6 -1
View File
@@ -184,7 +184,12 @@ namespace ICSharpCode.BamlDecompiler
return null;
if (!xmlnsMap.TryGetValue(xmlns, out var ns))
xmlnsMap[xmlns] = ns = XNamespace.Get(xmlns);
{
// Every XNamespace is created here, so escaping the URI once keeps the xmlns
// declaration and the names that use it in sync. Doing it later is not possible:
// the URI is baked into every name built from this namespace.
xmlnsMap[xmlns] = ns = XNamespace.Get(XamlUtils.EscapeInvalidXmlCharacters(xmlns));
}
return ns;
}
@@ -44,6 +44,7 @@ namespace ICSharpCode.BamlDecompiler
new AttributeRewritePass(),
new ConnectionIdRewritePass(),
new DocumentRewritePass(),
new EscapeInvalidXmlCharactersRewritePass(),
};
private BamlDecompilerTypeSystem typeSystem;
@@ -17,11 +17,14 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection.PortableExecutable;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.IL.Transforms;
@@ -125,5 +128,55 @@ namespace ICSharpCode.Decompiler.Tests
}
}
/// <summary>
/// A .resources container holds every BAML stream of an assembly. One entry the decompiler
/// cannot write - obfuscated BAML that produces characters XML cannot carry, say - must not
/// take the entries next to it down: they are unrelated pages of an unrelated type.
/// </summary>
[Test]
public void FailingResourceEntryKeepsTheOtherEntriesOfTheContainer()
{
string location = typeof(DecompilationErrorRecoveryTests).Assembly.Location;
using var stream = new FileStream(location, FileMode.Open, FileAccess.Read);
var module = new PEFile(location, stream, streamOptions: PEStreamOptions.PrefetchEntireImage);
var decompiler = new EntryFailingProjectDecompiler(
new UniversalAssemblyResolver(location, throwOnError: false, module.DetectTargetFrameworkId()));
var items = decompiler.WriteResources(module).ToList();
using (Assert.EnterMultipleScope())
{
Assert.That(items.Select(i => i.FileName), Does.Contain("good.baml"),
"the entry after the failing one is still written");
Assert.That(decompiler.Errors, Has.Count.EqualTo(1), "the failure is reported to the caller");
Assert.That(decompiler.Errors[0].ToString(), Does.Contain("bad.baml"),
"and names the entry that failed");
}
}
/// <summary>
/// Writes every resource entry as a project item, except the one named "bad.baml", which
/// throws the way a resource handler does when it cannot produce a file.
/// </summary>
sealed class EntryFailingProjectDecompiler : WholeProjectDecompiler
{
public EntryFailingProjectDecompiler(IAssemblyResolver assemblyResolver)
: base(assemblyResolver)
{
// Entries this fixture does not override still get written to disk.
TargetDirectory = Directory.CreateTempSubdirectory("ILSpyResourceRecovery").FullName;
}
public IEnumerable<ProjectItemInfo> WriteResources(MetadataFile module)
=> WriteResourceFilesInProject(module);
protected override IEnumerable<ProjectItemInfo> WriteResourceToFile(string fileName, string resourceName, Stream entryStream)
{
if (resourceName == "bad.baml")
throw new NotSupportedException("cannot write bad.baml");
return new[] { new ProjectItemInfo("Page", fileName) };
}
}
}
}
@@ -93,6 +93,12 @@
<PackageReference Include="System.Resources.Extensions" />
</ItemGroup>
<ItemGroup>
<!-- A .resources container holding two stream entries, so this assembly itself is the module
WholeProjectDecompiler's per-entry resource path can be exercised against. -->
<EmbeddedResource Include="Helpers\TwoStreamEntries.resources" LogicalName="TwoStreamEntries.g.resources" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ICSharpCode.ILSpyX\ICSharpCode.ILSpyX.csproj" />
<ProjectReference Include="..\ICSharpCode.Decompiler\ICSharpCode.Decompiler.csproj" />
@@ -506,7 +506,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
List<ProjectItemInfo> items;
try
{
items = WriteResourceFileInProject(r).ToList();
items = WriteResourceFileInProject(module, r).ToList();
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
@@ -522,7 +522,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
}
}
IEnumerable<ProjectItemInfo> WriteResourceFileInProject(Resource r)
IEnumerable<ProjectItemInfo> WriteResourceFileInProject(MetadataFile module, Resource r)
{
Stream? stream = r.TryOpenStream();
if (stream == null)
@@ -549,8 +549,18 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
}
Stream entryStream = (Stream)value!;
entryStream.Position = 0;
individualResources.AddRange(
WriteResourceToFile(fileName, name, entryStream));
try
{
individualResources.AddRange(
WriteResourceToFile(fileName, name, entryStream));
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
// One entry nobody can decode - a BAML stream carrying characters XML
// cannot represent, say - costs that entry, not every other entry
// sharing the container with it.
RecordError(ex as DecompilerException ?? new DecompilerException(module, $"Error writing resource '{name}'", ex));
}
}
decodedIntoIndividualFiles = true;
}
@@ -43,6 +43,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="InvalidXmlCharacterTests.cs" />
<Compile Include="MissingReferencesTests.cs" />
<Compile Include="XmlNamespaceResolutionTests.cs" />
</ItemGroup>
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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.
using System.IO;
using System.Reflection.PortableExecutable;
using ICSharpCode.BamlDecompiler;
using ICSharpCode.BamlDecompiler.Baml;
using ICSharpCode.Decompiler.Metadata;
using NUnit.Framework;
namespace ILSpy.BamlDecompiler.Tests
{
/// <summary>
/// Obfuscators put characters into BAML strings that XML cannot carry at all - not even as a
/// numeric character reference. They have to be escaped before they reach the XDocument;
/// otherwise writing the decompiled XAML throws and the whole resource is lost.
/// </summary>
[TestFixture]
public class InvalidXmlCharacterTests
{
static ushort TypeId(KnownTypes type) => unchecked((ushort)-(short)type);
static ushort MemberId(KnownMembers member) => unchecked((ushort)-(short)member);
/// <summary>
/// Builds a BAML stream out of <paramref name="records"/>, wrapped in the document
/// start/end records and the header the reader insists on.
/// </summary>
static MemoryStream CreateBaml(params BamlRecord[] records)
{
var version = new BamlDocument.BamlVersion { Major = 0, Minor = 0x60 };
var document = new BamlDocument {
Signature = "MSBAML",
ReaderVersion = version,
UpdaterVersion = version,
WriterVersion = version
};
document.Add(new DocumentStartRecord());
document.AddRange(records);
document.Add(new DocumentEndRecord());
var stream = new MemoryStream();
BamlWriter.WriteDocument(document, stream);
stream.Position = 0;
return stream;
}
/// <summary>
/// Decompiles against this test assembly as the main module: the BAML built here refers
/// only to well-known WPF types, which the decompiler resolves without it.
/// </summary>
static string Decompile(Stream baml)
{
var location = typeof(InvalidXmlCharacterTests).Assembly.Location;
using var fileStream = new FileStream(location, FileMode.Open, FileAccess.Read);
var file = new PEFile(location, fileStream, streamOptions: PEStreamOptions.PrefetchEntireImage);
var resolver = new UniversalAssemblyResolver(location, throwOnError: false,
file.DetectTargetFrameworkId(), file.DetectRuntimePack());
var decompiler = new XamlDecompiler(new BamlDecompilerTypeSystem(file, resolver),
new BamlDecompilerSettings());
return decompiler.Decompile(baml).Xaml.ToString();
}
[Test]
public void ControlCharacterInPropertyValue_IsEscaped()
{
string xaml = Decompile(CreateBaml(
new ElementStartRecord { TypeId = TypeId(KnownTypes.Button) },
new PropertyRecord {
AttributeId = MemberId(KnownMembers.Button_Content),
Value = "a\u0018b"
},
new ElementEndRecord()));
Assert.That(xaml, Does.Contain(@"Content=""a\u0018b"""));
}
[Test]
public void ControlCharacterInNamespaceUri_IsEscaped()
{
// The URI ends up both in the xmlns declaration and in the namespace of every element
// name, so it cannot be repaired after the document has been built.
string xaml = Decompile(CreateBaml(
new ElementStartRecord { TypeId = TypeId(KnownTypes.Button) },
new XmlnsPropertyRecord {
Prefix = "obf",
XmlNamespace = "clr-namespace:Obfuscated\u0018Namespace",
AssemblyIds = new ushort[0]
},
new ElementEndRecord()));
Assert.That(xaml, Does.Contain(@"xmlns:obf=""clr-namespace:Obfuscated\u0018Namespace"""));
}
[Test]
public void CharactersXmlCanCarry_AreLeftAlone()
{
// Tab, newline and astral characters are valid XML content; escaping them would
// change the output of every ordinary document.
string xaml = Decompile(CreateBaml(
new ElementStartRecord { TypeId = TypeId(KnownTypes.Button) },
new PropertyRecord {
AttributeId = MemberId(KnownMembers.Button_Content),
Value = "tab\tastral\U0001F600"
},
new ElementEndRecord()));
Assert.Multiple(() => {
Assert.That(xaml, Does.Contain("\U0001F600"), "the surrogate pair stays intact");
Assert.That(xaml, Does.Not.Contain("\\u"), "nothing valid gets escaped");
});
}
}
}