OJ安全

OJ安全问题主要是两个方面,一个是基于Web的安全问题,包括解决SQL注入、后端过滤等问题,这个不在本文讨论的范围之内,本文着重讨论一下第二个方面的问题:由用户提交恶意代码导致的安全问题。

恶意代码

由于OJ系统的特殊性,准许在服务器运行用户的提交的代码,那么如果代码里包含关机操作、删除文件、Socket通信等与算法无关的代码则很容易使服务器或者数据库出现问题,甚至直接崩溃。

解决方法

删除头文件

这一步是初步过滤,删除一些用不到的头文件,最典型的就是`Windows.h`,还有`WinBase.h`等,这些文件直接在编译器安装目录下删除就行了,但是一定要搞清楚不要删错了,因为很多文件之间有很强的依赖关系。基于这个曾经想过直接在用户提交的代码里面搜索这些头文件,如果搜到直接就用空字符串替换,但是这并没有什么卵用,因为C/C++的宏定义太强大,可以轻松绕过很多检查。

注释掉一些函数

有些头文件可以直接删掉,但有些头文件就不能删了,比如`stdio.h`,这个肯定无法删除,但是这个头文件里有`remove`、`fopen`等操作文件的函数,依然不能让用户使用,所以只能在注释掉这些函数。然而到最后,你会发现这依然没有什么卵用,因为用户可以直接把头文件帖到代码中!

作业对象

那么到底该怎么办呢,在Linux平台下,有`ptrace`可以维系父子进程之间的关系,但是Windows下没有这种现成的函数,但是好在Windows有作业对象(Job Object)这个东西,这个东西可以将进程组合在一起,并且创建一个“沙箱”,以便限制进程能够进行的操作!这不正是我们想要的吗。我们可以把用户提交的解题程序进程放入一个作业中,从而限制它的操作,使它运行时间受限,运行内存受限,访问权限受限等等,下面就简单介绍下如何利用作业对象限制进程的操作。

首先我们通过`CreateJobObject`创建一个作业对象:

1 HANDLE hJob = CreateJobObject(NULL,NULL);

接着我们就要加上针对作业进程的限制,进程创建后,通常需要设置一个沙箱(设置一些限制),以便限制作业中的进程能够进行的操作。可以给一个作业加上若干不同类型的限制:

  • 基本限制和扩展基本限制,用于防止作业中的进程垄断系统的资源。
  • 基本的UI限制,用于防止作业中的进程改变用户界面。
  • 安全性限制,用于防止作业中的进程访问保密资源(文件、注册表子关键字等)。

首先,我们需要加上时间内存等限制:

 1 JOBOBJECT_BASIC_LIMIT_INFORMATION Job_Limit ;
 2 memset(&Job_Limit, 0, sizeof(Job_Limit)) ;
 3 Job_Limit.LimitFlags =   JOB_OBJECT_LIMIT_PROCESS_TIME |
 4                 JOB_OBJECT_LIMIT_WORKINGSET |
 5                 JOB_OBJECT_LIMIT_ACTIVE_PROCESS ;
 6 Job_Limit.PerProcessUserTimeLimit.QuadPart = 10000*1000*3; //子进程执行时间ns(1s=10^9ns) 
 7 Job_Limit.MinimumWorkingSetSize = 1;
 8 Job_Limit.MaximumWorkingSetSize = 1024*1024*10;//10MB
 9 Job_Limit.ActiveProcessLimit = 1;
10 SetInformationJobObject(hJob, JobObjectBasicLimitInformation, &Job_Limit, sizeof(Job_Limit));

 

其中`PerProcessUserTimeLimit`限制每个进程的运行时间,`WorkingSetSize`设置作业中每个进程的工作集大小,`Job_Limit.ActiveProcessLimit = 1;`则限制每个作业中只能由一个进程在运行。

接着,我们来加上UI限制,比如不能让进程关机,读写剪贴板等:

 1 JOBOBJECT_BASIC_UI_RESTRICTIONS Job_UI_Limit;
 2 Job_UI_Limit.UIRestrictionsClass = JOB_OBJECT_UILIMIT_NONE;
 3 
 4 Job_UI_Limit.UIRestrictionsClass |= JOB_OBJECT_UILIMIT_EXITWINDOWS | //阻止进程注销,关机,重启或断开系统电源
 5                       JOB_OBJECT_UILIMIT_READCLIPBOARD | //阻止进程读取剪贴板中的内容。
 6                       JOB_OBJECT_UILIMIT_WRITECLIPBOARD | //阻止进程清除剪贴板中的内容。
 7                       JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS | //阻止进程更改系统参数。
 8                       JOB_OBJECT_UILIMIT_DISPLAYSETTINGS | //进程更改显示设置。
 9                       JOB_OBJECT_UILIMIT_GLOBALATOMS | //为作业指定其专有的全局原子表,并限定作业中的进程只能访问此作业的表。
10                       JOB_OBJECT_UILIMIT_DESKTOP | //阻止进程创建或切换桌面。
11                       JOB_OBJECT_UILIMIT_HANDLES ; //阻止作业中的进程使用同一个作业外部的进程所创建的用户对象( 如HWND) 。
12 SetInformationJobObject(hJob, JobObjectBasicUIRestrictions, &Job_UI_Limit, sizeof(Job_UI_Limit));

 

这些限制添加完后,我们就要创建进程了:

1 STARTUPINFO si = { sizeof(si) };
2 PROCESS_INFORMATION pi;
3 TCHAR szCmdLine[] = TEXT("calc.exe");
4 
5 CreateProcess(NULL, szCmdLine, NULL, NULL, FALSE, CREATE_BREAKAWAY_FROM_JOB | CREATE_SUSPENDED, NULL, NULL, &si, &pi);

 

这里为了演示,把执行程序改为Windows自带计算器,注意这里用的是`CREATE_SUSPENDED`,这样,创建了一个新进程,但是不允许它执行任何代码。

由于本程序是从不属于作业组成部分的进程来执行的,因此子进程也不属于作业的组成部分。如果准备立即允许子进程开始执行代码,那么它将跑出沙箱,并且能够成功地执行我想限制它做的工作。因此,当创建子进程之后,在我允许它开始运行之前,我必须显式地将该进程放入我新创建的作业,方法是调用下面的代码:

`AssignProcessToJobObject(hJob, pi.hProcess)`

然后我们就可以开始执行进程,并且统计我们所需要的信息:

 1 ResumeThread(pi.hThread);
 2 CloseHandle(pi.hThread);
 3 
 4 //通过WaitForSingleObject等待正在运行的工作对象,设置好允许使用时间.
 5 DWORD WaitRe = WaitForSingleObject(hJob,3000);
 6 if(WaitRe!=WAIT_FAILED)
 7 {
 8     JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo;
 9     DWORD lpReturnLength;
10     if(QueryInformationJobObject(hJob,JobObjectExtendedLimitInformation,&lpJobObjectInfo,sizeof(lpJobObjectInfo),&lpReturnLength))
11     {
12         printf("Memory:%dKb\n",lpJobObjectInfo.PeakProcessMemoryUsed/1024);
13         DWORD ExitCode=0;
14         //TerminateJobObject(Job,0);
15         if(GetExitCodeProcess(pi.hProcess,&ExitCode))
16         {
17             printf("ExitCode:%d\n",ExitCode);
18         }
19     }
20 }
21 else
22 {
23     printf("WaitForSingleObject     [Error:%d]\n",GetLastError());
24 }
25 TerminateJobObject(hJob,0);//exit
26 
27 CloseHandle(pi.hProcess);
28 CloseHandle(hJob);

当然了,这其中还涉及到子进程输入输出的问题,就不在这里讨论了。

访问控制列表

但是读到这里,你可能会想,好像用户提交的代码仍然可以操作服务器文件系统啊,没错,因为我们并没有对文件权限进行限制。这个时候就需要用到Windows系统的账户和访问控制列表了。

在服务器上新建一个帐户,要特别注意不能使用控制面板新建,而应该用管理——>系统工具新建,因为新建的帐户要求安全,而且不应出现在Windows系统登录界面上。对于新帐户的权限,设置其为允许登录,但是对于所有的驱动器或文件,都拒绝其读写权限。最后,新建一个文件夹,这个文件夹对新建的帐户放开读写权限,而且,将用户程序标准输出重定向到这个文件夹里。另外,对于服务器中的数据文件,放开该用户的读权限。用新建的帐号来运行用户程序。用户程序只能写指定的文件夹参考文献

设置好账户后,再用指定账户身份来运行子进程,具体如何操作可以参考ACM在线判题系统的安全性解决方案这篇文章。

Java Policy

上述方法都是针对C/C++代码采取的措施,那么针对Java代码,就没有这么复杂了,因为Java有自带的权限控制,只需要对指定文件夹下的Java代码设置权限,就可以保证该文件夹下的代码能安全运行。具体可参见Java沙箱的实现,里面有详细的代码实现。

小结

本文只是简略对一些处理Windows平台上的安全问题的常用手段做了一些介绍,很多细节在实现时会根据各OJ系统的具体实践有一些区别,至于Linux端的处理及一些其他的方法可以参看知乎上的这个问题:Online Judge 是如何解决判题端安全性问题的