zoukankan      html  css  js  c++  java
  • (xxxx)十一:SQLite3的db数据库解密(三)数据库在线备份

       前面两篇文章分别介绍了sqlite数据库句柄和sqlite3_exec函数调用来查找数据库内容。通过这种方式来查询,需要一直hook目标软件。如果目标软件有检测程序,就有可能被检测到。本文分享另一种读取数据库内容的办法:在线备份!

         db文件存储的数据,本质上都是二进制的。db文件由于被加密,在磁盘上的那个文件必须先解密,所以要先找到密钥,这是个麻烦事!通过前面的分享可以看出:执行sqlite3_exec时其实db文件已经解密,所以才能查出来明文!这时的数据库已经存在于内存,sqlite官方提供了备份数据库的整套API(注意:完整的备份功能需要好几个API,不止一个,这也为后续我们自己写代码备份带来了很多麻烦事!),链接在这里: https://www.sqlite.org/backup.html  ,根据官网的接口,自己写一个备份的demo,先在自己本机试试看行不行!,如下:

    #include <stdio.h>
    #include "sqlite3.h"
    
    int backupDb(
        sqlite3* pDb,               /* Database to back up */
        const char* zFilename,      /* Name of file to back up to */
        void(*xProgress)(int, int)  /* Progress function to invoke */
    );
    
    void XProgress(int a, int b);
    
    int main()
    {
        printf("Hello World!
    ");
        sqlite3* db = NULL;
        int result = sqlite3_open("testDB.db", &db);
    
        const char* zFilename = "testDB_back.db";
        backupDb(db, zFilename, XProgress);
    
        sqlite3_close(db);
    }
    
    int backupDb(
        sqlite3* pDb,               /* Database to back up */
        const char* zFilename,      /* Name of file to back up to */
        void(*xProgress)(int, int)  /* Progress function to invoke */
    ) {
        int rc;                     /* Function return code */
        sqlite3* pFile;             /* Database connection opened on zFilename */
        sqlite3_backup* pBackup;    /* Backup handle used to copy data */
    
        /* Open the database file identified by zFilename. */
        rc = sqlite3_open(zFilename, &pFile);
        if (rc == SQLITE_OK) {
    
            /* Open the sqlite3_backup object used to accomplish the transfer */
            pBackup = sqlite3_backup_init(pFile, "main", pDb, "main");
            if (pBackup) {
    
                /* Each iteration of this loop copies 5 database pages from database
                ** pDb to the backup database. If the return value of backup_step()
                ** indicates that there are still further pages to copy, sleep for
                ** 250 ms before repeating. */
                do {
                    rc = sqlite3_backup_step(pBackup, 5);
                    xProgress(
                        sqlite3_backup_remaining(pBackup),
                        sqlite3_backup_pagecount(pBackup)
                    );
                    if (rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED) {
                        sqlite3_sleep(250);
                    }
                } while (rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED);
    
                /* Release resources allocated by backup_init(). */
                (void)sqlite3_backup_finish(pBackup);
            }
            rc = sqlite3_errcode(pFile);
        }
    
        /* Close the database connection opened on database file zFilename
        ** and return the result of this function. */
        (void)sqlite3_close(pFile);
        return rc;
    }
    
    void XProgress(int a, int b)
    {
        printf("%d,%d
    ",a,b);
    }

         确实生成了db文件:用navicat也能顺利查到表和数据,说明备份是成功的!先在新的问题来了:怎么才能在线备份xxxx的db文件了? 从内存备份完整的db文件后,也能用这些专业的软件在本地轻松打开了!

      

          上两篇文章分析了通过openDataBase找到db句柄,在那里hook的话可以顺利得到数据库句柄。但是从上面的备份代码看,还涉及到很多sqlite3开头API的调用,这就麻烦了:

    • 因为在目标进程空间执行,需要这些API在目标进程的地址,而不是上面那么备份demo进程的地址,这就要像上次找sqlite3_exec函数的入口地址一样从新开始查找了,真麻烦!
    • 原xxxx软件大概率是没用到备份功能的,所以这些备份的代码应该是没有的。如果要备份,这些备份的代码都要自己在dll里面添加进去,里面涉及到sqlite3的API函数都要挨个从目标dll里面找到!

          因为IDA分析PE文件时会增加引用、F5反编译等功能,相对OD这种动态调试工具会方便一些,所以这里用IDA静态查找这些关键函数的偏移位置;先用IDA打开关键的dll(这个dll放了很多函数,打开非常慢):dll原本只有27M,经过IDA分析,添加了好多查找功能,最后膨胀到650M了!

           

        下面是需要挨个找的关键函数:

        DWORD address_sqlite3_open = wxBaseAddress + ;
        DWORD address_sqlite3_backup_init = wxBaseAddress + ; 
        DWORD address_sqlite3_backup_step = wxBaseAddress + ; 
        DWORD address_sqlite3_sleep = wxBaseAddress + ;
        DWORD address_sqlite3_backup_finish = wxBaseAddress + ;
        DWORD address_sqlite3_close = wxBaseAddress + ;
        DWORD address_sqlite3_backup_remaining = wxBaseAddress + ;
        DWORD address_sqlite3_backup_pagecount = wxBaseAddress + ;
        DWORD address_sqlite3_errcode = wxBaseAddress + ;
    •         先看第一个sqlite3_open: 前面已经找到了openDataBase的偏移,这里先根据偏移定位到openDataBase调用的地方,然后选择jmp to xref,如下:

         

         这里能看到所有的引用,根据sqlite3_open的源码,其实就是简单粗暴直接调用了openDataBase,所以第4、5两个引用最像(距离最近嘛,偏移只有D和F);先看看这个,和sqlite3.c中的源码极其类似,应该就是它了,先把函数改名标记一下,同时记住其偏移:0xA895B0

         

        同理可以标记出另一个sqlite3_open_v2函数(其实就是紧接着上面这个函数,C语言里面是挨着的,编译器大概率也会挨着翻译成机器码,shellcode也是根据这个原理生成的!后续也会根据这个原理查找其他关键的sqlite3函数),这里不再赘述;

    •      接着看第二个sqlite3_backup_init函数:先在sqlite3.c源文件中找到这个函数   找到了一个比较明显的特征:字符串:source and destination must be distinct

           

           立马在IDA中查找这个字符串:还真找到了!

         

            跳转到引用这里:

          

           和源码一比对,参数是能符合的,应该就是了:call sub_10A083F0应该就是sqlite3ErrorWithMsg了,这里先标记一下;

    sqlite3ErrorWithMsg(
            pDestDb, SQLITE_ERROR, "source and destination must be distinct"
        );

          往上溯源,找到函数入口,把函数名改成sqlite3_backup_init即可,记下这里的偏移:0xA26980

    •     接着找sqlite3_backup_step函数

           从sqlite3.c通读额整个函数,没有找到任何字符串,看来直接用字符串定位是不行的,只能换个思路:要么通过其他函数找(比如这个函数调用了很多其他函数,如果我们先找到了其他函数,就能根据调用关系、顺藤摸瓜找到这个函数了),要么通过机器码定位!;这里我们先用机器码试试。由于是开源的,我们在自己本地先在sqlite3_backup_step函数入口下个断点,再运行,然后转到反汇编,提取一些机器码,比如下面这个:先用前面6个byte的机器码试试;

    #ifdef SQLITE_ENABLE_API_ARMOR
      if( p==0 ) return SQLITE_MISUSE_BKPT;
    #endif
      sqlite3_mutex_enter(p->pSrcDb->mutex);
    010603C0 8B 45 08             mov         eax,dword ptr [p]  
    010603C3 8B 48 14             mov         ecx,dword ptr [eax+14h]  
    010603C6 8B 51 0C             mov         edx,dword ptr [ecx+0Ch]  
    010603C9 52                   push        edx  
    010603CA E8 C8 40 FF FF       call        _sqlite3_mutex_enter (01054497h)  
    010603CF 83 C4 04             add         esp,4  

        在IDA中菜单中选择search->sequence of byte, 输入8B 45 08 8B 48 14,找到了这3个地方:

     

       和C的源码比对,明显不是,放弃;这里暂时没有更好的思路了,暂时放弃,先找其他的函数;

    •        sqlite3_backup_finish:这里面也没找到字符串,还是根据特征码查找。同样先在本地的工程下断点,然后调试;由于没有字符串,特征码也没有匹配上(可能是xxxx用的sqlite版本和我本地的不一样,也有可能是编译器翻译成机器码不一样,总之是没匹配上),和刚才那个一样,暂时方式,继续找其他函数;
    •      sqlite3_sleep: 既然是sleep,肯定涉及到时间的计算;从源码看,有几行比较明显,比如下面的这行:有乘法,先转换成毫秒,再除以1000,所以先根据69 45 08 E8 03 00 00 这一串特征码在IDA中查找:
        rc = (sqlite3OsSleep(pVfs, 1000*ms)/1000);
      0105C66F 69 45 08 E8 03 00 00 imul        eax,dword ptr [ms],3E8h  
      0105C676 50                   push        eax  
      0105C677 8B 4D F8             mov         ecx,dword ptr [pVfs]  
      0105C67A 51                   push        ecx  
      0105C67B E8 D0 94 07 00       call        sqlite3OsSleep (010D5B50h)  
      0105C680 83 C4 08             add         esp,8

       还真找到了:和本地汇编代码比虽说不完全一样,但逻辑结果是一样的;再结合前面的代码对比,就是这里了!记住函数入口的偏移:0xA89C80

            

    •  sqlite3_errcode:从源码看,函数比较简单,没有字符串,但是调用了sqlite3SafetyCheckSickOrOk这个函数;
    SQLITE_API int sqlite3_errcode(sqlite3 *db){
      if( db && !sqlite3SafetyCheckSickOrOk(db) ){
        return SQLITE_MISUSE_BKPT;
      }
      if( !db || db->mallocFailed ){
        return SQLITE_NOMEM_BKPT;
      }
      return db->errCode & db->errMask;
    }

     继续进入sqlite3SafetyCheckSickOrOk函数,发现有invalid字符串了,但是比较短,感觉不够;继续进入logBadConnection函数:

    SQLITE_PRIVATE int sqlite3SafetyCheckSickOrOk(sqlite3 *db){
      u32 magic;
      magic = db->magic;
      if( magic!=SQLITE_MAGIC_SICK &&
          magic!=SQLITE_MAGIC_OPEN &&
          magic!=SQLITE_MAGIC_BUSY ){
        testcase( sqlite3GlobalConfig.xLog!=0 );
        logBadConnection("invalid");
        return 0;
      }else{
        return 1;
      }
    }

        这次就有明显的字符串了:API call with %s database connection pointer

    static void logBadConnection(const char *zType){
      sqlite3_log(SQLITE_MISUSE, 
         "API call with %s database connection pointer",
         zType
      );
    }

       放入IDA搜查,一路跟踪到这里:从参数来看,实锤就是这里了;

        

          先把函数名改了,再往上层层追溯,再和源代码比对,发现基址在这:0xA885D0;

    •     sqlite3_close:从C源码看,是直接调用了sqlite3Close,遂进入sqlite3Close函数,发现了一个字符串:unable to close due to unfinalized;如法炮制,继续用这个字符串在IDA里面找: 从参数和函数调用来看,确实是这里,实锤了!

          

          往上找到函数入口,函数名改为sqlite3Close;这个函数又被调用了好多次,只有标红的这两个最接近入口,和在C源码看到的接近,先看看这两个函数:

         

          第一个:从对比来看应该就是sqlite3_close了,在IDA中标记,并记录下偏移: 0xA871F0;IDA中紧接这下面就是sqlite3_close_v2,也顺便标记下!

         

        至此,还有sqlite3_backup_step、sqlite3_backup_finish、sqlite3_backup_remaining、sqlite3_backup_pagecount 4个函数没找到,原因都一样:(1)没有字符串  (2)特征码没匹配上(可能是xxxx用的sqlite版本和我本地做demo的sqlite不一样,也有可能是编译器翻译成机器码不一样);这该怎么办了?继续从C源码入手,找打了一个新的突破口:

        sqlite3_backup_init已经找到了,剩下这4个函数互相挨着的,sqlite3_backup_step在最前面,sqlite3_backup_pagecount在最后面;sqlite3_backup_init和sqlite3_backup_step之间只间隔了4个函数;前面说过了:编译器会按照顺利编译(可以利用此特性生成shellcode),也就是说这sqlite3_backup_init后面第5个函数很有可能就是sqlite3_backup_step,然后紧接着就是sqlite3_backup_finish、sqlite3_backup_remaining、sqlite3_backup_pagecount;在只读的数据段找到sqlite3_backup_init,如下:

         

             我们顺着先看前两个函数: 这两个函数代码几乎是一样的,不同的仅仅是返回值,分别是[eax+20h]和[eax+24h];没有其他任何代码了,看起来和sqlite3_backup_remaining、sqlite3_backup_pagecount很像,那么这两个是不是了? 就需要进一步验证参数了!

        

        参数类型如下:非常凑巧的是nRemaining偏移在0x20处,nPagecount偏移在0x24h处,那么这里实锤了这两个就是sqlite3_backup_remaining、sqlite3_backup_pagecount;偏移分别是0xA275C0、0xA275D0;

    struct sqlite3_backup {
      sqlite3* pDestDb;        /* Destination database handle */
      Btree *pDest;            /* Destination b-tree file */
      u32 iDestSchema;         /* Original schema cookie in destination */
      int bDestLocked;         /* True once a write-transaction is open on pDest */
    
      Pgno iNext;              /* Page number of the next source page to copy */
      sqlite3* pSrcDb;         /* Source database handle */
      Btree *pSrc;             /* Source b-tree file */
    
      int rc;                  /* Backup process error code */
    
      /* These two variables are set by every call to backup_step(). They are
      ** read by calls to backup_remaining() and backup_pagecount().
      */
      Pgno nRemaining;         /* Number of pages left to copy */
      Pgno nPagecount;         /* Total number of pages to copy */
    
      int isAttached;          /* True once backup has been registered with pager */
      sqlite3_backup *pNext;   /* Next backup associated with source pager */
    };

            先在只剩sqlite3_backup_step、sqlite3_backup_finish这两个函数没找到了;既然这个函数自身没有字符串,特征码也不对,那我们先在源码看看这些函数都在哪些地方被引用了,说不定能从这些引用的函数找到突破口了!很明显红框那个才是引用,其他的都是申明或我们自己的代码;

          

          进入引用,发现一个有趣的现象: 我们还缺的sqlite3_backup_step、sqlite3_backup_finish居然在同一个函数被调用了,这个函数就是sqlite3BtreeCopyFile;也就是说只要找到sqlite3BtreeCopyFile,就找到了我们想要的函数;

     /* 0x7FFFFFFF is the hard limit for the number of pages in a database
      ** file. By passing this as the number of pages to copy to
      ** sqlite3_backup_step(), we can guarantee that the copy finishes 
      ** within a single call (unless an error occurs). The assert() statement
      ** checks this assumption - (p->rc) should be set to either SQLITE_DONE 
      ** or an error code.  */
      sqlite3_backup_step(&b, 0x7FFFFFFF);
      assert( b.rc!=SQLITE_OK );
    
      rc = sqlite3_backup_finish(&b);
      if( rc==SQLITE_OK ){
        pTo->pBt->btsFlags &= ~BTS_PAGESIZE_FIXED;
      }else{
        sqlite3PagerClearCache(sqlite3BtreePager(b.pDest));
      }

        继续查找sqlite3BtreeCopyFile的引用:发现在sqlite3RunVacuum函数内,更让人惊喜的是,这个函数内部有大量的sql查询语句:

    rc = execSqlF(db, pzErrMsg,
          "SELECT sql FROM "%w".sqlite_master"
          " WHERE type='index' AND length(sql)>10",
          zDbMain
      );
      if( rc!=SQLITE_OK ) goto end_of_vacuum;
      db->init.iDb = 0;
    
      /* Loop through the tables in the main database. For each, do
      ** an "INSERT INTO vacuum_db.xxx SELECT * FROM main.xxx;" to copy
      ** the contents to the temporary database.
      */
      rc = execSqlF(db, pzErrMsg,
          "SELECT'INSERT INTO vacuum_db.'||quote(name)"
          "||' SELECT*FROM"%w".'||quote(name)"
          "FROM vacuum_db.sqlite_master "
          "WHERE type='table'AND coalesce(rootpage,1)>0",
          zDbMain
      );
      assert( (db->flags & SQLITE_Vacuum)!=0 );
      db->flags &= ~SQLITE_Vacuum;
      if( rc!=SQLITE_OK ) goto end_of_vacuum;
    
      /* Copy the triggers, views, and virtual tables from the main database
      ** over to the temporary database.  None of these objects has any
      ** associated storage, so all we have to do is copy their entries
      ** from the SQLITE_MASTER table.
      */
      rc = execSqlF(db, pzErrMsg,
          "INSERT INTO vacuum_db.sqlite_master"
          " SELECT*FROM "%w".sqlite_master"
          " WHERE type IN('view','trigger')"
          " OR(type='table'AND rootpage=0)",
          zDbMain
      );
      if( rc ) goto end_of_vacuum;
    
      /* At this point, there is a write transaction open on both the 
      ** vacuum database and the main database. Assuming no error occurs,
      ** both transactions are closed by this block - the main database
      ** transaction by sqlite3BtreeCopyFile() and the other by an explicit
      ** call to sqlite3BtreeCommit().
      */
      {
        u32 meta;
        int i;
    
        /* This array determines which meta meta values are preserved in the
        ** vacuum.  Even entries are the meta value number and odd entries
        ** are an increment to apply to the meta value after the vacuum.
        ** The increment is used to increase the schema cookie so that other
        ** connections to the same database will know to reread the schema.
        */
        static const unsigned char aCopy[] = {
           BTREE_SCHEMA_VERSION,     1,  /* Add one to the old schema cookie */
           BTREE_DEFAULT_CACHE_SIZE, 0,  /* Preserve the default page cache size */
           BTREE_TEXT_ENCODING,      0,  /* Preserve the text encoding */
           BTREE_USER_VERSION,       0,  /* Preserve the user version */
           BTREE_APPLICATION_ID,     0,  /* Preserve the application id */
        };
    
        assert( 1==sqlite3BtreeIsInTrans(pTemp) );
        assert( 1==sqlite3BtreeIsInTrans(pMain) );
    
        /* Copy Btree meta values */
        for(i=0; i<ArraySize(aCopy); i+=2){
          /* GetMeta() and UpdateMeta() cannot fail in this context because
          ** we already have page 1 loaded into cache and marked dirty. */
          sqlite3BtreeGetMeta(pMain, aCopy[i], &meta);
          rc = sqlite3BtreeUpdateMeta(pTemp, aCopy[i], meta+aCopy[i+1]);
          if( NEVER(rc!=SQLITE_OK) ) goto end_of_vacuum;
        }
    
        rc = sqlite3BtreeCopyFile(pMain, pTemp);

         继续如法炮制,根据这些语句先找到sqlite3RunVacuum函数,再进一步找到sqlite3BtreeCopyFile函数(从源码看,这个函数调用了memset,这也是比较明显的特征之一);

    text:10A276E8 E8 13 FA 86 00                          call    _memset
    .text:10A276ED 8B 07                                   mov     eax, [edi]
    .text:10A276EF 83 C4 0C                                add     esp, 0Ch
    .text:10A276F2 89 45 DC                                mov     [ebp+var_24], eax
    .text:10A276F5 8B 47 04                                mov     eax, [edi+4]
    .text:10A276F8 89 7D E0                                mov     [ebp+var_20], edi
    .text:10A276FB 89 75 CC                                mov     [ebp+var_34], esi
    .text:10A276FE C7 45 D8 01 00 00 00                    mov     [ebp+var_28], 1
    .text:10A27705 8B 08                                   mov     ecx, [eax]
    .text:10A27707 8B 46 04                                mov     eax, [esi+4]
    .text:10A2770A 8B 10                                   mov     edx, [eax]
    .text:10A2770C 0F B7 81 8E 00 00 00                    movzx   eax, word ptr [ecx+8Eh]
    .text:10A27713 66 39 82 8E 00 00 00                    cmp     [edx+8Eh], ax
    .text:10A2771A 74 24                                   jz      short loc_10A27740
    .text:10A2771C 8B 8A D4 00 00 00                       mov     ecx, [edx+0D4h]
    .text:10A27722 66 89 82 8E 00 00 00                    mov     [edx+8Eh], ax
    .text:10A27729 85 C9                                   test    ecx, ecx
    .text:10A2772B 74 13                                   jz      short loc_10A27740
    .text:10A2772D 98                                      cwde
    .text:10A2772E 50                                      push    eax
    .text:10A2772F FF B2 98 00 00 00                       push    dword ptr [edx+98h]
    .text:10A27735 FF B2 DC 00 00 00                       push    dword ptr [edx+0DCh]
    .text:10A2773B FF D1                                   call    ecx
    .text:10A2773D 83 C4 0C                                add     esp, 0Ch
    .text:10A27740
    .text:10A27740                         loc_10A27740:                           ; CODE XREF: sqlite3BtreeCopyFile+BA↑j
    .text:10A27740                                                                 ; sqlite3BtreeCopyFile+CB↑j
    .text:10A27740 8D 45 C8                                lea     eax, [ebp+var_38]
    .text:10A27743 68 FF FF FF 7F                          push    7FFFFFFFh
    .text:10A27748 50                                      push    eax
    .text:10A27749 E8 E2 F5 FF FF                          call    sub_10A26D30
    .text:10A2774E 8D 45 C8                                lea     eax, [ebp+var_38]
    .text:10A27751 50                                      push    eax
    .text:10A27752 E8 69 FD FF FF                          call    sub_10A274C0

       再对比源码,根据0x7FFFFFFF很容易找到sqlite3_backup_step和sqlite3_backup_finish,偏移分别是0xA26D30、0xA274C0

     memset(&b, 0, sizeof(b));
      b.pSrcDb = pFrom->db;
      b.pSrc = pFrom;
      b.pDest = pTo;
      b.iNext = 1;
    
    #ifdef SQLITE_HAS_CODEC
      sqlite3PagerAlignReserve(sqlite3BtreePager(pTo), sqlite3BtreePager(pFrom));
    #endif
    
      /* 0x7FFFFFFF is the hard limit for the number of pages in a database
      ** file. By passing this as the number of pages to copy to
      ** sqlite3_backup_step(), we can guarantee that the copy finishes 
      ** within a single call (unless an error occurs). The assert() statement
      ** checks this assumption - (p->rc) should be set to either SQLITE_DONE 
      ** or an error code.  */
      sqlite3_backup_step(&b, 0x7FFFFFFF);
      assert( b.rc!=SQLITE_OK );
    
      rc = sqlite3_backup_finish(&b);

       至此,所有关键函数的偏移都已经找到,总结如下:

        //.text:10A895B0
        DWORD address_sqlite3_open = wxBaseAddress + 0xA895B0;
        //.text:10A26980 
        DWORD address_sqlite3_backup_init = wxBaseAddress + 0xA26980;
        //.text:10A89C80
        DWORD address_sqlite3_sleep = wxBaseAddress + 0xA89C80;
        //.text:10A871F0 
        DWORD address_sqlite3_close = wxBaseAddress + 0xA871F0;
        //.text:10A26D30
        DWORD address_sqlite3_backup_step = wxBaseAddress + 0xA26D30;
        //.text:10A274C0 
        DWORD address_sqlite3_backup_finish = wxBaseAddress + 0xA274C0;
        //.text:10A275C0
        DWORD address_sqlite3_backup_remaining = wxBaseAddress + 0xA275C0;
        //.text:10A275D0
        DWORD address_sqlite3_backup_pagecount = wxBaseAddress + 0xA275D0;
        //.text:10A885D0
        DWORD address_sqlite3_errcode = wxBaseAddress + 0xA885D0;

       效果展示:能hook到所有的db数据库:

       

       选择msg0导出,然后放入sqlite export:所有表、字段和数据都能看到了!用户的隐私荡然无存!

       

    最后,做了这么久的逆向,自己总结的要点如下:关于调试和反调试,xxxx逆向时并未遇到,后续逆向过TP时再分享!

    参考:

    1、https://github.com/zmrbak  2019 PC xxxx探秘/SQLite_L37; 注意:不同版本中函数的偏移是不一样的,不能直接照抄,需要重新找偏移!

  • 相关阅读:
    hdu1238 Substrings
    CCF试题:高速公路(Targin)
    hdu 1269 迷宫城堡(Targin算法)
    hdu 1253 胜利大逃亡
    NYOJ 55 懒省事的小明
    HDU 1024 Max Sum Plus Plus
    HDU 1087 Super Jumping! Jumping! Jumping!
    HDU 1257 最少拦截系统
    HDU 1069 Monkey and Banana
    HDU 1104 Remainder
  • 原文地址:https://www.cnblogs.com/theseventhson/p/14492894.html
Copyright © 2011-2022 走看看