2022年9月30日金曜日

ディープコピー(詳細コピー)で複製時に気を付けるべき点(C#)

どうも。ひっくです。

クラスのオブジェクトをディープコピー(詳細コピー)したい場合があると思います。

「ディープコピー C#」で検索すると上位に出てくる検索結果上位に、

「BinaryFormatter」クラスを使用した実装例が非常に多く見受けられます。

 

コード量もすっきりしていて一見良さそうに見えますが、

今後は可能な限り使用しない方が良いでしょう、というお話を今回はしようと思います。

BinaryFormatterを利用したディープコピー実装例


まず、コードを見てみましょう。

public static class Utils
{
    /// <summary>
    /// ディープコピー作成
    /// </summary>
    /// <typeparam name="T">型パラメータ</typeparam>
    /// <param name="src">コピー元情報</param>
    /// <returns>コピーオブジェクト</returns>
    public static T DeepClone<T>(this T src)
    {
        using (var memoryStream = new System.IO.MemoryStream())
        {
            var binaryFormatter = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
            binaryFormatter.Serialize(memoryStream, src);
            memoryStream.Seek(0, System.IO.SeekOrigin.Begin);
            return (T)binaryFormatter.Deserialize(memoryStream);
        }
    }
}

[Serializable]
public class Customer
{
    public string CustomerID { get; set; }
    public string CustomerName { get; set; }
    public DateTime LastModified { get; set; }
    public List<PurchaseGoods> PurchaseGoodsList { get; set; }
}

public class PurchaseGoods
{
    public string Name { get; set; }

    public int Price { get; set; }
}

private void CopyTest()
{
    var goods1 = new List<PurchaseGoods>()
    {
        new PurchaseGoods() { Name = "milk", Price = 160 },
        new PurchaseGoods() { Name = "egg", Price = 250 },
    };

    var goods2 = new List<PurchaseGoods>()
    {
        new PurchaseGoods() { Name = "meat", Price = 350 },
        new PurchaseGoods() { Name = "fish", Price = 380 },
    };

    var customers = new List<Customer>()
    {
        new Customer() { CustomerID = "D0001", CustomerName = "Customer1", PurchaseGoodsList = goods1 },
        new Customer() { CustomerID = "D0002", CustomerName = "Customer2", PurchaseGoodsList = goods2 },
    };

    var cloneCustomers = customers.DeepClone();

    customers.First().CustomerID = "A0001";
    customers.First().LastModified = DateTime.Now;
    customers.First().PurchaseGoodsList.First().Name = "tomato";

    foreach (var customer in customers)
    {
        Debug.WriteLine($"CustomerID:{customer.CustomerID} CustomerName:{customer.CustomerName}");
        foreach (var goods in customer.PurchaseGoodsList)
        {
            Debug.WriteLine($"GoodsName:{goods.Name} GoodsPrice:{goods.Price}");
        }
    }
    foreach (var customer in cloneCustomers)
    {
        Debug.WriteLine($"CustomerID:{customer.CustomerID} CustomerName:{customer.CustomerName}");
        foreach (var goods in customer.PurchaseGoodsList)
        {
            Debug.WriteLine($"GoodsName:{goods.Name} GoodsPrice:{goods.Price}");
        }
    }
}

CopyTest() の実行結果は以下のようになります。

「customers」をディープコピーした「cloneCustomers」には、

customersに対する変更が反映されていないことが確認できます。(ディープコピーできていますね)

CustomerID:A0001 CustomerName:Customer1
GoodsName:tomato GoodsPrice:160
GoodsName:egg GoodsPrice:250
CustomerID:D0002 CustomerName:Customer2
GoodsName:meat GoodsPrice:350
GoodsName:fish GoodsPrice:380
CustomerID:D0001 CustomerName:Customer1
GoodsName:milk GoodsPrice:160
GoodsName:egg GoodsPrice:250
CustomerID:D0002 CustomerName:Customer2
GoodsName:meat GoodsPrice:350
GoodsName:fish GoodsPrice:380

 

BinaryFormatterが非推奨とされる理由


BinaryFormatterですが、現在MSDNでは使用が非推奨とされています。

.NET5では警告が表示されるものの使用は可能な状態だったようなのですが、

2022/11リリース予定の.NET7ではとうとうコンパイルエラーになる(破壊的変更が行われる)ようです。

 

そもそも.NET5で警告扱い(ASP.NETでは既定で実行時エラー)だった理由は、逆シリアル化の脆弱性があるからです。

MSDNでは以下の様に説明されています。

「逆シリアル化の脆弱性は、要求ペイロードが安全でない方法で処理される脅威のカテゴリです。 攻撃者がこのような脆弱性をアプリに対して利用することに成功すると、サービス拒否 (DoS)、情報漏えい、またはターゲット アプリ内でのリモート コード実行が発生する可能性があります。」

 

どんな場面で上記の脆弱性に対する危険が発生しうるのか、文章を読むだけではいまいちぴんと来なかったんですが、

OWASP Top 10 2017 A8(リンク先PDF 15ページ参照)を見ると、良くわかりました。

要は信頼できないデータに対してデシリアライズすると、逆シリアル化の脆弱性に対して対策されていない場合、

リモートコード実行や権限書き換えなどによる情報漏洩が起きる可能性があるよねってことなんですよね。

Webアプリのように外から受信したデータを処理することが多いなら、上記が起こりうる確率も高くなるというので

ASP.NETでは既定で実行時エラーになるようにされたんだろう、と個人的に納得しました。

 

.NET7で破壊的変更が行われる以上いずれ置き換えもしなくてはならなくなるし、今後は使用しない方が良いのだと思います。

 

代替手段は?


ICloneable.Cloneを実装して、シャローコピー(簡易コピー)とディープコピー(詳細コピー)の

両方に対応するコピー関数を用意するしか手はなさそうです。

コード量は増えるし、クラスのメンバが増えたり階層化したりするとその都度メンテしないといけないしで、

BinaryFormatterですっきり書きたくなっちゃいますが、可能な限りこっちで実装する方が良いと思われます。

public class Customer : ICloneable
{
    public string CustomerID { get; set; }
    public string CustomerName { get; set; }
    public DateTime LastModified { get; set; }
    public List<PurchaseGoods> PurchaseGoodsList { get; set; }

    public Customer Clone()
    {
        return (Customer)this.MemberwiseClone();
    }
    
    object ICloneable.Clone()
    {
        return this.Clone();
    }

    public Customer DeepCopy()
    {
        Customer customer = this.Clone();
        customer.PurchaseGoodsList = this.PurchaseGoodsList.Select(goods => goods.DeepCopy()).ToList();
        return customer;
    }
}

public class PurchaseGoods : ICloneable
{
    public string Name { get; set; }
    public int Price { get; set; }
    
    public PurchaseGoods Clone()
    {
        return (PurchaseGoods)this.MemberwiseClone();
    }
    
    object ICloneable.Clone()
    {
        return this.Clone();
    }

    public PurchaseGoods DeepCopy()
    {
        return this.Clone();
    }
}

private void CopyTest()
{
    var goods1 = new List<PurchaseGoods>()
    {
        new PurchaseGoods() { Name = "milk", Price = 160 },
        new PurchaseGoods() { Name = "egg", Price = 250 },
    };

    var goods2 = new List<PurchaseGoods>()
    {
        new PurchaseGoods() { Name = "meat", Price = 350 },
        new PurchaseGoods() { Name = "fish", Price = 380 },
    };

    var customers = new List<Customer>()
    {
        new Customer() { CustomerID = "D0001", CustomerName = "Customer1", PurchaseGoodsList = goods1 },
        new Customer() { CustomerID = "D0002", CustomerName = "Customer2", PurchaseGoodsList = goods2 },
    };

    var cloneCustomers = new List<Customer>();
    cloneCustomers.AddRange(customers.Select(customer => customer.DeepCopy()));

    customers.First().CustomerID = "A0001";
    customers.First().LastModified = DateTime.Now;
    customers.First().PurchaseGoodsList.First().Name = "tomato";

    foreach (var customer in customers)
    {
        Debug.WriteLine($"CustomerID:{customer.CustomerID} CustomerName:{customer.CustomerName}");
        foreach (var goods in customer.PurchaseGoodsList)
        {
            Debug.WriteLine($"GoodsName:{goods.Name} GoodsPrice:{goods.Price}");
        }
    }
    foreach (var customer in cloneCustomers)
    {
        Debug.WriteLine($"CustomerID:{customer.CustomerID} CustomerName:{customer.CustomerName}");
        foreach (var goods in customer.PurchaseGoodsList)
        {
            Debug.WriteLine($"GoodsName:{goods.Name} GoodsPrice:{goods.Price}");
        }
    }
}

 

CopyTest() の実行結果は以下で、BinaryFormatterの実装例と同じ結果になります。

CustomerID:A0001 CustomerName:Customer1
GoodsName:tomato GoodsPrice:160
GoodsName:egg GoodsPrice:250
CustomerID:D0002 CustomerName:Customer2
GoodsName:meat GoodsPrice:350
GoodsName:fish GoodsPrice:380
CustomerID:D0001 CustomerName:Customer1
GoodsName:milk GoodsPrice:160
GoodsName:egg GoodsPrice:250
CustomerID:D0002 CustomerName:Customer2
GoodsName:meat GoodsPrice:350
GoodsName:fish GoodsPrice:380

 

まとめ


以上、ディープコピー(詳細コピー)で複製時に気を付けるべき点について取り上げました。

既にBinaryFormatterでのディープコピーを使用してしまっている場合は、古いソースでは警告も表示されないので

脆弱性への対応が必要なケースかどうか、脆弱性への対応が行われているかを改めて確認した方が良いでしょう。

今後新たにコピー機能を追加する場合は、BinaryFormatterは使用しないことをお勧めします。

 

今回はこのへんで。ではまた!

0 件のコメント:

コメントを投稿