Watch & Learn

Debugwar Blog

To be or not to be, this is a problem ...

逆向群晖NoteStation用于博客后端

2021-06-23 08:47:54


笔者的博客情节


从高中时代开始,笔者就开始以各种各样的姿势折腾自己的博客:一开始用现成的博客网站,然后开始折腾wordpress一类的博客系统,再后来开始自己用各种语言写博客系统……

总之,不是在折腾就是在去折腾的路上(惭愧的是博文没写几篇……)。然而,折腾了这么久,始终总有这样或者那样的不满意:楼主遇见过博客网站倒闭,wordpress的主题和功能不满意,自己写的系统要托管到vps上……

其实,上面说的问题总结一下,无非是如下几个需求:

  • 基础设施要稳定可靠
  • 博客系统要完全可控
  • 写作要方便

恰好参加工作后,入手了一台群晖的NAS。有个笑话说,其实群晖卖NAS硬件只是交个朋友,真正值钱的是DSM系统,不得不说DSM的确非常好用,不仅有丰富的应用,而且支持和生态也都建设的很到位。

其实一开始笔者是用Evernote国际版的,后来Evernote对免费用户越来越不友好逼得笔者只好弃坑,再后来辗转过Onenote、有道云笔记、为知笔记等等,最终都因为这样那样的问题而放弃,直到用到了DSM的NoteStation。

先来看看NoteStation的优势吧(以笔者的需求来评价是否是优势):

  • 优秀的多平台支持(尤其是Linux和Android平台)
  • 有一个类似Evernote的Chrome插件(这个插件可以用来快速保存网页,简直是转载神器)
  • 私有云(基础设施完全可控)

回头看一下上面的优势和对博客的需求会发现,契合度其实非常的高。然而NoteStation有个问题:这玩意是闭源的,不能二次开发。

笔者一度想用NoteStation作为博客后台进行写作,然而也是因为这东西闭源一直没有将想法落地,直到我看到了这篇1文章,笔者强烈建议各位在继续阅读之前大概看一下这篇文章。

在看过上述提到的文章1后,笔者基本了解了NoteStation的文档组织方式,接下来便是逐步落地最初的"用NoteStation搭建一套博客系统"的想法。

编码问题


在落实的过程中,笔者遇到一个关键问题,先来看一下笔记的存储结构:


上面截图中的metatext.jsonbasic.json等都是存储一些笔记的基本信息用的,比较诡异的是version/text这个目录下的东西,本身这个目录下的文件是存储笔记正文内容用的,但是这个{XX}的形式是个什么鬼?而且整个文件名部分看起来像是某种特殊的编码,最重要的是这个编码应该是私有编码。

如果不能理解这个编码,后续的博客后台代码将无法“优雅”的实现——总不能硬编码到程序里面吧。好不容易弄清了NoteStation的文件组织结构,难道因为这么一个小小的点放弃笔者多年以来的想法吗?当然答案各位看官肯定已经知道了,不然哪来的这篇博文。

编码问题的解决


DSM系统本质上还是一个网站,凡是网站无非就是:WebServer + App的模式,而NoteStation作为DSM这个“网站”的一个App,自然需要有这个App的服务程序。

稍微对DSM做一下研究即可知道,DSM中大部分的App都是以cgi的方式实现的(为什么要用cgi可能是因为闭源需求吧,不然php这种约等于公开),DSM本身是一套运行在*nix系统上的Web服务,NoteStation以cgi应用的方式和用户交互。

因此,可以先看一下so文件(*nix系统上的可执行程序)的情况。在DSM一开始安装的时候,需要指定一个安装盘,换句话说,所有的DSM应用都会被安装在这个盘下面(笔者这里为/volume2),先用如下命令看一下NoteStation的安装目录下,有哪些so文件:

  1. find ./ -type f -iname '*\.so*'  

结果如下:


通过目录,我们基本可以猜到,webapi下的文件可能主要是用于提供和用户的Web页面交互,lib目录下的文件主要提供各种基础功能的支持。

然而这并没有什么卵用——我们需要知道上文中提到的神秘编码是如何解码的。

其实也不是毫无头绪,至少我们知道程序要处理version/text目录下的文件,version/text就是我们的一个突破口。

由于群晖的系统缺少很多基础命令,因此笔者将文件打了个包一起拽了下来,打包命令如上图最后一行:

  1. find ./ -type f -iname '*\.so*' -exec cp {} /volume1/Documents/Sos \;  

然后使用如下命令,提取所有so文件内的可见字符串,并将包含text/的字符串过滤了出来:

  1. strings -f * | grep -Pi 'text/'  

结果如下:


运气比较好,并没有几个文件。而且上面说的webapilib两个目录其实还是有点卵用的,基本上我们只需要看libsynodrive.so.6.0这个文件就行了,另外几个文件其实都是和HTTP相关的文本(而且这几个文件都在webapi目录下)。

祭出万能的IDA,shift+f12提取字符串,定位到/text/*处:


有两处引用,经过观察sub_583a0+9C处的引用不是我们需要的,现在只剩下一处了,简直爽歪歪。来到这处调用处,发现是给pattern的一个赋值(下图中的171行):


接着追踪pattern的交叉引用发现上图中的303行是关键位置,因为v107通过std::string::assign赋值给了v129,而306行的SYNODriveDecode函数引用了v129变量,SYNODriveDecode这个函数怎么看都像是能回答我们如何解密神秘编码的函数:

  1. __int64 __fastcall SYNODriveDecode(const std::string *a1, unsigned __int8 *a2, size_t a3, char a4)  
  2. {  
  3.   // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]  
  4.   
  5.   v4 = *(const char **)a1;  
  6.   v5 = std::string::_Rep::_S_empty_rep_storage;  
  7.   n[0] = a3;  
  8.   v6 = *((_QWORD *)v4 - 3);  
  9.   v27[0] = (__int64)off_2E7F50 + 24;  
  10.   if ( !v6 )  
  11.   {  
  12.     //省略错误处理代码  
  13.     ……  
  14. LABEL_3:  
  15.     ……  
  16.     goto LABEL_4;  
  17.   }  
  18.   if ( a4 )  
  19.   {  
  20.     std::string::assign((std::string *)v27, a1);  
  21.     v11 = 0LL;  
  22. LABEL_8:  
  23.     bzero(a2, n[0]);  
  24.     // SLIBCBase64Decode为DSM封装的一个函数,猜测是包装了base64的解码函数
  25.     if ( (unsigned int)SLIBCBase64Decode(v27[0], *(_QWORD *)(v27[0] - 24), a2, n) )  
  26.     {  
  27.       v8 = 1;  
  28.     }  
  29.     else  
  30.     {  
  31.       //省略错误处理代码  
  32.       ……  
  33.       v8 = 0;  
  34.     }  
  35.   }  
  36.   else  
  37.   {  
  38.     v11 = (char *)calloc(v6 + 1, 1uLL);  
  39.     if ( !v11 )  
  40.     {  
  41.       //省略错误处理代码  
  42.       ……  
  43.       goto LABEL_3;  
  44.     }  
  45.     snprintf(v11, v6 + 1, "%s", v4);  
  46.     v22 = &v25;  
  47.     while ( 1 )  
  48.     {  
  49.       v13 = strchr(v11, '{');  
  50.       v14 = v13;  
  51.       // 不存在 { 字符时会进入这个if代码  
  52.       if ( !v13 )  
  53.       {  
  54.         v21 = strlen(v11);  
  55.         std::string::append((std::string *)v27, v11, v21);  
  56.         goto LABEL_8;  
  57.       }  
  58.       *v13 = 0;  
  59.       v15 = strlen(v11);  
  60.       std::string::append((std::string *)v27, v11, v15);  
  61.       *v14 = '{';  
  62.       v16 = strchr(v14, '}');  
  63.       // 没有更多的 {xx} 形式的字符需要处理了  
  64.       if ( !v16 )  
  65.         break;  
  66.       *v16 = 0;  
  67.       v11 = v16 + 1;  
  68.       v17 = strtol(v14 + 1, 0LL, 10);  
  69.       *(v11 - 1) = '}';  
  70.       std::string::string(v28, 1LL, (unsigned int)v17, &v25);  
  71.       // 将 {xx} 中的 xx, 使用strtol以10进制转换  
  72.       // 然后将传唤后的整数作为字符append到待处理字符串最后  
  73.       std::string::append((std::string *)v27, (const std::string *)v28);  
  74.       v18 = v28[0] - 24;  
  75.       if ( (void *)(v28[0] - 24) != v5 )  
  76.       {  
  77.         //省略错误处理代码  
  78.         ……  
  79.       }  
  80.       if ( !v11 )  
  81.         goto LABEL_8;  
  82.     }  
  83.     syslog(3, "%s:%d Failed [%s], err=%m\n""common/synodrive_common.cpp", 839LL, "NULL == szEnd");  
  84.     SYNODriveErrAppendEx("common/synodrive_common.cpp", 839, "NULL == szEnd", (char)&v25);  
  85.     v8 = 0;  
  86.   }  
  87. LABEL_4:  
  88.   free(v11);  
  89.   //省略错误处理代码  
  90.   ……  
  91.   return v8;  
  92. }  

好了,逻辑基本清楚了:将{}中的字符串提取出来,然后转换成10进制对应的数字,最后10进制数字的ASCII值作为字符替换到原文中的原位置,最后用私有函数SLIBCBase64Decode解码。

关于SLIBCBase64Decode私有函数,笔者也懒得找在哪实现的了,暂时先闭着眼睛认为就是Base64Decode函数了(有符号就是爽:Synology-LIBC-Base64Decode)。

还是有点抽象,以version/text/Y{110}J{112}ZWY{61}为例:

  1. ASCII(110) -> n  
  2. ASCII(112) -> p  
  3. ASCII(61)  -> =  

因此version/text/Y{110}J{112}ZWY{61}转换为:version/text/YnJpZWY=

现在顺眼多了,地球人都知道YnJpZWY=是base64:


原来是简介的意思~

Coding Time


验证没问题后,接下来便是最简单的部分。

由上述分析易得用于解码的代码(Typescript语法):

  1. const bs_filenameDecode = (filename : string) : string => {  
  2.     let decoded_filename = '', current_position = 0;  
  3.     while (true) {  
  4.         const curly_start_position = filename.indexOf('{', current_position);  
  5.         if (curly_start_position != -1) {  
  6.             const curly_end_position = filename.indexOf('}', curly_start_position);  
  7.             decoded_filename += filename.substring(current_position, curly_start_position);  
  8.             decoded_filename += String.fromCharCode(  
  9.                 parseInt(filename.substring(curly_start_position + 1, curly_end_position))  
  10.             );  
  11.             if (curly_end_position + 1 === filename.length) break;  
  12.             current_position = curly_end_position + 1;  
  13.         }  
  14.     }  
  15.     return decoded_filename;  
  16. }  

至于为什么NoteStation的作者会用这么奇怪的编码方式,笔者也猜不出来。不过libsynodrive.so.6.0文件还暴露了一个叫SYNODriveEncode的函数,这个函数用来编码一个“神秘编码”出来,这里就不贴这个函数的IDA HexRay代码了,直接说结果: 函数将传入字符串用base64编码,然后将所有[A-Z0-9]之外的字符,转换成 {对应ascii数值} 的形式。

对应的编码逻辑如下(Typescript语法):

  1. const bs_filenameEncode = (filename : string) : string => Buffer.from(filename)  
  2.     .toString('base64')  
  3.     .split('')  
  4.     .map(
  5.         c => /[A-Z0-9]/.test(c) ? c : `{${c.charCodeAt(0)}}`  
  6.     ).join('');  

有了bs_filenameDecode和bs_filenameEncode就可以愉快的读取NoteStation各种笔记的简介、正文相关的数据了。

后记


其实整个博客系统已经完成了,只是介于笔者是懒癌晚期患者,到现在才动笔写点东西出来。

整个博客的工程化过程中,其实涉及到很多安全方面的问题——各位不会认为后台直接跑在群晖的NAS上吧?不会以为整个系统没有隔离吧?不会认为随便什么笔记都能通过博客系统查看吧?这些相关的问题,有机会再专门写东西跟各位看官分享吧,这次先写到这里。

最后来一张博客后台的截图,没什么别的意思,纯属炫耀一下:


参考

  1. 群晖系统NoteStation日志数据损毁的各种恢复方式记录
Catalog
  • 笔者的博客情节
  • 编码问题
  • 编码问题的解决
  • Coding Time
  • 后记
  • 参考
  • CopyRight(c) 2020 - 2025 Debugwar.com

    Designed by Hacksign