Dalvik下一代殼通用解決方案
1. 追根溯源dvmDexFileOpenPartial脫殼點
首先,我們需要知道,Dex載入最後的邏輯實現幾乎都是在BaseDexClassLoader
// http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java#29
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
...
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
//這裡就是設定parent的地方
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
...
}
然後進入到DexPathList
// http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java#85
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
...
this.definingContext = definingContext;
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions);
...
}
再到 makeDexElements,它是建立Dex檔案的真正方法.
//http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java#makeDexElements
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory,
ArrayList<IOException> suppressedExceptions) {
ArrayList<Element> elements = new ArrayList<Element>();
/*
* Open all files and load the (direct or contained) dex files
* up front.
*/
for (File file : files) {
File zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ex) {
System.logE("Unable to load dex file: " + file, ex);
}
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
...
} else if (file.isDirectory()) {
...
} else {
System.logW("Unknown file type for: " + file);
}
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, false, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
makeDexElements其實就是不斷的去files中取出file,然後構建Element新增到elements中我們的dex檔案,緊接著執行loadDexFile。去看看 loadDexFile方法.
// http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java#loadDexFile
private static DexFile loadDexFile(File file, File optimizedDirectory)
throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0);
}
}
繼續跟蹤loadDex方法.
// http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/DexFile.java#141
static public DexFile loadDex(String sourcePathName, String outputPathName,
int flags) throws IOException {
/*
* TODO: we may want to cache previously-opened DexFile objects.
* The cache would be synchronized with close(). This would help
* us avoid mapping the same DEX more than once when an app
* decided to open it multiple times. In practice this may not
* be a real issue.
*/
return new DexFile(sourcePathName, outputPathName, flags);
}
// http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/DexFile.java#97
private DexFile(String sourceName, String outputName, int flags) throws IOException {
...
mCookie = openDexFile(sourceName, outputName, flags);
mFileName = sourceName;
guard.open("close");
}
看到openDexFile就猜得到,這是開啟dex檔案的方法.
// http://androidxref.com/4.4.2_r2/xref/libcore/dalvik/src/main/java/dalvik/system/DexFile.java#openDexFile
// sourceName : 載入路徑
// outputName : 輸出路徑
// flags : 0
private static int openDexFile(String sourceName, String outputName,
int flags) throws IOException {
return openDexFileNative(new File(sourceName).getCanonicalPath(),
(outputName == null) ? null : new File(outputName).getCanonicalPath(),
flags);
}
native private static int openDexFileNative(String sourceName, String outputName,
int flags) throws IOException;
然後看到openDexFileNative
也就知道是個native方法了. 所以我們需要去看c++程式碼。Android原始碼中native檔案命名就是以native方法所在路徑命名的
//http://androidxref.com/4.4.4_r1/xref/dalvik/vm/native/dalvik_system_DexFile.cpp
static void Dalvik_dalvik_system_DexFile_openDexFileNative(const u4* args,
JValue* pResult)
{
...
/*
* Try to open it directly as a DEX if the name ends with ".dex".
* If that fails (or isn't tried in the first place), try it as a
* Zip with a "classes.dex" inside.
*/
if (hasDexExtension(sourceName)
&& dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) {
ALOGV("Opening DEX file '%s' (DEX)", sourceName);
pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
pDexOrJar->isDex = true;
pDexOrJar->pRawDexFile = pRawDexFile;
pDexOrJar->pDexMemory = NULL;
} else if (dvmJarFileOpen(sourceName, outputName, &pJarFile, false) == 0) {
...
} else {
...
}
...
}
hasDexExtension判斷檔案是否是dex字尾,dvmRawDexFileOpen就是去開啟dex檔案了
//http://androidxref.com/4.4.2_r2/xref/dalvik/vm/RawDexFile.cpp#109
int dvmRawDexFileOpen(const char* fileName, const char* odexOutputName,
RawDexFile** ppRawDexFile, bool isBootstrap)
{
...
dexFd = open(fileName, O_RDONLY);
...
//生成優化後的dex檔案路徑
if (odexOutputName == NULL) {
cachedName = dexOptGenerateCacheFileName(fileName, NULL);
if (cachedName == NULL)
goto bail;
} else {
cachedName = strdup(odexOutputName);
}
...
if (newFile) {
u8 startWhen, copyWhen, endWhen;
bool result;
off_t dexOffset;
dexOffset = lseek(optFd, 0, SEEK_CUR);
result = (dexOffset > 0);
if (result) {
startWhen = dvmGetRelativeTimeUsec();
result = copyFileToFile(optFd, dexFd, fileSize) == 0;
copyWhen = dvmGetRelativeTimeUsec();
}
if (result) {
//進入對dex檔案的優化流程中
result = dvmOptimizeDexFile(optFd, dexOffset, fileSize,
fileName, modTime, adler32, isBootstrap);
}
if (!result) {
ALOGE("Unable to extract+optimize DEX from '%s'", fileName);
goto bail;
}
endWhen = dvmGetRelativeTimeUsec();
ALOGD("DEX prep '%s': copy in %dms, rewrite %dms",
fileName,
(int) (copyWhen - startWhen) / 1000,
(int) (endWhen - copyWhen) / 1000);
}
...
return result;
}
來關注一下優化dex的函式dvmOptimizeDexFile
// http://androidxref.com/4.4.2_r2/xref/dalvik/vm/analysis/DexPrepare.cpp#351
bool dvmOptimizeDexFile(int fd, off_t dexOffset, long dexLength,
const char* fileName, u4 modWhen, u4 crc, bool isBootstrap)
{
const char* lastPart = strrchr(fileName, '/');
if (lastPart != NULL)
lastPart++;
else
lastPart = fileName;
ALOGD("DexOpt: --- BEGIN '%s' (bootstrap=%d) ---", lastPart, isBootstrap);
pid_t pid;
/*
* This could happen if something in our bootclasspath, which we thought
* was all optimized, got rejected.
*/
if (gDvm.optimizing) {
ALOGW("Rejecting recursive optimization attempt on '%s'", fileName);
return false;
}
pid = fork();
if (pid == 0) {
static const int kUseValgrind = 0;
//需要呼叫優化的程式dexopt
static const char* kDexOptBin = "/bin/dexopt";
static const char* kValgrinder = "/usr/bin/valgrind";
static const int kFixedArgCount = 10;
static const int kValgrindArgCount = 5;
static const int kMaxIntLen = 12; // '-'+10dig+'\0' -OR- 0x+8dig
int bcpSize = dvmGetBootPathSize();
int argc = kFixedArgCount + bcpSize
+ (kValgrindArgCount * kUseValgrind);
const char* argv[argc+1]; // last entry is NULL
char values[argc][kMaxIntLen];
char* execFile;
const char* androidRoot;
int flags;
/* change process groups, so we don't clash with ProcessManager */
setpgid(0, 0);
/* full path to optimizer */
androidRoot = getenv("ANDROID_ROOT");
if (androidRoot == NULL) {
ALOGW("ANDROID_ROOT not set, defaulting to /system");
androidRoot = "/system";
}
execFile = (char*)alloca(strlen(androidRoot) + strlen(kDexOptBin) + 1);
strcpy(execFile, androidRoot);
strcat(execFile, kDexOptBin);
/*
* Create arg vector.
*/
int curArg = 0;
if (kUseValgrind) {
/* probably shouldn't ship the hard-coded path */
argv[curArg++] = (char*)kValgrinder;
argv[curArg++] = "--tool=memcheck";
argv[curArg++] = "--leak-check=yes"; // check for leaks too
argv[curArg++] = "--leak-resolution=med"; // increase from 2 to 4
argv[curArg++] = "--num-callers=16"; // default is 12
assert(curArg == kValgrindArgCount);
}
//拼接命令
argv[curArg++] = execFile;
argv[curArg++] = "--dex";
sprintf(values[2], "%d", DALVIK_VM_BUILD);
argv[curArg++] = values[2];
sprintf(values[3], "%d", fd);
argv[curArg++] = values[3];
sprintf(values[4], "%d", (int) dexOffset);
argv[curArg++] = values[4];
sprintf(values[5], "%d", (int) dexLength);
argv[curArg++] = values[5];
argv[curArg++] = (char*)fileName;
sprintf(values[7], "%d", (int) modWhen);
argv[curArg++] = values[7];
sprintf(values[8], "%d", (int) crc);
argv[curArg++] = values[8];
...
if (kUseValgrind)
execv(kValgrinder, const_cast<char**>(argv));
else
//這裡執行了/bin/dexopt程式
execv(execFile, const_cast<char**>(argv));
ALOGE("execv '%s'%s failed: %s", execFile,
kUseValgrind ? " [valgrind]" : "", strerror(errno));
exit(1);
} else {
...
}
}
此函式中會拼接命令列,然後呼叫execv是執行/bin/dexopt程式等待此程式去優化dex檔案
它的原始碼就在/dalvik/dexopt/OptMain.cpp下
它的main函式如下
// http://androidxref.com/4.4.2_r2/xref/dalvik/dexopt/OptMain.cpp
int main(int argc, char* const argv[])
{
...
if (argc > 1) {
if (strcmp(argv[1], "--zip") == 0)
return fromZip(argc, argv);
else if (strcmp(argv[1], "--dex") == 0)
return fromDex(argc, argv);
else if (strcmp(argv[1], "--preopt") == 0)
return preopt(argc, argv);
}
...
return 1;
}
此處肯定是呼叫了fromDex函式了
// http://androidxref.com/4.4.2_r2/xref/dalvik/dexopt/OptMain.cpp#fromDex
static int fromDex(int argc, char* const argv[])
{
...
/* do the optimization */
if (!dvmContinueOptimization(fd, offset, length, debugFileName,
modWhen, crc, (flags & DEXOPT_IS_BOOTSTRAP) != 0))
{
ALOGE("Optimization failed");
goto bail;
}
result = 0;
...
}
關注dvmContinueOptimization
bool dvmContinueOptimization(int fd, off_t dexOffset, long dexLength,
const char* fileName, u4 modWhen, u4 crc, bool isBootstrap)
{
...
{
/*
* Map the entire file (so we don't have to worry about page
* alignment). The expectation is that the output file contains
* our DEX data plus room for a small header.
*/
bool success;
void* mapAddr;
//對當前dex進行記憶體對映
mapAddr = mmap(NULL, dexOffset + dexLength, PROT_READ|PROT_WRITE,
MAP_SHARED, fd, 0);
...
//重寫dex
success = rewriteDex(((u1*) mapAddr) + dexOffset, dexLength,
doVerify, doOpt, &pClassLookup, NULL);
if (success) {
DvmDex* pDvmDex = NULL;
u1* dexAddr = ((u1*) mapAddr) + dexOffset;
if (dvmDexFileOpenPartial(dexAddr, dexLength, &pDvmDex) != 0) {
ALOGE("Unable to create DexFile");
success = false;
} else {
/*
* If configured to do so, generate register map output
* for all verified classes. The register maps were
* generated during verification, and will now be serialized.
*/
if (gDvm.generateRegisterMaps) {
pRegMapBuilder = dvmGenerateRegisterMaps(pDvmDex);
if (pRegMapBuilder == NULL) {
ALOGE("Failed generating register maps");
success = false;
}
}
DexHeader* pHeader = (DexHeader*)pDvmDex->pHeader;
updateChecksum(dexAddr, dexLength, pHeader);
dvmDexFileFree(pDvmDex);
}
}
...
}
...
return result;
}
發揮一目十行的能力, 看到rewriteDex
// http://androidxref.com/4.4.2_r2/xref/dalvik/vm/analysis/DexPrepare.cpp#rewriteDex
/**
第一個引數:dex的起始地址
第二個引數:dex的位元組數
**/
static bool rewriteDex(u1* addr, int len, bool doVerify, bool doOpt,
DexClassLookup** ppClassLookup, DvmDex** ppDvmDex)
{
DexClassLookup* pClassLookup = NULL;
u8 prepWhen, loadWhen, verifyOptWhen;
DvmDex* pDvmDex = NULL;
bool result = false;
const char* msgStr = "???";
/* if the DEX is in the wrong byte order, swap it now */
if (dexSwapAndVerify(addr, len) != 0)
goto bail;
/*
* Now that the DEX file can be read directly, create a DexFile struct
* for it.
*/
//此函式也有我們需要的dex的起始地址和位元組數
if (dvmDexFileOpenPartial(addr, len, &pDvmDex) != 0) {
ALOGE("Unable to create DexFile");
goto bail;
}
...
result = true;
bail:
...
return result;
}
這個函式就有我們想要的東西了,就是dex的起始地址和dex的位元組數
其中dvmDexFileOpenPartial方法就是一個脫殼點,其程式碼如下
// http://androidxref.com/4.4.2_r2/xref/dalvik/vm/DvmDex.cpp#146
int dvmDexFileOpenPartial(const void* addr, int len, DvmDex** ppDvmDex)
{
...
pDexFile = dexFileParse((u1*)addr, len, parseFlags);
...
result = 0;
bail:
return result;
}
於是乎我們可以修改這邊的程式碼為:
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int dvmDexFileOpenPartial(const void* addr, int len, DvmDex** ppDvmDex)
{
char dexfilepath[100] = {0};
int pid = getpid();
sprintf(dexfilepath,"/sdcard/%d_%d_dvmDexFileOpenPartial.dex",len,pid);
//fopen
int fd = open(dexfilepath,O_CREAT|O_RDWR,0666);
if (fd>0){
write(fd,addr,len);
close(fd);
}
DvmDex* pDvmDex;
DexFile* pDexFile;
int parseFlags = kDexParseDefault;
int result = -1;
/* -- file is incomplete, new checksum has not yet been calculated
if (gDvm.verifyDexChecksum)
parseFlags |= kDexParseVerifyChecksum;
*/
pDexFile = dexFileParse((u1*)addr, len, parseFlags);
if (pDexFile == NULL) {
ALOGE("DEX parse failed");
goto bail;
}
pDvmDex = allocateAuxStructures(pDexFile);
if (pDvmDex == NULL) {
dexFileFree(pDexFile);
goto bail;
}
pDvmDex->isMappedReadOnly = false;
*ppDvmDex = pDvmDex;
result = 0;
bail:
return result;
}
2. 追根溯源dexFileParse脫殼點
// http://androidxref.com/4.4.2_r2/xref/dalvik/vm/DvmDex.cpp#146
int dvmDexFileOpenPartial(const void* addr, int len, DvmDex** ppDvmDex)
{
...
pDexFile = dexFileParse((u1*)addr, len, parseFlags);
...
result = 0;
bail:
return result;
}
關注dexFileParse, 它也能作為一個脫殼點.
// http://androidxref.com/4.4.2_r2/xref/dalvik/libdex/DexFile.cpp#289
DexFile* dexFileParse(const u1* data, size_t length, int flags)
{
DexFile* pDexFile = NULL;
const DexHeader* pHeader;
const u1* magic;
int result = -1;
if (length < sizeof(DexHeader)) {
ALOGE("too short to be a valid .dex");
goto bail; /* bad file format */
}
...
}
修改這邊的程式碼為:
DexFile* dexFileParse(const u1* data, size_t length, int flags)
{
char dexfilepath[100] = {0};
int pid = getpid();
sprintf(dexfilepath,"/sdcard/%d_%d_dexFileParse.dex",length,pid);
//fopen
int fd = open(dexfilepath,O_CREAT|O_RDWR,0666);
if (fd>0){
write(fd,data,length);
close(fd);
}
DexFile* pDexFile = NULL;
const DexHeader* pHeader;
const u1* magic;
int result = -1;
if (length < sizeof(DexHeader)) {
ALOGE("too short to be a valid .dex");
goto bail; /* bad file format */
}
pDexFile = (DexFile*) malloc(sizeof(DexFile));
if (pDexFile == NULL)
goto bail; /* alloc failure */
memset(pDexFile, 0, sizeof(DexFile));
/*
* Peel off the optimized header.
*/
if (memcmp(data, DEX_OPT_MAGIC, 4) == 0) {
magic = data;
if (memcmp(magic+4, DEX_OPT_MAGIC_VERS, 4) != 0) {
ALOGE("bad opt version (0x%02x %02x %02x %02x)",
magic[4], magic[5], magic[6], magic[7]);
goto bail;
}
pDexFile->pOptHeader = (const DexOptHeader*) data;
ALOGV("Good opt header, DEX offset is %d, flags=0x%02x",
pDexFile->pOptHeader->dexOffset, pDexFile->pOptHeader->flags);
/* parse the optimized dex file tables */
if (!dexParseOptData(data, length, pDexFile))
goto bail;
/* ignore the opt header and appended data from here on out */
data += pDexFile->pOptHeader->dexOffset;
length -= pDexFile->pOptHeader->dexOffset;
if (pDexFile->pOptHeader->dexLength > length) {
ALOGE("File truncated? stored len=%d, rem len=%d",
pDexFile->pOptHeader->dexLength, (int) length);
goto bail;
}
length = pDexFile->pOptHeader->dexLength;
}
dexFileSetupBasicPointers(pDexFile, data);
pHeader = pDexFile->pHeader;
if (!dexHasValidMagic(pHeader)) {
goto bail;
}
/*
* Verify the checksum(s). This is reasonably quick, but does require
* touching every byte in the DEX file. The base checksum changes after
* byte-swapping and DEX optimization.
*/
if (flags & kDexParseVerifyChecksum) {
u4 adler = dexComputeChecksum(pHeader);
if (adler != pHeader->checksum) {
ALOGE("ERROR: bad checksum (%08x vs %08x)",
adler, pHeader->checksum);
if (!(flags & kDexParseContinueOnError))
goto bail;
} else {
ALOGV("+++ adler32 checksum (%08x) verified", adler);
}
const DexOptHeader* pOptHeader = pDexFile->pOptHeader;
if (pOptHeader != NULL) {
adler = dexComputeOptChecksum(pOptHeader);
if (adler != pOptHeader->checksum) {
ALOGE("ERROR: bad opt checksum (%08x vs %08x)",
adler, pOptHeader->checksum);
if (!(flags & kDexParseContinueOnError))
goto bail;
} else {
ALOGV("+++ adler32 opt checksum (%08x) verified", adler);
}
}
}
/*
* Verify the SHA-1 digest. (Normally we don't want to do this --
* the digest is used to uniquely identify the original DEX file, and
* can't be computed for verification after the DEX is byte-swapped
* and optimized.)
*/
if (kVerifySignature) {
unsigned char sha1Digest[kSHA1DigestLen];
const int nonSum = sizeof(pHeader->magic) + sizeof(pHeader->checksum) +
kSHA1DigestLen;
dexComputeSHA1Digest(data + nonSum, length - nonSum, sha1Digest);
if (memcmp(sha1Digest, pHeader->signature, kSHA1DigestLen) != 0) {
char tmpBuf1[kSHA1DigestOutputLen];
char tmpBuf2[kSHA1DigestOutputLen];
ALOGE("ERROR: bad SHA1 digest (%s vs %s)",
dexSHA1DigestToStr(sha1Digest, tmpBuf1),
dexSHA1DigestToStr(pHeader->signature, tmpBuf2));
if (!(flags & kDexParseContinueOnError))
goto bail;
} else {
ALOGV("+++ sha1 digest verified");
}
}
if (pHeader->fileSize != length) {
ALOGE("ERROR: stored file size (%d) != expected (%d)",
(int) pHeader->fileSize, (int) length);
if (!(flags & kDexParseContinueOnError))
goto bail;
}
if (pHeader->classDefsSize == 0) {
ALOGE("ERROR: DEX file has no classes in it, failing");
goto bail;
}
/*
* Success!
*/
result = 0;
bail:
if (result != 0 && pDexFile != NULL) {
dexFileFree(pDexFile);
pDexFile = NULL;
}
return pDexFile;
}
3.編譯系統.
這邊我使用的是ubuntu16.04的系統. 具體其他配置就不多說了.
操作步驟:
- 設定環境 [source build/envsetup.sh]
- 選擇目標 [lunch]
- 這裡編譯的是nexus5, 所以選擇 7 (aosp_hammerhead-userdebug) [7]
- 編譯程式碼 -j4 意味著4執行緒, 根據自己cpu而定. [time make -j4]
4. 刷機
編譯出來的檔案在 ~/SourceCode/Android-4.4.4_r1/out/target/product/hammerhead
拿出 boot.img/cache.img/system.img/userdata.img
然後執行命令
fastboot flash boot boot.img
fastboot flash cache cache.img
fastboot flash system system.img
fastboot flash userdata userdata.img
相關文章
- 以殼解殼――ASProtect 1.23RC4殼的Stolen Code簡便解決方案
- Startalk(星語)——通用通訊解決方案
- 多欄位登入通用解決方案
- 通用模板解決方案,提升影片生產效率
- SOFAMesh中的多協議通用解決方案x-protocol介紹系列(1) : DNS通用定址方案協議ProtocolDNS
- SOFAMesh中的多協議通用解決方案x-protocol介紹系列(1):DNS通用定址方案協議ProtocolDNS
- 分散式事務概述及大廠通用解決方案分散式
- ETL通用解決方案---oracle+儲存過程 實現Oracle儲存過程
- 基於Vue構造器建立Form元件的通用解決方案VueORM元件
- 使用150行SQL建立PostgreSQL通用審計解決方案 - supabaseSQL
- iPhone X 適配手Q H5 頁面通用解決方案iPhoneH5
- Oracle RAC遷移至南大通用GBase 8c 解決方案Oracle
- Android加殼過程中mprotect呼叫失敗的原因及解決方案Android
- 前端的批量介面如何快速響應?有沒有通用解決方案?前端
- C# 資料庫併發的解決方案(通用版、EF版)C#資料庫
- 解決問題通用方法論
- java 遇到NoSuchMethodError通用解決思路JavaError
- 解決方案| anyRTC金融音視訊解決方案
- 一套通用的企業級中後臺前端設計解決方案前端
- BottomNavigationView的通用修改記錄(新解決方案)NavigationView
- 以殼解殼--SourceRescuer脫殼手記破解分析
- 銳動影片SDK在金融業務加密雙錄管理系統通用解決方案加密
- LAMP解決方案LAMP
- 高併發解決方案詳解(9大常見解決方案)
- Armadillo殼時間問題的解決And脫殼――Mr.Captor V2.8APT
- 使用ruby過程中遇到安裝gem失敗的一些通用解決方案
- ios不支援fixed解決解決方案iOS
- 前端整合解決方案前端
- 高可用解決方案
- UnexpectedRollbackException解決方案Exception
- Feast on Amazon 解決方案AST
- 埠占用解決方案
- 經緯恆潤遠端診斷車雲解決方案——下一代診斷技術
- API安全建設指南 | 綠盟下一代Web應用及API安全防護解決方案APIWeb
- 從單機到分散式微服務,大檔案校驗上傳的通用解決方案分散式微服務
- 智慧停車場解決方案,反向尋車系統解決方案
- .Net2.0通用反射脫殼機完整版反射
- 跨域問題,解決方案 – CORS方案跨域CORS