Nginx处理http请求头部流程

主要流程如上图,大致步骤如下:

  1. 三次握手,接受到对方的syn报文,tcp(内核)回复syn+ack,等再次接受对端的ack,内核会根据负责均衡算法选择一个worker处理,然后epoll会将并将刚刚建立的socket句柄交给nginx的事件处理器,事件处理判断是一个建立连接的读事件就进行accept系统调用,此时会给该链接分配连接内存池,默认512B。
  2. accept成功后会触发ngx_http_init_connection回调方法,该回调是在ngx_http_add_listening方法中设置的。
    ls->handler = ngx_http_init_connection;

    在init_connection方法中会将新建连接对应的读写事件通过epoll_ctl系统调用加入到epoll中,并设置一个定时器client_header_timeout,用来保证在指定时间内如果没有收到header就断开连接。

  3. 用户发送请求行和header,内核回复ack,并且epoll_wait拿到fd,开始调用ngx_http_wait_request_handler方法。内核需要将data从内核读缓冲区复制到用户空间读缓冲区,所以该方法会从connection_pool中分配client_header_buffer_size(通常是1k),用来保存数据。同时nginx还会分配一块内存叫做request_pool,用来服务nginx处理请求。
    size = cscf->client_header_buffer_size;
    ngx_palloc(c->pool, size);
  4. 当接收到的uri和header超过1k,nginx还会从request_pool中分配一块large_client_header_buffers内存用开保存更大的用户数据。
  5. 接收到uri后,会接着接受header,所有的header接受完后,会移除之前设置的client_header_timeout。之后就是交给http模块来处理核心逻辑。

TLS疑点杂记

客户端拿到证书链后有哪些操作 

1. 验证证书是否过期,证书中会包含颁发日期和过期日期,不过这里有个问题是怎么验证证书的过期时间是有效的?

2. 验证证书是否吊销,有两种做法,第一是CRL即证书吊销列表。证书被吊销后会被记录在CRL中,CA会定期发布CRL。浏览器可以去CRL服务器去下载这个列表来检查证书是否被吊销了,缺点显而易见就是不实时以及下载列表以及比对非常耗时。第二是OCSP,来代替CRL,它是一个协议,规定了客户端如果发送一个请求,客户端带着证书去查询,OCSP服务器会给出该证书的相关信息。

3. 验证证书链是否合法,即验证每层证书是否是上一级证书颁发的,一直到根证书。一般所有CA机构的根证书(包括二级证书)列表都会保存在操作系统或者浏览器中。

4. 数字签名的验证,服务器会给客户端发送一个数字签名,也就是对证书内容散列算出来的,而浏览器又可以拿到CA的公钥(内置在操作系统或浏览器),于是用公钥对证书内容同样的散列,结果与数字签名比对。

客户端和服务器如何磋商对称加密使用的密钥

早期的TLS协议会用三个random随机数来生成一个密钥,这个做法会有一些隐患,比如斯诺登事件,具体可以google。现在主流的做法是用DHCE椭圆加密算法,这个算法会有一些中间参数,在Certificates和Server Hello Done中间发送给客户端,客户端会根据这些参数用算法生成一对公私钥,并将公钥发送给服务器端。服务器用自己的私钥以及客户端的公钥生成一个密钥,同理客户端用服务器的公钥以及自己的私钥生成一个密钥,而DHCE算法可以保证两边生成的密钥是一致的。

如何生成一个免费的tls证书

推荐Let‘s Encrypt,参考:http://guitoo.cc/wordpress/2018/07/05/lets-encryp-https/

 

有意思的加法器

cpu的alu中,最重要的逻辑部件应该是加法器了,计算机内任何逻辑运算都可以用加法器来完成。

加法器又分为好几种,针对不同的使用场景,可以分为带标示加法器和无标示加法器。

无标示加法器

最基本的一位加法器又三个输入两个输出,分别是低位进位cin,输入a,输入b,高位进位cout以及结果F,逻辑电路比较简单,用逻辑运算表达式则为

F = a Xor b Xor cin
cout = a && b || a && cin || b &&cin
 

举个例子:

1 + 1

cin第一位上的运算为0,a和b都是1,所以F是0,满2进1所以进位cout为1,代表第二位进1。

而n位加法器则是有多个1位加法器组合,前一位的cout作为后一位的cin。

 

带标示的n位加法器

无标示的n位加法器的局限性比较多,比如无法处理溢出行为,举个例子,两个8位无符号数加法

1 1 1 1 1 1 1 1
0 0 0 0 0 0 0 1

结果最高有效位会产生进位,结果会溢出,无标示的n位加法器无法处理这种情况,只能做取余操作,即舍掉最高位的进位,那么结果就错误了。还有一个情况是有符号数的运算,有符号数的最高位是符号位,且计算机内部是用补码进行计算,所以最高位的符号位会参与运算,那么如果没有一个标示位标示符号位的运算,那么结果也会出现错误。

所以带标示的n位加法器解决了这个问题。与普通的n位加法器类似,带标示的n位加法器,也有输入输入及进位,只不过最后结果会有好几个标示,这些标示会存放在专用寄存器中,以便控制器处理。主要有以下几个标示:

  1. 溢出标示,处理最高位的进位问题
  2. 0标示,如果所有位上的加法器运算结果F都为0,那么0标示为1,标示结果是0
  3. 符号标示,标示结果的符号位
  4. 进位/借位标示,加法则是进位标示,减法则是借位标示(计算机对减法的运算会转成加法2-1 = 2 + (-1))

有了这些标示,加法器就可以处理很多复杂的运算,比如和mux结合实现带符号数的加法等等

Mysql为什么会选错索引

mysql经常在执行某些sql时会选错索引,导致和预期的执行时间差距甚大,这其实不是mysql的bug,mysql选择索引是一个非常复杂的算法,所以选错也时有发生。

选择索引是优化器的工作,优化器会根据多种情况做为衡量因素来选择一个mysql认为最优的索引,通常一个主要因素就是扫描行数。在执行sql之前,mysql会预估一个扫描行数,(既然是预估那么肯定和实际的扫描行数有差距),而如何预估是取决于一个索引基数,索引值的重复率越低,基数越大,那基数是如何确定的?mysql是采取采样统计,即在索引段中取出N个页,统计重复率然后得到每页不重复值的平均值,再乘上这个索引一共有多少页(其实这里我有个疑问没想明白,索引扫描是很快的,而且不涉及到回表,何不直接扫描索引统计,这样更加准确。猜测mysql对基数的使用弱化了准确性的严格程度,第二个采样统计可能效率更高,更少的磁盘io,特别是索引比较大的情况下)。

show index from table

可以查看每个索引的基数值(cardinality列),这个值是可以重新统计的

analyze table t;

可以手动重新采样统计,或者索引表更比较频繁(1/M),会自动重新统计

刚刚说了扫描行数是通过基数预估出来的,第一个是预估,第二个是基数也不是非常准确,所以这个扫描行数可能会很不准确,从而导致选错索引,除了这点,还有一些其他原因可能会导致选错索引:

  1. 考虑回表的代价:比如possible_keys有主键id和a,对id扫描10万次的代价可能比索引a扫描50000次的代价小,因为在没有索引覆盖的情况下,50000次回表的随机io代价不可忽略。
  2. 排序的影响,mysql会创建临时表对无序的数据进行排序,这个开销也是不可忽略的,所以优化器在索引选择时会考虑到这点,比如possible_keys有索引a和b,sql最后需要按照b排序,那么b扫描100000次的代价可能比a扫描50000次的代价小,因为索引的有序性,所以选择索引a会重新对数据进行排序。

 

undo表空间

undo tablespace 中包含undo log,是一种独立的表空间。在mysql启动时会默认创建两个undo表空间,且系统要求起码有两个活跃的undo表空间以支持自动truncate功能。(这样可以保证一个离线truncate另一个继续提供服务)。undo表空间文件创建在–innodb_undo_derectory变量指定的位置,默认在data目录下,文件名undo_N格式。每个文件的初始大小基于–innodb_page_size参数(默认16K), 默认大小为10M。

添加undo表空间

8.0.14版本以前,可以在启动时设置–innodb_undo_tablespaces来指定创建的undo表空间,后续版本废弃改参数,创建可以使用

create undo tablespace tablespace_name add datafile "filename";

使用该语法时,指定的filename可以是绝对路径,但是该路径必须是innodb可以识别的,–innodb_directoried变量定义了这些路径,而且不管该变量如何显示定义,都会包含三个默认路径,分别是–innodb_undo_directory, –data_dir,–innodb_data_home_dir,mysql启动时会在这些路径里扫描寻找undo tablespaces。
如果没有指定绝对路径,那么undo tablespace会被创建在innodb_undo_directory下,如果该变量为空,会创建在mysql的data(/var/lib/mysql)目录下.

删除undo表空间

create undo tablespace语法创建的undo表空间可以使用drop undo tablespace语法来删除(默认创建的表空间不可用),在删除前必须保证该空间是empty状态。
在清空一个表空间前必须把它的状态设置为inactive,确保该表空间的rollback segment的undo segment不会分配给事务使用。

alter undo tablespace tablespace_name set inactive;

设置后会等待已经使用该表空间的事务提交,待所有的事务提交后,purge线程开始truncate表空间至初始大小,这时候表空间的状态为empty,即可以被drop。

 drop undo tablespace tablespace_name;

可以在INNODB_TABLESPACES 表里查询到每个表空间的状态,包括undo表空间

 SELECT NAME, STATE FROM INFORMATION_SCHEMA.INNODB_TABLESPACES 
 WHERE NAME LIKE tablepace_name;

默认的undo表空间不可以被drop但是可以设置成inactive状态,但是必须保证起码有两个活跃的表空间以支持表空间的自动truncate,否则会报错。

移动undo表空间

用create undo tablespace语法创建的undo表空间文件可以移动到任何innndb可以识别的路径,mysql在启动时会找到他们,但是默认创建的undo表空间不可移动,如果默认undo表空间被移动了,那么必须在启动时更改–innodb_undo_directory参数为移动后的路径。

清除undo表空间

一个表空间文件默认最大为1G,由–innodb_max_undo_log_size参数控制,innodb提供了自动和手动两种方式清除undo log
自动清除由purge线程(垃圾回收线程,可能有多个,后台独立运行,参数–innodb_purge_threads指定)完成,需要开启–innodb_undo_log_truncate参数,默认是开启的。

由于自动truncate必须保证有两个活动的undo表空间,那么手动就必须有三个获得undo表空间,因为可能有一个正在truncate而处于offline。
两种方式清除过程如上所述,等undo表空间的状态时empty后,状态再设置成active

对于自动清除可以使用–innodb_purge_rseg_truncate_frequency参数来提高清除频率,默认是purge被触发(undo tablespace大小超过–innodb_max_undo_log_size参数)了128次后,执行一次truncate,可以设置更小的值加速truncate。

MVCC多版本并发控制

mvcc可以保证RR隔离级别下一致性读(consistent read)的问题,也解决了读写冲突的问题。

读写冲突的来源

没有mvcc控制的情况下,假如我们的场景是读远大于写的场景,那么事务A对某读频繁的表id为1的行进行更新,根据两阶段锁协议,那么该行是会加一个X锁,且没有释放,这样其他事务在读取的时候会被锁上,导致并发能力降低。

而mvcc允许行被加锁的情况下,读取旧版本数据。

一致性读

innodb的一致性读通过一致性读视图及undo log实现。

Undo Log

数据库中的每一行都保存这不同的版本,旧版本保存在undo log中。

当一行数据被创建时,会有一些额外的数据,如下图(其中rowid只会在没有主键情况下生成,一般某个表没有指定主键innodb会选择第一个唯一索引作为主键,如果在没有会隐式的生成一列rowid):

当改行数据被修改时,

为该行记录生成undo log, 新纪录的db_roll_pt指针指向该条记录。

undo logs会有一个单独的purge线程负责清除,保证undo logs不会无限增长。

undo logs会有一些参数控制:

innodb_undo_directory:将undo log存储位置独立出来,默认位置为安装目录。

inndb_undo_tablespace:mysql初始化时分配的表空间个数,默认为128,之后会在data目录下创建制定个数的undo文件,文件名以undo_000,undo_001递增。(8.0.14版本后不再支持,具体undo tablespace的介绍,看详情)

(之所以mysql可以把undo log存储独立出来,原因是可以把日志部署到ssd上,提高事务的并发读。)

undo log都是保存在undo log segment中,undo log segment 保存在rollback segment中。

每个回滚段包含1024个undo log segment, 而mysql最大支持128个回滚段(innodb_rollback_segments参数可以显示控制)。因为每个事务在读写undo log时都要独占一块undo log segment,所以理论上最大支持1024 * 128个并发事务操作。

 

一致性读的实现

RR级别下,数据可见性的规则就是,事务启动前已经提交的事务的更改可见,事务启动后的事务提交不可见。(begin/start transaction并不是一个事务的起点,在该指令后的第一个操作标的语句才标识着事务启动。)

每个事务在启动时会获得一个递增的事务id,记trx_id,并且会创建一个read-view的struct,该结构体包含一个当前所有活动事务的id集合(包括当前事务),一个最小活动事务id和一个最大活动事务id,之前提到过,每一行数据都有一个额外的db_trx_id, 读取到该行时,如果该db_trx_id与当前事务id不一致,就开始读取undo log,然后利用undo log和活动事务集合判断出可以读取的值,判断逻辑图如下:

以上概念称为一致性读,与之对应的有一个”当前读”, 即:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)

举个例子:

k初始为1。

那么B再去查询时结果为3,这就是当前读。

缓冲区溢出攻击

输入的大小超出了分配的缓冲区内存大小,导致溢出。

char string[5];
gets(string);  // 输入helloworld

程序相关的数据一般保存在内存的以下区域:

  1.  数据段:静态存储区(比如字符串字面量)和全局变量
  2. 代码段:程序机器码
  3. 栈:函数栈帧,一个函数栈帧包括参数,返回地址(函数返回后cpu执行的下一条指令的地址),局部变量等
  4. 堆:程序运行时动态申请的内存数据

当调用函数时,逻辑堆栈帧被压入栈中,堆栈帧包括函数的参数、返回地址、EBP(EBP是当前函数的存取指针,即存储或者读取数时的指针基地址,可以看成一个标准的函数起始代码)和局部变量(如果函数有局部变量)。程序执行结束后,局部变量的内容将会消失,但是不会被清除。
当函数返回时,逻辑堆栈帧从栈中被弹出,然后弹出EBP,恢复堆栈到调用函数时的地址,最后弹出返回地址到EIP(寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。),从而继续运行程序。所以假如有下图的情况:

由于栈是低地址方向增长的,因此局部数组buffer的指针在缓冲区的下方。当把data的数据拷贝到buffer内时,超过缓冲区区域的高地址部分数据会“淹没”原本的其他栈帧数据,根据淹没数据的内容不同,可能会有产生以下情况:

  1. 淹没了其他的局部变量。如果被淹没的局部变量是条件变量,那么可能会改变函数原本的执行流程。这种方式可以用于破解简单的软件验证。

       2. 淹没了ebp的值。修改了函数执行结束后要恢复的栈指针,将会导致栈帧失去平衡。

       3. 淹没了返回地址。这是栈溢出原理的核心所在,通过淹没的方式修改函数的返回地址,使程序代码执行“意外”的流程!

       4. 淹没参数变量。修改函数的参数变量也可能改变当前函数的执行结果和流程。

       5. 淹没上级函数的栈帧,情况与上述4点类似,只不过影响的是上级函数的执行。当然这里的前提是保证函数能正常返回,即函数地址不能被随意修改(这可能很麻烦!)。

而缓冲区溢出攻击就是利用了第三点,改变了cpu要执行的下一条指令的地址。

但是从实现上来说很难,因为没法预估返回地址在栈区的什么位置,也就无法确定溢出多少字节才能覆盖到返回地址。

PDO的深入理解

pdo可以通过预处理语句和参数绑定防注入,那么是不是就是绝对安全呢,根据之前的开发中踩的坑以及读到的一些相关资料,做一些总结。

 

本地预处理和模拟预处理

在初始化PDO驱动时,可以设置一项参数,PDO::ATTR_EMULATE_PREPARES,作用是打开模拟预处理(true)或者关闭(false)。手册描述

模拟预处理是防止某些数据库不支持预处理,比如sqllite,这样pdo会在本地进行预处理模拟。然后会对绑定的参数进行转义处理,最后将拼接好的sql语句发送给mysql服务器进行处理。也就是预处理工作并不是发生在数据库层面。与数据库的交互只有一次,而本地预处理只是将sql模板发送给数据库,数据库完成预处理操作,包括解析,编译,生成执行计划,之后pdo再将参数发给数据完成数据绑定,换句话说,为什么native prepare statment(数据库层面的prepare)可以防止sql注入,是因为sql模板已经交由数据库解析完成,sql语意确定,那么所有的参数都会被视作字符串参数,而不是sql语句或者表达式。

如果我们使用mysql,建议关闭模拟预处理。(某些低版本mysql不支持预处理除外)。

另外注意,即使mysql版本支持预处理,某些语句mysql无法支持prepare,那么pdo在处理时还是会使用模拟预处理。这样可能会存在注入的风险。

However, be aware that PDO will silently fallback to emulating statements that MySQL can’t prepare natively,参考这篇回答

 

字符集设置的正确性

还是上面那片问答,高票答主给了一个例子

$pdo->query('SET NAMES gbk');
$var = "\xbf\x27 OR 1=1 /*";
$query = 'SELECT * FROM test WHERE name = ? LIMIT 1';
$stmt = $pdo->prepare($query);
$stmt->execute(array($var));

为什么这个例子可以完成注入,答主也给了解释,原因就是上面所说的,pdo会进行模拟预处理,这样会在本地进行转义,具体转义可能类似于mysql_real_escape_string()(PHP 5.5.0 起已废弃,并在自 PHP 7.0.0 开始被移除)或者PDO::quote(),此类函数和addslashes这种函数区别在于,它会读取当前pdo驱动设置的字符集,而set names gbk并没有设置这个值,至于为什么,看鸟哥这篇文章,所以该函数在转义时还是认为pdo驱动的字符集是latin或者是utf8,于是它给x27转义,结果就变成了xbf5c27(x27对应ascii的\),该而数据库服务器的字符集被设置成gbk,于是xbf5c27被解释成

縗'

于是完成了注入,这也就是宽字节注入,经典的5c问题。

所以除了关闭模拟预处理之外,设置字符集也很重要,除了set names utf8,还需要设置pdo驱动的字符集,防止出现fallback成模拟预处理的情况,php5.6.3版本以上可以在初始化pdo时设置,如下

$pdo = new PDO('mysql:host=localhost;dbname=testdb;charset=utf8', $user, $password);

或者mysqli_character_set_name函数 。

所以说最好的做法是关闭模拟预处理和设置正确的字符集。

 

另外补充几点:
1. MySQL客户端服务端针对预处理语句和非预处理语句定义了不同的数据传输协议。预处理语句使用所谓的二进制传输协议,MySQL服务端以二进制发送数据,发送之前不会被序列化。客户端不会仅接收字符串,相反的,他们将获取二进制数据并且尝试去转换值到合适的PHP数据类型。

2. 虽然预处理语句可以帮助我们防止sql注入,但是前提是我们必须正确的使用,例如下面代码就没用了:

$sql = 'select * from tab where name=' . $name . ' and password = ? limit 1';
$pdo->prepared($sql);
$pdo->execute([$password]);

这种需要保证所有的传入参数都先使用占位符代替。

 

参考:

https://www.v2ex.com/t/362646

https://github.com/wuzehv/blog/issues/32

http://zhangxugg-163-com.iteye.com/blog/1835721