Sndav
发布于 2024-04-18 / 63 阅读
0
0

【.NET 安全】 01-环境搭建&反序列化入门

前言

这个星期稍微学习了一下.NET反序列化相关的知识,所以想写一篇文章系统的梳理一下.NET安全的相关知识。在阅读了市面上的.NET反序列化文章后,对比Java反序列化,虽然.NET在Web开发中已经用的非常多了,但是针对其安全研究较少,只有部分师傅做过.NET分析。市面上的文章质量一言难尽,鲜有文章能讲明白,讲清楚。

环境搭建

最常见的C#开发写工具是Visual Studio,作为微软爸爸的官方标配,其各种功能都很完善。但是对于安全研究人员(起码是对于我来说),用起来不太顺手,并且很多功能可能不太需要。

这里我推荐使用JetBrians家的Rider作为主力的开发工具和调试工具。对于我来说,Rider有这么几个点吸引我

  • 统一的快捷键和UI,对于熟悉IDEA等JB家族IDE的师傅来说,VS的快捷键和JB的快捷键差别太大,用起来手感很差。

  • 自带C#反编译和调试功能,很多师傅还在用dySpy来对没有代码的C#dll文件进行调试,但是Rider和IDEA类似,继承了反编译工具和调试功能,用起来不要说太爽。下图就是SessionSecurityToken的反编译的结果,效果非常好!

    image-20230805161020768

  • 软件体积小,相较于VS动辄几十个G的安装大小,Rider的体积就小很多了。

这里非常推荐初学者用Rider进行C#代码的编写和调试,手感棒棒的!至于如何安装Rider,这个我相信绝大多数师傅应该都没问题,这里就不在多说。

一个常见的小问题

在写代码的时候会经常发现有一些类找不到,这个时候我们需要右键Dependencies并点击Reference。

image-20230805161732169

之后,搜索你需要的依赖库,并Add就可以了。如果你的依赖库是一个dll文件,你可以点击下面的Add From并选择对应文件也可以添加。

Add Reference

一些与反序列化无关的知识点

在.NET中执行命令

在Java的CC链反序列化中,我们可以通过Runtime.getRuntime().exec()执行命令。同样的,在.NET中我们可以使用Process.Start()去执行命令1,值得高兴的是Process.Start是一个static方法,可以直接调用,不需要像在Java中调用getRuntime()获取Instance。

Process.Start

Process.Start有多个定义,其中最简单的就是上图中的。其有两个参数,第一个参数为fileName一般为cmd.exe,第二个参数是arguments,通常为/c <command>。代码写起来也很简单

using System.Diagnostics;

namespace Lesson01
{
    internal class Program
    {
        private static void RunCommand(string cmd)
        {
            Process.Start("cmd.exe", "/c " + cmd);
        }
        public static void Main(string[] args)
        {
            RunCommand("calc.exe");
        }
    }
}

RunCommand

在.NET中加载dll

这里的dll不是指那些用C/C++语言编写的动态链接库,而是.NET的依赖库,可以理解为Java中的class文件。类似于Java中调用TemplatesImpl加载class文件,在.NET可以用Assembly.Load直接加载dll文件。

ExploitClass

我们首先需要编写一个dll项目。在Rider中,我们右键解决方案名称,选择New Project,选择Class Library,最后点击Create

创建dll项目

在生成的代码中,添加构造函数,这样我们的库就写完了。

using System.Diagnostics;

namespace ExploitClass
{
    public class ExploitClass
    {
        public ExploitClass()
        {
            Process.Start("cmd.exe", "/c calc.exe");
        }
    }
}

Loader

在Loader中,我们需要使用Assembly.Load加载我们上面编写的dll文件。我们可能需要在Loader Project中添加ExploitClass的引用。

添加ExploitClass的引用

这样之后我们可以直接用下面的代码获得ExploitClass的dll地址了。

typeof(ExploitClass.ExploitClass).Assembly.Location

Assembly.Load的用法也很简单,第一个参数是dll的字节码即可。

var dllLocation = typeof(ExploitClass.ExploitClass).Assembly.Location;
Console.WriteLine("Dll Location: " + dllLocation);
var dll = File.ReadAllBytes(dllLocation);
Assembly.Load(dll).CreateInstance("ExploitClass.ExploitClass");

可以看到这种方法有一定的局限性,需要访问到这个ExploitClass(这里用了CreateInstance),才可以执行。为了解决这个问题,可以使用ModuleInit.Fody这个库设置C# module的init函数

namespace ExploitClass
{
    public static class ModuleInitializer
    {
        public static void Initialize()
        {
            System.Diagnostics.Process.Start("cmd.exe", "/c calc");
        }
    }
}

这样的话就不需要CreateInstance了,在Assembly.Load的时候就可以执行命令了。

.NET中的反序列化器

在笔者的学习过程中,见识了了很多反序列化器:

  • 有以Serializer结尾的XmlSerializer2,DataContractSerializer3,JavaScriptSerializer 等;
  • 有以Formatter结尾的BinaryFormatter,SoapFormatter等;
  • 有以Reader等结尾的XamlReader等;
  • 甚至第三方的如JSON.NET

这些反序列化器各不相同。在此我们分类进行介绍。

Formatter序列化

以Formatter反序列化器作为入门篇可能是对初学者最方便的了,Formatter反序列化器是一个C#支持的,非常标准的反序列化器。常见的Formatter序列化器有下面几种:

  • BinaryFormatter:生成的序列化数据是二进制
  • SoapFormatter:生成的数据用于Soap请求
  • ObjectStateFormatter:用于生成ViewState这种状态
using (MemoryStream memoryStream = new MemoryStream())
{
    // 序列化
    var formatter = new BinaryFormatter(); // 可以替换为其他formatter
	formatter.Serialize(memoryStream, myObject);
    memoryStream.Position = 0;
    
    // 反序列化
    var myObject = formatter.Deserialize(memoryStream);
}

上面的代码展示了针对一个Object对象进行序列化和反序列化的过程。

用于控制序列化过程的注解

但是不是所有的类都可以被Formatter序列化器进行序列化,下面是一个标准的,可以使用Formatter序列器进行序列化的对象

[Serializable]
public class CustomObject1
{
    [OnDeserializing]
    private void TestOnDeserializing(StreamingContext sc)
    {
        Console.WriteLine("TestOnDeserializing");

    }
    [OnDeserialized]
    private void TestOnDeserialized(StreamingContext sc)
    {
        Console.WriteLine("TestOnDeserialized");
    }
    [OnSerializing]
    private void TestOnSerializing(StreamingContext sc)
    {
        Console.WriteLine("TestOnSerializing");
    }
    [OnSerialized]
    private void TestOnSerialized(StreamingContext sc)
    {
        Console.WriteLine("TestOnSerialized");
    }   
}

所有支持Formatter序列化的对象都需要在类的声明位置标注[Serializable]。在类当中可以指定[OnDeserializing],[OnDeserialized],[OnSerializing],[OnSerialized]4种注解,来指定序列化和反序列化中的行为。当然,除了Formatter类之外,有一些其他的序列化器可以使用上述注解。

我们用这个代码对CustomObject1进行序列化测试,可以观察到如下输出:

image-20230805235653200

这说明在默认情况下,序列化的会先执行[OnSerializing]标注的函数,在最后会执行[OnSerialized]。反序列化的时候同理:会先执行[OnDeserializing]标注的函数,在最后会执行[OnDeserialized]。如果某个标注了[OnDeserializing]/[OnDeserialized]函数在执行反序列化存在可以RCE的地方,那么就可以在反序列化的时候实现RCE。

ISerializable接口

除了上述的注解之外,如果对象实现了ISerializable也可以通过构造函数和GetObjectData函数控制序列化过程。下面展示了一个带有对应函数的类:

[Serializable]
public class CustomObject2: ISerializable
{
    [NonSerialized]
    private string _val;

    public CustomObject2()
    {

    }
    protected CustomObject2(SerializationInfo info, StreamingContext context)
    {
        Console.WriteLine("CustomObject2 constructor is called");
        _val = info.GetString("foooo");
    }

    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        Console.WriteLine("GetObjectData");
        info.AddValue("foooo", _val);
    }

    [OnDeserializing]
    private void TestOnDeserializing(StreamingContext sc)
    {
        Console.WriteLine("TestOnDeserializing");

    }
    [OnDeserialized]
    private void TestOnDeserialized(StreamingContext sc)
    {
        Console.WriteLine("TestOnDeserialized");
    }
    [OnSerializing]
    private void TestOnSerializing(StreamingContext sc)
    {
        Console.WriteLine("TestOnSerializing");
    }
    [OnSerialized]
    private void TestOnSerialized(StreamingContext sc)
    {
        Console.WriteLine("TestOnSerialized");
    }   
}

同样,我们对这个类进行序列化和反序列化,可以看到其调用顺序

image-20230806105348925

OK,到此为止我们可以总结出一个流程

image-20230806110724450

SurrogateSelector

在序列化的时候,我们可以通过设置fomatter的SurrogateSelector来指定序列化代理选择器。通过SurrogateSelector,可以自定义GetObjectData和SetObjectData从而控制序列化和反序列化流程。

通过设置代理器,可以将Formatter不支持序列化的对象进行序列化从而达到扩展Formatter的效果。

参考Y4er师傅的dotnet-serialize-1014,下面是一个没有标注序列化的类,在默认情况下是不能序列化的。

class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
    }

    public override string ToString()
    {
        return Name;
    }
}

如果我们想序列化这个类,那就需要使用代理器了。下面是一个针对Person对象的序列化代理器,其设置了序列化该类的方法GetObjectData,和反序列化该类的方法SetObjectData

class PersonSerializeSurrogate : ISerializationSurrogate
{

    public void GetObjectData(Object obj, SerializationInfo info, StreamingContext context)
    {
        var p = (Person)obj;
        info.AddValue("Name", p.Name);
    }

    public Object SetObjectData(Object obj, SerializationInfo info, StreamingContext context, ISurrogateSelector selector)
    {
        var p = (Person)obj;
        p.Name = info.GetString("Name");
        return p;
    }
}

我们通过设置代理,就可以序列化了

var formatter = new BinaryFormatter(); // 可以替换为其他formatter
SurrogateSelector ss = new SurrogateSelector(); // 创建selector
ss.AddSurrogate(typeof(Person), formatter.Context, new PersonSerializeSurrogate()); // 为Person对象设置代理器
formatter.SurrogateSelector = ss; // 设置代理选择器
formatter.Serialize(memoryStream, new Person("name"));

在设置完对象之后,序列化和反序列化的过程就有些不一样了,下面是Y4er师傅总结的流程。

image-20230806113607424

Serializer序列化

Serializer家族相对于Formatter家族就复杂一些,比如说最常见的XmlSerializer就不受上面注解的影响。

XmlSerializer

XmlSerializer进行序列化和反序列化的时候需要指定对应的ClassType,下面是XmlSerializer序列化和反序列化的标准代码:

XmlSerializer xmlSerializer = new XmlSerializer(typeof(XmlObject2));
using (MemoryStream memoryStream = new MemoryStream())
{
    var obj = new XmlObject2();
    TextWriter writer = new StreamWriter(memoryStream);
    xmlSerializer.Serialize(writer, obj);
    memoryStream.Position = 0;
    Console.WriteLine(Encoding.UTF8.GetString(memoryStream.ToArray()));

    XmlObject2 p1 = (XmlObject2)xmlSerializer.Deserialize(memoryStream);
}

我们可以看到在new XmlSerializer()的时候,构造函数的第一个参数代表所序列化类的类型,这在一定程度上限制了XmlSerializer的利用场景。在研究XmlSerializer的序列化和反序列化的时候,

使用 XmlInclude或SoapInclude 特性静态指定非已知的类型

我经常遇到一个报错:使用 XmlInclude或SoapInclude 特性静态指定非已知的类型,Y4er也在文章中提到了这个报错,不过他的解释我没怎么看懂,我这里在来详细说明一下。我们设想下面这个类:

public class XmlObject
{
    public string Name;
    public string Value;

}

public class XmlObject2
{
    public object AnyObj;
}

当我们序列化下面这个对象的时候

var obj = new XmlObject2();
obj.AnyObj = new XmlObject2();
xmlSerializer.Serialize(writer, obj);

你会发现序列化的结果很正常:

<?xml version="1.0" encoding="utf-8"?>
<XmlObject2 xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <AnyObj xsi:type="XmlObject2" />
</XmlObject2>

但是,如果你讲AnyObj的值设置为new XmlObject(),也就是:

var obj = new XmlObject2();
obj.AnyObj = new XmlObject();
xmlSerializer.Serialize(writer, obj);

你会惊奇的发现,上面的代码报错了。

image-20230806152953162

我猜测:在创建序列化器的时候,需要指定一个类型,那么在序列化的过程中,序列化器会遍历对象所有的元素,如果某个元素超出了在创建序列化器指定的类型(比如说这里的XmlObject),那么就会抛出这个报错。

那么解决这个问题的方式有2种:

  1. 在创建的时候把你需要的类加进去。
  2. 利用XmlInclude将你需要的类加进去。

第二种可以参考官方文档,这里不在赘述(未来也用不上)。第一种的实现方案看似很难,其实只需要利用好泛型。在.NET的反序列化中常见的可以有很多泛型的类是ExpandedWrapper

var obj = new ExpandedWrapper<XmlObject, XmlObject2>();
obj.ProjectedProperty0 = new XmlObject2();
obj.ProjectedProperty0.AnyObj = new XmlObject();

我们将之前的代码改成这样,就可以成功序列化了。

<?xml version="1.0" encoding="utf-8"?>
<ExpandedWrapperOfXmlObjectXmlObject2 xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <ProjectedProperty0>
    <AnyObj xsi:type="XmlObject" />
  </ProjectedProperty0>
</ExpandedWrapperOfXmlObjectXmlObject2>

DataContractSerializer & DataContractJsonSerializer

这两个差别不大,合在一起说了。前一个出来的结果是XML类型,后一个是JSON类型。虽然DataContractSerializerXmlSerializer都是SerializerXML输出,但是两者可以说完全不同。下面是DataContractSerializer的标准序列化和反序列化代码:

var s = new DataContractSerializer(typeof(DemoObject2));
using (MemoryStream memoryStream = new MemoryStream())
{
    var obj = new DemoObject2();
    obj.AnyObj = new DemoObject2();
    s.WriteObject(memoryStream, obj);
    memoryStream.Position = 0;
    Console.WriteLine(Encoding.UTF8.GetString(memoryStream.ToArray()));

    var p1 = s.ReadObject(memoryStream);
}

这居然用WriteObject和ReadObject,咋和Java一样呢?

【待续。。。】

参考文献

Footnotes

  1. https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.start?view=net-7.0

  2. https://learn.microsoft.com/en-us/dotnet/api/system.xml.serialization.xmlserializer?view=net-7.0

  3. https://learn.microsoft.com/en-us/dotnet/api/system.runtime.serialization.datacontractserializer?view=net-7.0

  4. https://github.com/Y4er/dotnet-deserialization/blob/main/dotnet-serialize-101.md


评论