C++设计一个简单的壳(2)
来源:互联网 发布:练字软件免费下载 编辑:程序博客网 时间:2024/06/05 11:14
本文中的代码均来自《黑客免杀攻防》,如要转载,需写明来源,请勿用于非法用途,作者对此文章中的代码造成的任何后果不负法律责任。本文接上一篇简单的壳1.
2.加壳部分
写这个部分的时候我自己对于这部分的代码也没有做到很熟悉,因为这部分相对于前一部分来说复杂了许多,后面就会看见。所以我也是通过再次巩固来进一步理解加壳的基本步骤。废话不多说,首先还是从声明部分说起。对了在这之前,最好是把上一部分获得的dll文件作为这个项目中的资源,以方便后面的操作。
2.1声明
这个项目中有两个自己定义的头文件,一个是和上面一样的声明文件,还有一个就是类CProcessingPE的头文件,这个类是专门做一些关于PE文件的操作的,可想而知这部分对PE文件的操作将会是多频繁。好了,那就一个一个头文件来看吧。
首先肯定是用于给Stub部分(就是上面的壳部分)传递参数的结构体,前面说过了不再细说:
typedef struct _GLOBAL_PARAM
{
BOOL bShowMessage; // 是否显示解密信息
DWORDdwOEP; // 程序入口点
PBYTElpStartVA; // 起始虚拟地址(被异或加密区)
PBYTElpEndVA; // 结束虚拟地址(被异或加密区)
}GLOBAL_PARAM,*PGLOBAL_PARAM;
接下来是一个导出函数,因为加壳这个部分生成的也是dll文件以提供界面部分调用,而调用的函数就是下面要导出的,两个参数分别是需要加壳的PE文件路径和是否显示解密之后的信息:
A1PACK_BASE_API bool A1Pack_Base(LPWSTRstrPath,bool bShowMsg);
好了,在定义CProcessingPE类之前,还有一个结构体需要定义,这个结构体用于存储被加壳的PE文件信息,里面的成员很容易理解,就不细说了:
typedef struct _PE_INFO
{
DWORD dwOEP; // 入口点
DWORD dwImageBase; // 映像基址
PIMAGE_DATA_DIRECTORYpDataDir; // 数据目录指针
IMAGE_DATA_DIRECTORY stcExport; // 导出目录
PIMAGE_SECTION_HEADERpSectionHeader; // 区段表头部指针
}PE_INFO,*PPE_INFO;
下面再来看类的定义,这里面也都有注释,各个函数的具体代码等用到的时候再详细说明:
class CProcessingPE
{
public:
CProcessingPE(void);
~CProcessingPE(void);
public:
DWORDRVAToOffset(ULONG uRvaAddr); // RVA转文件偏移
DWORDOffsetToRVA(ULONG uOffsetAddr); // 文件偏移转RVA
BOOL GetPeInfo(LPVOID lpImageData, DWORDdwImageSize, PPE_INFO pPeInfo); // 获取PE文件的信息
void FixReloc(DWORD dwLoadImageAddr); // 修复重定位信息
PVOIDGetExpVarAddr(LPCTSTR strVarName); // 获取导出全局变量的文件偏移
void SetOEP(DWORD dwOEP); // 设置新OEP
PVOIDAddSection(LPCTSTR strName, DWORD dwSize, DWORD dwChara, PIMAGE_SECTION_HEADERpNewSection, PDWORD lpSize); // 添加区段
private:
DWORD m_dwFileDataAddr; // 目标文件所在缓存区的地址
DWORD m_dwFileDataSize; // 目标文件大小
PIMAGE_DOS_HEADERm_pDos_Header; // DOS头指针
PIMAGE_NT_HEADERSm_pNt_Header; // NT头指针
PE_INFO m_stcPeInfo; // PE关键信息
};
简单了解完了一些声明之后再根据程序的流程来详细了解加壳是如何运作的。
2.2加壳代码
整个加壳的部分并没有用到Dllmain函数,而是通过MFC界面程序调用导出函数来完成的,因此需要从导出函数入手。导出函数分为八个部分,下面依次来看。
(1)生成输出文件路径
LPWSTRstrSuffix = PathFindExtension(strPath); // 获取文件的后缀名
wcsncpy_s(szOutPath,MAX_PATH,strPath,wcslen(strPath));// 备份目标文件路径到szOutPath
PathRemoveExtension(szOutPath); // 将szOutPath中保存路径的后缀名去掉
wcscat_s(szOutPath,MAX_PATH,L"_Pack"); // 在路径最后附加“_Pack”
wcscat_s(szOutPath,MAX_PATH,strSuffix); // 在路径最后附加刚刚保存的后缀名
代码很简单,这是在原路径上在原文件名后面加上一个“_Pack”以表示这是已经加壳的代码,然后产生最终的输出文件的全路径备用。
(2)获取文件信息,并映射进内存中
if( INVALID_HANDLE_VALUE ==(hFile_In=CreateFile(strPath,GENERIC_READ,FILE_SHARE_READ,NULL,OPEN_EXISTING,0,NULL)))
{ return false; }
if( INVALID_FILE_SIZE == ( dwFileSize=GetFileSize(hFile_In,NULL)) )
{ CloseHandle(hFile_In);
returnfalse;
}
if( !(lpFileImage=VirtualAlloc(NULL,dwFileSize*2,MEM_COMMIT,PAGE_READWRITE)) )
{ CloseHandle(hFile_In);
returnfalse;
}
DWORDdwRet;
if( !ReadFile(hFile_In,lpFileImage,dwFileSize,&dwRet,NULL) )
{ CloseHandle(hFile_In);
VirtualFree(lpFileImage,0,MEM_RELEASE);
returnfalse;
}
这段代码也是比较容易,显示打开文件然后获取文件大小,之后分配内存空间并将其复制进内存,这样做其实是为了不破坏原有的未加壳的PE文件。
(3)获取PE文件信息
objProcPE.GetPeInfo(lpFileImage,dwFileSize,&stcPeInfo);
这部分虽然只有一条语句,却是相当关键的,这里调用了类的成员函数GetPeInfo,三个参数分别为:文件在内存中的首地址,文件大小和输出参数(用于存储该PE文件的结构体)。接下来便是分析GetPeInfo函数。
这个函数中开始先是判断当前OEP是否为空,不为空则直接将objProcPE对象中的PE信息复制给第三个参数,直接返回,否则从文件的内存副本的获取信息,代码如下:
//获取DOS头、NT头
m_pDos_Header= (PIMAGE_DOS_HEADER)lpImageData;
m_pNt_Header =(PIMAGE_NT_HEADERS)((DWORD)lpImageData+m_pDos_Header->e_lfanew);
//获取OEP
m_stcPeInfo.dwOEP= m_pNt_Header->OptionalHeader.AddressOfEntryPoint;
//获取映像基址
m_stcPeInfo.dwImageBase= m_pNt_Header->OptionalHeader.ImageBase;
//获取关键数据目录表的内容
PIMAGE_DATA_DIRECTORYlpDataDir = m_pNt_Header->OptionalHeader.DataDirectory;
m_stcPeInfo.pDataDir= lpDataDir;
CopyMemory(&m_stcPeInfo.stcExport,lpDataDir+IMAGE_DIRECTORY_ENTRY_EXPORT, sizeof(IMAGE_DATA_DIRECTORY));
//获取区段表与其他详细信息
m_stcPeInfo.pSectionHeader= IMAGE_FIRST_SECTION(m_pNt_Header);
这段代码相信也没什么难度,如果不清楚PE文件格式,可以看一下之前的关于PE文件的详细说明。由于这些信息获取的时候都是存储在对象中的PE_INFO成员,所以最后复制到参数3这个函数就结束了。
(4)获取目标文件代码段的起始结束信息
//读取第一个区段的相关信息,并将其加密(默认第一个区段为代码段)
PBYTE lpStart =(PBYTE)(stcPeInfo.pSectionHeader->PointerToRawData+(DWORD)lpFileImage);
PBYTE lpEnd =(PBYTE)((DWORD)lpStart+stcPeInfo.pSectionHeader->SizeOfRawData);
PBYTE lpStartVA =(PBYTE)(stcPeInfo.pSectionHeader->VirtualAddress+stcPeInfo.dwImageBase);
PBYTE lpEndVA =(PBYTE)((DWORD)lpStartVA+stcPeInfo.pSectionHeader->SizeOfRawData);
这段代码是利用了(3)中返回的结构体中的区段表成员来获得第一个区段的在文件中的起始结束偏移和RVA的。这里默认第一个区段就是.text代码段,书上说市场上几乎所有的编译器都是这样的,所以这样做不仅可以比较简单,同时还兼顾了兼容性。
(5)对文件进行预处理
Pretreatment(lpStart,lpEnd,stcPeInfo);
这个函数是对文件的预处理,下面来看看它的代码:
void Pretreatment(PBYTE lpCodeStart, PBYTElpCodeEnd, PE_INFO stcPeInfo)
{ // 加密指定区域
while( lpCodeStart<lpCodeEnd )
{ *lpCodeStart ^= 0xA1;
*lpCodeStart +=0x88;
lpCodeStart++;
}
// 给第一个区段附加上可写属性
PDWORDpChara = &(stcPeInfo.pSectionHeader->Characteristics);
*pChara =*pChara|IMAGE_SCN_MEM_WRITE;
}
看到红色前两句就是熟悉的加密语句,而后面则必须要给.text区段增加可写属性,这是为了在解密时改变.text区段的值,使其恢复成原来的代码。
(6)植入Stub
DWORD dwStubSize = 0;
GLOBAL_PARAMstcParam = {0};
stcParam.bShowMessage = bShowMsg;
stcParam.dwOEP = stcPeInfo.dwOEP +stcPeInfo.dwImageBase;
stcParam.lpStartVA = lpStartVA;
stcParam.lpEndVA = lpEndVA;
dwStubSize =Implantation(lpFileImage,dwFileSize,&objProcPE,stcPeInfo,stcParam);
这里前面分别定义了Stub的大小以及结构体GLOBAL_PARAM的一个对象并对其进行了赋值,然后调用Implantation函数,参数分别为:文件在内存中的基址,文件大小,之前定义的类对象,PE信息,刚刚初始化的用于给Stub传入参数的结构体。下面来看看这个至关重要的函数,这个函数相当复杂,并且调用了许多函数,必须要分块讲解。
// 1. 在资源中读取文件内容
HRSRC hREC = NULL; // 资源对象
HGLOBALhREC_Handle = NULL; // 资源句柄
DWORD dwStubSize = NULL; // 文件大小
LPVOID lpResData = NULL; // 资源数据指针
HMODULEhModule =GetModuleHandle(L"A1Pack_Base.dll");
if( !(hREC=FindResource(hModule, MAKEINTRESOURCE(IDR_STUB1), L"STUB"))) return false;
if( !(hREC_Handle=LoadResource(hModule, hREC)) ) return false;
if( !(lpResData=LockResource(hREC_Handle)) ) return false;
if( !(dwStubSize=SizeofResource(hModule, hREC)) ) return false;
还记得之前将壳的生成的dll文件作为资源吗?这里就用到了,取出资源中的dll文件,获取内容以及大小。再来看看第2部分:
// 2. 提取Stub部分的关键信息
CProcessingPEobjProcPE;
PE_INFO stcStubPeInfo;
PBYTE lpData = new BYTE[dwStubSize];
// 2.1 将Stub复制到临时缓冲区,防止重复操作
CopyMemory(lpData,lpResData,dwStubSize);
// 2.2 获取Stub的PE信息
objProcPE.GetPeInfo(lpData,dwStubSize,&stcStubPeInfo);
// 2.3 算出代码段的相关信息(默认第一个区段为代码段)
//获取.text段的偏移和大小
PBYTElpText =(PBYTE)(stcStubPeInfo.pSectionHeader->PointerToRawData+(DWORD)lpData);
DWORDdwTextSize = stcStubPeInfo.pSectionHeader->SizeOfRawData;
第2部分一目了然,再来看第3部分:
// 3. 添加区段
DWORD dwNewSectionSize = 0;
IMAGE_SECTION_HEADER stcNewSection = {0};
PVOIDlpNewSectionData = pobjPE->AddSection(L".A1Pass",dwTextSize,IMAGE_SCN_CNT_CODE|IMAGE_SCN_MEM_READ|IMAGE_SCN_MEM_WRITE|IMAGE_SCN_MEM_EXECUTE,&stcNewSection,&dwNewSectionSize);
这里首先定义了两个输出参数,分别是新添加区段的首地址和大小,然后调用AddSection函数对PE文件添加一个新的区段,前三个参数分别为区段名、Stub.dll中.text的大小以及新区段的属性值,后面两个则是返回参数。到了这里,接下去又不得不分析AddSection函数,这个函数代码也比较多,但是就不分块了,一气呵成讲完:
PVOID CProcessingPE::AddSection(LPCTSTR strName,DWORD dwSize, DWORD dwChara, PIMAGE_SECTION_HEADER pNewSection, PDWORD lpSize)
{
PIMAGE_SECTION_HEADERpSectionHeader = IMAGE_FIRST_SECTION(m_pNt_Header);
//1. 获取基本信息,这里获取到的信息是为了最后插入新区段时需要的,因为新区段要插入到//整个头部的后面
DWORDdwDosSize = m_pDos_Header->e_lfanew;
DWORDdwPeSize = sizeof(IMAGE_NT_HEADERS32);
DWORDdwStnSize =m_pNt_Header->FileHeader.NumberOfSections * sizeof(IMAGE_SECTION_HEADER);
DWORDdwHeadSize = dwDosSize+dwPeSize+dwStnSize;
//2. 在区段表中加入新区段的信息
//2.1 获取基本信息
CHAR szVarName[7] = {0};
// 文件粒度,其实就是区段在文件中的对齐值,下面的区段粒度则是在内存中的对齐值
DWORDdwFileAlign = m_pNt_Header->OptionalHeader.FileAlignment;
DWORDdwSectAlign = m_pNt_Header->OptionalHeader.SectionAlignment;//区段粒度
WORD dwNumOfsect =m_pNt_Header->FileHeader.NumberOfSections; //区段数目
//2.2 获取最后一个区段的信息
IMAGE_SECTION_HEADERstcLastSect = {0};
CopyMemory(&stcLastSect,&pSectionHeader[dwNumOfsect-1], sizeof(IMAGE_SECTION_HEADER));
//2.3 根据区段粒度计算相应地址信息
DWORDdwVStart = 0; // 新区段虚拟地址起始位置
DWORDdwFStart = stcLastSect.SizeOfRawData + stcLastSect.PointerToRawData;//新区段文件地址起始位置,下面则是根据stcLastSect区段的实际虚拟内存大小是否对齐来计算新区段所需的内存起址
if ( stcLastSect.Misc.VirtualSize%dwSectAlign )
dwVStart= (stcLastSect.Misc.VirtualSize / dwSectAlign+1) * dwSectAlign +stcLastSect.VirtualAddress;
else
dwVStart= (stcLastSect.Misc.VirtualSize / dwSectAlign ) * dwSectAlign + stcLastSect.VirtualAddress;
DWORDdwVirtualSize = 0; // 新区段虚拟大小
DWORDdwSizeOfRawData = 0; // 新区段文件大小
//和上面一样,都是判断是否对齐内存来获得该区段在内存中的大小
if( dwSize%dwSectAlign)
dwVirtualSize = (dwSize / dwSectAlign+1) * dwSectAlign;
else
dwVirtualSize = (dwSize / dwSectAlign ) * dwSectAlign;
//这里则是获得新区段在文件中的大小
if( dwSize%dwFileAlign )
dwSizeOfRawData= (dwSize / dwFileAlign+1) * dwFileAlign;
else
dwSizeOfRawData= (dwSize / dwFileAlign ) * dwFileAlign;
//由于传进来的第一个参数是宽字符串,所以这里将其转化为普通字符串
WideCharToMultiByte(CP_ACP,NULL, strName, -1, szVarName, _countof(szVarName), NULL, FALSE);
//2.4 根据前面获得的信息组装一个新的区段头
IMAGE_SECTION_HEADERstcNewSect = {0};
CopyMemory(stcNewSect.Name,szVarName, 7); // 区段名称
stcNewSect.Misc.VirtualSize= dwVirtualSize; // 虚拟大小
stcNewSect.VirtualAddress = dwVStart; // 虚拟地址
stcNewSect.SizeOfRawData = dwSizeOfRawData; // 文件大小
stcNewSect.PointerToRawData= dwFStart; // 文件地址
stcNewSect.Characteristics = dwChara; // 区段属性
//2.5 写入指定位置CopyMemory其实就是menset函数
CopyMemory((PVOID)((DWORD)m_dwFileDataAddr+dwHeadSize), &stcNewSect, sizeof(IMAGE_SECTION_HEADER));
//3. 修改区段数目字段NumberOfSections
m_pNt_Header->FileHeader.NumberOfSections++;
//4. 修改PE文件的景象尺寸字段SizeOfImage
m_pNt_Header->OptionalHeader.SizeOfImage+= dwVirtualSize;
//5. 返回新区段的详细信息、大小,以及可直接访问的地址
CopyMemory(pNewSection,&stcNewSect, sizeof(IMAGE_SECTION_HEADER));
*lpSize = dwSizeOfRawData;
return(PVOID)(m_dwFileDataAddr+dwFStart);
}
到了这里,新区段也就是Stub.dll的代码段(由于之前已经把数据段都加入代码段中,所以用到的全局变量也在其中)已经加入到PE文件中。下面来看第4部分:
// 4. 对Stub部分进行的重定位操作,首先用公式计算出新映像的加载基址,然后传入重定位函数
// 利用公式:新的加载地址 = (新区段的起始RVA - Stub的".Text"区段的起始RVA) + 映像基址
DWORDdwLoadImageAddr =
(stcNewSection.VirtualAddress -stcStubPeInfo.pSectionHeader->VirtualAddress) + stcPeInfo.dwImageBase;
objProcPE.FixReloc(dwLoadImageAddr);
下面再来看重定位函数:
void CProcessingPE::FixReloc(DWORDdwLoadImageAddr)
{
//1. 获取映像基址与代码段指针
DWORD dwImageBase;
PVOID lpCode;
dwImageBase= m_pNt_Header->OptionalHeader.ImageBase;
lpCode = (PVOID)( (DWORD)m_dwFileDataAddr +RVAToOffset(m_pNt_Header->OptionalHeader.BaseOfCode) );
//2. 获取重定位表在内存中的地址
PIMAGE_DATA_DIRECTORY pDataDir;
PIMAGE_BASE_RELOCATIONpReloc;
pDataDir= m_pNt_Header->OptionalHeader.DataDirectory+IMAGE_DIRECTORY_ENTRY_BASERELOC;
pReloc =(PIMAGE_BASE_RELOCATION)((DWORD)m_dwFileDataAddr +RVAToOffset(pDataDir->VirtualAddress));
//3. 遍历重定位表,并对目标代码进行重定位
while( pReloc->SizeOfBlock && pReloc->SizeOfBlock < 0x100000 )
{
//3.1 取得重定位项TypeOffset与其数量
PWORD pTypeOffset =(PWORD)((DWORD)pReloc+sizeof(IMAGE_BASE_RELOCATION));
DWORD dwCount =(pReloc->SizeOfBlock-sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
//3.2 循环检查重定位项
for( DWORD i=0; i<dwCount; i++ )
{
if ( !*pTypeOffset ) continue;
//3.2.1 获取此重定位项指向的指针
DWORD dwPointToRVA = (*pTypeOffset&0x0FFF)+pReloc->VirtualAddress;
PDWORDpPtr =(PDWORD)(RVAToOffset(dwPointToRVA)+(DWORD)m_dwFileDataAddr);
//3.2.2 计算重定位增量值
DWORDdwIncrement = dwLoadImageAddr - dwImageBase;
//3.2.3 修复需重定位的地址数据
*((PDWORD)pPtr)+= dwIncrement;
pTypeOffset++;
}
//3.3 指向下一个重定位块,开始另一次循环
pReloc= (PIMAGE_BASE_RELOCATION)((DWORD)pReloc + pReloc->SizeOfBlock);
}
}
对于了解重定位机制的同学来说这段代码是比较容易懂的,重定位完成之后后面的代码就比较少了,也比较简单,这里就一起列出:
// 5. 写入配置参数
// 5.1 获取Stub的导出变量地址,这个和第一部分写壳时候的代码差不多,都是遍历导出表获得地址,只不过这里是变量而之前是导出函数
PVOIDlpPatam = objProcPE.GetExpVarAddr(L"g_stcParam");
// 5.2 保存配置信息到Stub中
CopyMemory(lpPatam,&stcParam,sizeof(GLOBAL_PARAM));
// 6. 将Stub复制到新区段中
CopyMemory(lpNewSectionData,lpText,dwTextSize);
// 7. 计算并设置新OEP,这是为了在程序加载时第一时间运行Stub.dll中的代码,运行这段代码给PE文件解密之后再将控制权交还给原来的PE文件代码
DWORDdwNewOEP = 0;
// 7.1 计算新OEP
DWORDdwStubOEP = stcStubPeInfo.dwOEP;
DWORDdwStubTextRVA =stcStubPeInfo.pSectionHeader->VirtualAddress;
DWORDdwNewSectionRVA = stcNewSection.VirtualAddress;
dwNewOEP= (dwStubOEP-dwStubTextRVA) + dwNewSectionRVA;
// 7.2 设置新OEP
pobjPE->SetOEP(dwNewOEP);
// 8. 释放资源,函数返回
delete[]lpData;
FreeResource(hREC_Handle);
returndwNewSectionSize;
至此,已经将所有的修改都在内存文件中做好了,接下来的就是将内存中的文件写入到新建的文件中去,这个对于使用过Windows API的同学来说应该很容易,下面就来简单列出代码。
(7,8)写入新文件并返回,结束程序
//7将处理完成后的结果写入到新文件中,这里用到了第一部分创建的输出文件路径变量
if( INVALID_HANDLE_VALUE != (hFile_Out=CreateFile(szOutPath,GENERIC_WRITE|GENERIC_READ, FILE_SHARE_WRITE|FILE_SHARE_READ, NULL,OPEN_ALWAYS,0,NULL)) )
{
DWORDdwRet = 0;
WriteFile(hFile_Out,lpFileImage, dwStubSize+dwFileSize,&dwRet,NULL);
}
//8释放相关资源并返回
CloseHandle(hFile_In);
CloseHandle(hFile_Out);
VirtualFree(lpFileImage,0,MEM_RELEASE);
returntrue;
到了这里,加壳的所有部分都已结束,运行完这些代码,就给PE文件加上了简单的壳,接下来也就是最后一部分我们来看简单的MFC界面程序。
3. MFC界面程序
关于MFC界面实际上我自己也不是很了解,但是看到作者写了那么多代码,实在不想去看,大概看了一下,觉得下面这几段需要贴出来记录一下:
//这里是包含头文件
#pragma once
#define A1PACK_BASE_EXPORTS
#include "../A1Pack_Base/A1Pack_Base.h"
//包含静态库
#ifdef _DEBUG
#pragma comment(lib, "../Debug/A1Pack_Base.lib")
#else
#pragma comment(lib, "../Release/A1Pack_Base.lib")
#endif
//点击加密按钮之后调用A1Pack_Base的代码
if (!A1Pack_Base(m_strPath.GetBuffer(),m_bShowMsg) )
MessageBox(L"加密失败-_-!");
else
MessageBox(L"加密成功!");
4.总结
使用C++编写简单的壳这一章应该是到目前为止最困难的一章了,对于PE文件还不是很熟悉,再加上整个代码比较多,但是理清思路还是不难懂,就是需要查阅一些之前的笔记资料什么的。看到书上后面还有一个专业壳的介绍,等到明天再看吧,今后还得多练习才能巩固这些知识,不能放松!- C++设计一个简单的壳(2)
- C++设计一个简单的壳(1)
- 用C语言设计一个简单计算器
- 简单的一个自定义分页(C#)
- 一个简单的计算器程序(C++)
- 一个简单的计算器(C语言)
- 一个简单的全局HOOK(C++)
- (C#)一个最简单的链表类
- 一个简单的dao设计
- 一个简单的C程序
- 一个简单的C程序
- c 的一个简单题目
- C/C++:递归的一个简单示例
- 一个简单的监控系统的设计
- 《黑客免杀攻防学习笔记》——C++设计一个简单的壳2
- 一个简单的计算器的实现(C++)
- 一个简单的计算器的实现(C++)
- 一个简单的读取bmp文件的程序(c)
- php表单加入Token防止重复提交
- 请不要做浮躁的人(新手必读!)
- 1589-Xiangqi【模拟】
- Ubuntu 12.10 安装 jdk-7u10-linux-x64.tar.gz
- Ubuntu下安装JDK图文解析
- C++设计一个简单的壳(2)
- js判断当前窗口是否有父窗口
- uva 10740 - Not the Best(第k短路)
- Android平台下OpenGL初步
- 状态机在线上录单系统的实际应用例子
- 第三方库 EGOCache
- obj-c编程10:Foundation库中类的使用(6)[线程和操作队列]
- POJ 3723 Conscription(最大生成树)
- oracle查询