2016/2/2 星期二
Matt Johnson 高级软件工程师,AzureCAT patterns & practices团队成员
现在已经是二月份了,我们知道2016年是闰年,对大多数人来说,这可能只是一个奇特的月份,会有额外多出一天来工作或者娱乐。但是对于软件开发者来说,闰年可能会造成巨大的烦恼。
如果你现在才想起来检查你的代码以应对闰年漏洞的话,那就得尽快了。事实上,漏洞可能已经带来了影响,但是你可能完全还没有意识到。什么样的漏洞会潜伏在你的代码里呢?
- 差一错误(off-by-one issues),特别是围绕使用日期范围查询来筛选数据
- 用户界面出现意外或者非预期的行为
- 由于潜在的边界案例引起的异常、崩溃或异常挂起
“嗯“,你说,“我的代码是好的,因为我们有单元测试。“
“哦,是真的吗?”我说,“你做的测试里面有适当的模拟时钟的案例吗?他们测试过一些边缘情况了吗?包括将2月29号和12月31号考虑在内吗?你测试过你可能使用的底层C++代码和系统的其余部分了吗?你知道闰年漏洞会是什么样的吗?”
通常情况下会一脸茫然。
为什么这个漏洞和Azure有关系呢?
它适用于任何开发人员编写的代码,并且大部分代码可能最终会运行在Azure云上在2012年Azure经历过一次
闰年造成的服务中断,我们一直在努力,以确保在这次闰年不会有任何问题。我们希望我们所有的客户可以从上次经历的事故以来我们所做的研究和经验中获益。
那我需要知道什么呢?
这里要介绍许多内容,让我们从最重要的开始吧。
这些都是上过头条的大事件。我敢肯定至少发生了数千起有着不同程度的影响力和引人注目的事件。另一个是
1996年鲜为人知的漏洞,直接导致新西兰和塔斯马尼亚岛铝冶炼厂的控制中心在12月31号午夜(第366天)突然关闭。
因为熔融金属的温度不受控制从而导致了数百万美元的设备损坏。一个又一个的故事警示我们软件的小故障对真实世界造成的危险。考虑到最近广泛采用IOT(物联网)与云计算交互,我们都应该保持高度警惕。
两个最危险的闰年漏洞
#1:在C/C++中加上或减去年份
在C/C++代码使用Win32 API时,
SYSTEMTIME结构类型是民用时间的常用表示方式,日期的每个部分都用不同的字段来表示,分离出年、月、日和其他值,下面的代码很常见,:
<pre name="code" class="cpp">SYSTEMTIME st; // 声明一个SYSTEMTIME的变量
GetSystemTime(&st); // 获取当前的日期和时间赋给st变量
st.wYear++; // 给st增加1年
上面的代码将成功调用,但是在2月29号被调用的话就会有风险,所得到的值仍会是2月29号,但是所得值的年份却是非闰年,例如,
2016-02-29 + 1 年 = 2017-02-29, 这个值并不存在!在最终作为参数传递给其他函数前,这个值可能被会传递很多次。例如
SystemTimeToFileTime,
它会导致函数失败并返回0。不幸的是,在代码中发现调用方法后却不检查返回值的情况已是司空见惯,这会导致不可预见的错误,例如使一个
FILETIME值处在未初始化的状态。
- 经常检查Win32函数调用结果的状态值,特别是:SystemTimeToFileTime。
- 正确的给SYSTEMTIME 增加一年,检查结果的有效性,必要时做出调整。
SYSTEMTIME st; // 声明一个SYSTEMTIME的变量
GetSystemTime(&st); // 获取当前的日期和时间赋给st变量
st.wYear++; // 给st增加1年
// 检查是否为闰年
bool leap = st.wYear % 4 == 0 && (st.wYear % 100 != 0 || st.wYear % 400 == 0);
// 如果是2月29号但不是闰年,就将28号赋给st.wDay
st.wDay = st.wMonth == 2 && st.wDay == 29 && !leap ? 28 : st.wDay;
需要注意的是,类似的错误也可能发生在标准的C++(非Windows)代码中,其使用
tm结构类型而不是
SYSTEMTIME,它具有略微不同的行为,月份是使用0-11而不是1-12,所以2月在
tm 中是用1来表示的。你可能调用
_mkgmtime
来生成
time_t结构类型而不是
SystemTimeToFileTime。最主要的不同点是,当你传递一个非闰年的2月29号时,它会生成一个表示3月1号的值。如果你的应用程序需要的值是2月28号的话,那就需要做出调整了。
#2:声明一个由全年中每一天构成的数组
int items[365];
items[dayOfYear - 1] = x;
上面的C语言代码可以很容易在C#或者其他语言中重写,或使用字符串或者其他类型来代替整数类型。关键点是是我们通过声明一个固定大小的数组来放置数据,并且假设一年中的每一天都在数组中有一个位置。问题是闰年中的第366天即12月31号在数组中将无法放置。
这种方式针对不同的语言产生的影响有很大的不同。在C#中,这会抛出
IndexOutOfRangeException 异常。在C语言中,在开启编译对边界有效性检查的情况下会产成
缓冲区溢出,这些影响或微不足道,或相当重大。JavaScript 开发者在这个问题上不用担心,因为第366个元素会被自动添加。
数据过滤问题
闰年漏洞会影响从前一年的2月28号到下一年的3月1号的数据。通常他们都体现在数据过滤中,当查询范围不能正确计算到多出的一天;或者假设一年总是365天,或者假设2月总是28天。例如下面的SQL 语句:
SELECT AVG(Total) as AverageOrder, SUM(Total) as GrandTotal
FROM Orders WHERE OrderDate >= @startdate AND OrderDate < @enddate
这个查询本身是没有问题的,但是如果
@enddate 设置成今天,
@startdate 设置成今天减去365天,这样会发生什么情况呢?如果在这范围内正好有一个2月29号,那么这个查询范围就不能覆盖整个一年。假如这个查询是表示一年的数据,那么它是不正确的,因为开始时间少算了一天。
当你对这样的漏洞进行评估的时候,问问你自己这样的漏洞会造成什么样的影响。在这种情况下,这些值会显示在哪里?例如,如果订单的平均金额作为图标的数据显示在仪表盘上,并每天进行更新,那么这可能并没有像在公司的财报(比如证监会季度文件)上显示全年的总销售额那么重要。当然,这个评定需要一些熟悉它的用途和使用情况的人;这没有一个通用的规则去遵守的。
也许通过下面的方法可以解决这个问题:
TimeSpan oneYear = TimeSpan.FromDays(isLeapYear(endDate.Year) ? 366 : 365);
DateTime startDate = endDate - oneYear;
然而这个方法是有缺陷的。我们不能只通过年来确定需要添加的天数。 假如
endDatea可以是
2016-01-01 , 虽然2016年是闰年。但是只要减去365天就可以得到
2015-01-01 。然而,你必须考虑2月29号是否在范围内,如果手动来做的话,势必会增加代码的复杂度,特别是你需要考虑覆盖多个年份而不仅仅是一个。
最终归结为这样的一个事实,.NET中用
TimeSpan 来表示绝对时间(其他语言也有类似的类型),将“年“和”月“作为民用时间的单位。一年或者一个月中的时间绝对量是一个变量,它依赖于你所要描述的年和月。当你考虑夏令时,同样可以说是实际上的一天,本片文章将不详述。
.NET中正确的解决方案是:
DateTime startDate = endDate.AddYears(-1);
AddYears 方法能够正确的实现所有必要的逻辑来判断需要向前或向后移动多少天(本例子中的-1)。
JavaScript增加一年
JavaScript 开发者在这种情况下真的应该使用
moment.js,可以简单的表示如下:
var m = moment();
m.add(1, 'years');
然而,一些人仍然喜欢使用笨方式做这件事,所以你经常会看到这样的:
var d = new Date();
d.setFullYear(d.getFullYear() + 1);
这儿会产生我之前描述的问题,如果今天是闰年的2月29号,上面代码得到的结果将会是3月1号,这个结果也许是你可接受或不可接受的。考虑到如果对其他日期,得到的月份的值和原值是一样的,同样考虑到你的程序可能希望得到一个月末的日期而不是一个月初的日期。
通过下面的方法你能够通过JavaScript语言正确的增加年数而不需要一个完整的库。
function addYears(d, n) {
var m = d.getMonth();
d.setFullYear(d.getFullYear() + n);
if (d.getMonth() !== m)
d.setDate(d.getDate() - 1);
}
// 使用举例点击打开链接
var d = new Date();
addYears(d, 1);
这种方式实现了增加年数,它会检查日期是否过渡到了3月,如果是的话就做出调整。再次强调,不要试着去计算到底增加多少天来实现这个,除非你真的知道你在做什么。
其他常见的错误
下面是其他一些开发者出现的与闰年相关的错误,例如:
- 搞乱了闰年的算法。并不是每4年一次,而是能被4整除,但是不能被100整除,或者能被400整除的年份。1900年就不是闰年,但是2000年是,2100年也不是闰年。
- 使用以每个月的天数定义的数组,并将2月份标记为28天。当使用这样的数组时,你必须考虑到闰年的第29天,一个不错的方式就是为闰年准备一个不同的数组以区别于平常的年份。或者更好的方式是使用你拥有的API (当可用的时候)来代替自己做算术。
- 为闰年创建分支来编写代码,但是没有测试所有的代码。例如,Zune 漏洞的代码在顶部的有一个分支叫IsleapYear(year),这显然是从来没有测试过。
- 使用单独的年、月和天的值,但不验证有效性。例如,你可能有一个界面通过单独的下拉控件来选择每一个日期的组成部分,如果只是测试某天是否在一个月内有效是不够的,你还必须考虑年。
- 在日期计算中使用一年的平均长度,例如365.25或者365.2425天。虽然这个可能是在科学上是精确的,但是它绝不适合对民用时间进行这样的操作,至少如果你需要的是精确的值。如果你只需要一个近似值这是没有问题的,但是相关联的当天时间可能会从结果中消失。
怎么捕获闰年漏洞?
- 仔细检查你的代码。搜寻任何与时间相关的,仔细的排查。
- 确保你做过很多的单元测试,知道如何“模拟时钟”(下一节介绍)
- 测试全年,而不只是之前的闰年
- 验证所有的输入,包括配置
- 验证结果和完善测试方案,要有一个结果验证失败后的策略(failure strategy)。
经常有人问我其他两种方法:
静态代码分析
如果有一套工具通过跑一下你的代码就可以帮助分析并指出闰年的漏洞就好了,但不幸的是,据我所知没有。目前能给到你的就是,简单的字符搜索或者是正则表达式搜索。
.NET真正需要的是有一整套
Roslyn 分析器,它可以捕获普通的日期/时间漏洞包括闰年、时区、夏令时 、语法分析等。可惜,我还没有时间去创建这样的分析器,也许日后我会去做,但现在还没有。
如果C++、JavaScript或者其他语言有类似的工具也是不错的。尽管据我所知是没有的。
时间错位
为什么不将时钟移到到之后的某个时间,看看会发生什么?对于某些系统这个实际上是可行的,但这种想法会有几个问题:
- 你的单元测试可能仍然捕获不到所有的情况。你也许不会去捕获日期筛选错误,除非你实际在看(用肉眼)你整个程序的屏幕和报告,这肯定是容易出错的。
- 你可能对安全性有一种错觉,相信一切都是没问题的,只有当你的客户在2月29号或者3月1号给你打电话抱怨的时候,你会意识到你之前的想法是错误的。
- 许多系统用域服务器进行身份验证,或者使用时间敏感的其他身份验证方案。需要承认的是Kerberos协议有严格的时间同步要求,默认的时间差不超过5分钟。同时考虑SSL证书、代码签名证书和其他与安全有关的依赖于时钟的情况,如果你试图在真实时间上做手脚会导致验证失败。
所以,一般来说,我建议你不要用这个方式,或者至少你需要考虑到外部资源的任何依赖项可能会导致误判或者使某些代码不被测试到。
模拟时钟
如何测试在不同日期有不同行为的代码呢?模拟时钟!
这是在许多可靠的系统中发现一个共同的模式(有时以“虚拟时钟”的名字被人所知道)。最主要的一点是,你所知道的系统时钟告诉你现在是什么时间-不应该随意使用。程序逻辑不应该直接调用
DateTime.Now 或者
DateTime.UtcNow或者
new Date() 或者
GetSystemTime 或在你自己所用的语言中用类似的手段来获取当前时间和日期。
取而代之的是,你应该将时钟作为一种服务(在某种意义上),像其他的服务一样,你可以去模拟它。
例如,在.NET中,我们不在程序的逻辑中直接调用
DateTimeOffset.UtcNow (或者类似的API),而是:
- 创建一个IClock 接口,并声明方法GetCurrentTime,它的返回类型是DateTimeOffset。
- 创建一个SystemClock 类来实现IClock接口,并在GetCurrentTime方法内调用DateTimeOffset.UtcNow。
- 创建一个FakeClock 类实现IClock接口, 它接收一个固定的值作为构造函数的参数,并在GetCurrentTime方法内返回这个固定值。
- 你只要在你的程序逻辑中依赖IClock 接口。这通常叫做构造函数注入。
- 在测试时使用FakeClock ,运行时使用SystemClock。
这个听上去需要做很多工作,但是你只要用过一次你就会看到它的优势,这确实是确保所有对当前日期和时间有依赖性的情况下对代码进行测试的唯一方法。
我没有在这提供代码,因为在不同的语言下这种开发模式是一样的。
Noda Time在这方面已经做的很不错了,它在主程序集中提供
IClock 和
SystemClock,在
NodaTime.Testing程序集中提供
FakeClock。用Noda
Time你会做的更好。
JavaScript开发者应该考虑使用像
Sinon.JS 、
MockDate 或者
built-in
mocking support from moment.js 这些类库。
其他语言的类库有相似的功能,请务必在试图自己实现前找到它们。
结论
闰年已经到了,不是2000年,也不是
2038年,我们不得不定期地与之抗衡。前4年你写了多少代码呢?你确定一切都符合标准?现在花时间来测试和扫描你的代码吧,你也许会发现一些你不知道的潜伏在暗处的事情。
还有问题?请在这留下你的评论,我们将很乐意回答。
这部分内容最初发表在
codeofmatt.com ,经许可再版。
本文翻译自:https://azure.microsoft.com/en-us/blog/is-your-code-ready-for-the-leap-year/