面试问题

Author Avatar
Xzhah 9月 28, 2022
  • 在其它设备中阅读本文章

web相关

什么是ssrf攻击以及原理

​ 服务端请求伪造,攻击者在未能取得服务器所有权限时,利用服务器漏洞以服务器的身份发送构造好的请求给服务器所在内网。比如可以用来端口扫描,file协议读文件。

​ 一般是用户输入url,服务器帮忙解析资源,比如根据url下载图片,然后去抓包,通过修改url为内网的ip和端口,达到访问内网资源的目的。

​ 如果探测到redis服务,默认情况下没有密码,就可以未授权执行命令(把反弹shell写进定时任务里),从而达到反弹shell的目的。

​ ssrf防护可以通过过滤返回信息、统一错误信息、限制请求的端口为常用端口、内网ip黑名单、禁用不需要的协议等方法。

什么是csrf以及原理

​ 跨站请求伪造,一种挟持用户在当前已登录的Web上执行非本意操作的攻击方法。具体来说,比如用户已经登录了A网站,攻击者利用广告,链接等诱导用户打开B网站(比如把图片地址设置为攻击链接、js自动提交表单、构造一个诱惑链接),而B网站利用表单或者其他方式向A网站发送请求,就能通过身份验证执行操作。本质是利用了网站对用户网页浏览器的信任。

​ csrf的防护方式:

​ 1)重要操作二次认证,防止操作在后台自动执行

​ 2)设置会话的超时时间

​ 3)操作类请求使用Post,浏览类请求使用Get。因为Post更安全,不会作为url的一部分,不会被缓存,并且可以被服务器日志记录

​ 4)可以通过referer字段表明请求来源,服务端可以通过这个字段判断是否在合法的网站下发送的请求,用于同源检测。但是referer的内容来自于浏览器,各浏览器对referer的实现方法不同,可能有漏洞被利用。

​ 5)在请求地址中加入token,攻击者只能利用用户的cookie信息,当HTTP请求中以参数的形式加入一个随机产生的token,并在服务端验证。比如token可以放在session中,每次请求就可以把session中的token拿出来与请求中的token对比。

​ 6)跨域时不发送cookie

http里为什么还要有host

​ host的目的是用来实现虚拟主机技术,即把一台完整的服务器分成若干个主机,可以在一个主机上运行多个网站或服务。因为http默认端口80,https默认端口443。这样的话根据一个ip来解析就不知道是访问哪个目录了。但是使用host请求头,就可以根据域名解析对应网站。

xss

​ 跨站脚本攻击,是一种代码注入攻击,攻击者通过在目标网站上注入恶意脚本,使之在用户浏览器上运行,从而获取用户的Cookie等敏感信息。

​ 该漏洞原理本质是攻击者的输入/参数中携带者恶意代码,而浏览器可能会把其解析为正常代码,从而执行。

​ xss可以分为三种:

​ 存储型XSS:攻击者将恶意代码提交到目标网站的数据库中(通过论坛发帖、商品评论、用户私信),用户打开目标网站时,网站服务端将恶意代码从数据库中取出,拼接在HTML中返回给浏览器。

​ 反射型XSS:攻击者构造出特殊的URL,其中包含恶意代码。当用户打开url,恶意代码被取出,拼接在HTML中返回给浏览器。浏览器解析并执行恶意代码。

​ DOM型XSS:攻击者构造出特殊的URL,其中的恶意代码由前端的JS取出解析执行。

socket的粘包和分包

​ 粘包:如果两个包发送时间间隔很短,包长度足够长,那么发送的两个包内容可能在接收端那边就是合并到一个包里去了。这是socket的优化机制,可以降低内存开销。这时候就要考虑怎么把两个包的内容区分开了,有两种方法:1.给包的头尾都加上标记 2.在包头加上内容长度。

​ 分包:如果socket包有长度限制,那么一个包的内容也可能会被拆分为多个包进行发送。同样可以靠1.给包的头尾都加上标记 2.在包头加上内容长度两种方式解决。

xxe

poi

文件上传

文件包含

java反序列化

​ 原理是找到一些反序列化点,这时里面对象的内容是用户可控的。然后从反序列化的读取函数(如readObject)中去找一些利用点。比如HashMap put方法中的URLDNS,或者apache common-collections中的Transformer有可以反射执行任意函数的链条。

java相关

ThreadLocal原理

​ ThreadLocal的目的是每个线程都有一份独立的局部变量,互相之间无法影响。与加锁不同之处在于,ThreadLocal会把传进来的变量每个线程都产生一个新的副本,从而互不影响。ThreadLocal的实现原理如下:
​ 1)每个线程都维护了一个ThreadLocalMap对象,ThreadLocalMap由数组实现,采用开放定址法解决hash冲突(即如果发生了元素冲突的情况,就使用下一个槽位存储。因为使用了散列算法,所以地址分配得比较均匀,所以可以使用开放定址法)。与开放定址法作为对比的还有链表法,冲突的元素放到一个链表中,如HashMap。

​ 2)ThreadLocalMap中存储着Entry对象,Entry中的key就是ThreadLocal对象,value是真正的值。Entry中的key是弱引用,其目的是当ThreadLocal不再被使用时,gc可以自动回收这个ThreadLocal对象,避免内存泄漏。但Entry中的value是强引用,所以在ThreadLocal的get,set,remove方法中都会检测是否有key变为null,进行对相应Entry的清理工作。所以建议每次用完ThreadLocal都做一次remove的调用。

​ 建议将ThreadLocal作为静态对象使用,这样的话多个线程的ThreadLocalMap中共用一个ThreadLocal对象作为key,就可以操作线程自己的副本对象。如果把ThreadLocal作为示例对象就会每个线程里多创建一个对象,造成内存的浪费。

gc回收机制

​ gc回收机制中使用了两种判断对象是否可回收的策略:

​ 1)引用计数法,当一个对象被创建后,每次被引用其对应的引用计数器就+1。当引用失效后,引用计数器就-1。这种方法的问题在于,如果两个或者多个对象之间存在循环引用的情况,引用计数就永远不会归0。所以只有早期JVM采用这个策略。

​ 2)可达性分析,定义GC ROOT对象,然后从这些对象出发,得到所有的可达对象。Java中可以作为GC ROOT的对象有:a)虚拟机栈中的引用对象,如局部变量。b)方法区中的静态属性对象,方法区中常量引用对象。c)本地方法栈中(Native方法)引用的对象。

​ 三种经典的gc回收算法是:

​ 1)标记-清除法:最基础的收集算法,由标记清除两个步骤组成。由上面的可达性分析找出所有的不可达对象,然后对所有标记的对象原地清除。这种方法的缺点在于回收完后会有很多不连续的内存空间。

​ 2)复制算法:复制算法的思路是把内存区域分为两块:S0,S1,每次只使用其中一块内存如S0,当使用完触发gc时,便把S0上存活的对象移到S1上面去。然后把S0上的全部清理掉。这样也保证了内存的连续性,并且标记和复制可以同步进行。缺点就是内存减小了一半。

​ 3)标记-整理法:该方法解决了标记-清除算法中内存不连续的问题,在完成标记步骤后,该算法会把存活的对象全都向前移动。然后将边界以外的内存全部清理掉。该算法的缺点在于效率会低于标记-清除法。

​ 堆中分为年轻代、老年代、永久代三个部分:

​ 1)年轻代是存活周期比较短的对象,分为Eden、S0、S1三个区域。一开始对象都在年轻代的Eden区域,当Eden区放满了或者对象太大无法再放进Eden区时,此时对年轻代(Eden和S0)进行一次minor gc,存活的对象放进S1中,然后清空Eden和S0,因为年轻代中存活的对象数量通常少,所以此时用复制算法开销较低。每次年轻代经历了minor gc存活下来的对象年龄会+1,到了一定岁数(通常15)就可以进入老年代。以及S1满了的话对象也可以进入老年代。

​ 2)老年代中再满了就会出发full gc,或者System.gc()也可能会触发full gc 。此时就使用标记-清除法或者标记整理法。也可以使用两者结合,先多次标记-清除,等碎片太多了再用标记-整理。

​ 在java语言中提供了四种垃圾回收器:

​ 1)串行垃圾回收器:串行垃圾回收器会暂停所有的应用程序线程,然后采用单独的线程进行GC。

​ 2)并行垃圾回收器:JVM的默认垃圾回收器,相较于串行回收性能有所提升,也需要暂停有所程序线程,但是会多线程进行gc。

​ 3)并发标记扫描垃圾回收器:在初始标记时暂停其他线程,然后在可达性分析过程中恢复应用线程。但这在实时性上可能出问题,因为应用中对象的引用是在不断更新的。然后是重新标记,即把并发标记时产生变动的对象的存活标记进行修正,这需要暂停其他线程。最后是并发清除,此时可以恢复其他线程。

​ 4)G1垃圾回收器:G1中各代(年轻代、老年代、永久代)的存储地址不连续,其会维护一个优先级列表,每次在收集时间内优先回收价值最大的Region。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

引用类型

​ 1)强引用,在java中使用最广泛的,如Object o = new Object()这一类。只要强引用还在,gc永远不会回收被引用的对象。

​ 2)软引用,用来描述有用但是非必须的对象。对于软引用,在系统内存不足时会对其进行回收。这种可以用来缓存相关进行处理。

​ 3)弱引用,当gc扫描到只有弱引用时,不管内存是否充足都会回收。比如ThreadLocal,如果不使用弱引用,其会等到线程结束才被回收,但是有的线程生命周期可能远长于ThreadLocal的生命周期。

​ 4)虚引用,任何时候gc都可以回收虚引用,不会对生命周期构成任何影响。虚引用必须和引用队列关联使用,其作用在于这个对象被gc回收时可以收到一个系统通知,也就是说如果gc要回收一个虚引用时,会把它加入到相应的引用队列,如果程序发现某个虚引用已经加入到引用队列,就可以在其被回收之前进行相应工作。比如管理直接内存(堆以外内存,jvm自动管理的范围是堆内存),就会用虚引用来进行清理工作。

线程池

​ 1)线程池的意义:线程池使得线程可以被复用,当线程执行完任务又可以返回线程池领取新任务执行。同时线程池可以更好管理线程,无论是控制线程数量还是控制线程状态。

​ 2)线程池重要概念:corePoolSize,核心线程大小,线程池一直运行核心线程就不会停止,核心线程就是线程池里一直要复用的线程,非核心线程是当核心线程满了,阻塞队列也满了,临时顶上去的任务线程,后续没任务了是要回收的,所以非核心线程不会一直被复用。maximumPoolSize,线程池最大线程数,非核心线程数量=maximumPoolSize - corePoolSize。keepAliveTime,空闲线程存活时间,如果线程空闲下来,超过这个时间还没有任务执行,则会结束该线程。但是这里只回收非核心线程。workQueue,阻塞队列,线程池中的线程数量大于核心线程的数量时,就把新建的任务加入阻塞队列。RejectedExecutionHandler,饱和策略,如果阻塞队列满了,线程数也达到了最大线程数,线程池会根据饱和策略来执行后续操作,默认的策略就是丢掉任务抛出异常,还有三种是callerRunsPolicy(谁往线程池里丢这个任务就谁负责,一般是主线程),DiscardOldestPolicy(放弃阻塞队列中最旧的任务,然后把该线程加入阻塞队列),DiscardPolicy(丢掉任务,不报异常)。

​ 3)线程池可以用submit和execute两种方法去启动任务,execute只能提交Runnable类型任务,submit既能提交Runnable类型又能提交Callable类型任务。execute碰到异常会直接抛出,并且execute是没有重量级锁的。submit会吃掉异常,通过Future的get方法可以将异常重新抛出。

设计模式

单例模式:单例模式下的类只有一个实例,类的构造函数为私有函数以保证该类无法被其他代码实例化。在自己的类构造过程中实例化自己,提供get()方法让其他代码获取自己的唯一实例。也可以在get()方法里实例化,这样可以起到lazy初始化的效果。也可以在类构造过程中写一个静态类Holder,其内部成员INSTANCE是单例类的实例化对象。然后在get方法中访问Holder.INSTANCE也能起到lazy加载的效果,并且还是线程安全。

工厂模式:定义一个创建对象的接口,让其子类自行决定要实例化哪一个工厂类。比如暴露一个getXXX(String type),可以根据type的内容选择实例化哪一个子类,不过这些子类需要实现相同的接口或者继承自相同的父类。

抽象工厂模式:抽象工厂就是创建工厂的工厂。

建造者模式:把复杂对象拆分为多个零件对象实现,分为Product(产品)、AbstractBuilder(抽象构建者)、ConcreteBuilder(具体构建者)、Director(指挥者)。以StringBuilder为例,StringBuilder中Appendable为抽象建造者,定义了建造方法append, AbstractStringBuilder实现了Appendable接口的方法所以是具体构建者,StringBuilder重写了AbstractStringBuilder的append方法并以特定顺序对AbstractStringBuilder的append方法进行调用,所以既是具体建造者又是指挥者。另外提一下,StringBuffer的建造者模式和StringBuilder是一样的,只不过StringBuffer加了synchrozed关键字是线程安全的。

jvm

​ 包括内存管理、解释执行、JIT、classLoader、gc。

内存管理

​ jvm将内存划分为以下几个区域:

​ 1)程序计数器:程序计数器的作用可以看作当前线程所指向字节码的行号指示器,在JVM中需要执行字节码解释工作,就会通过程序计数器来选取下一条字节码指令。占据较小的内存空间,是线程私有。

​ 2)虚拟机栈:Java方法执行的内存模型,每个方法执行的时候会创建一个栈帧,用于存储局部变量、操作栈、动态链接、方法出口等信息。每个方法从被调用到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。StockOverflowError错误代表着,该栈的深度过深,大于规定的深度(如递归一直调用下去),就会抛出该错误。线程私有。

​ 3)本地方法栈:本地方法栈服务于native方法,与虚拟机栈的作用相似。本地方法接口可以通过本地方法接口来访问虚拟机内部的运行时信息。线程私有。

​ 4)堆:java内存中最大的一块,被所有线程共享的一片区域。堆的目的是存放对象实例,几乎所有的对象实例都在这里分配内存。但是随着JIT编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换优化技术使得“所有对象在堆上分配内存”不那么绝对了。

JIT编译器

​ 为了提高热点代码的执行效率,在运行时,JIT(即时编译器)会把java字节码编译成本地机器码,并进行各层次的优化,保存到内存中。重点有C1编译器(局部优化,短时程序)、C2编译器(长期优化),方法内联优化、逃逸分析,栈上分配、标量替换等。

操作系统相关

进程与线程的区别

​ 进程是资源的容器,把资源集中到一起,而线程是在CPU上被实际调度的实体对象。进程是资源分配的基本单位,包括可执行的代码、打开的文件描述符、挂起的信号、进程的状态、内存地址空间、以及一个或多个执行线程。而线程是进程中的活动对象,CPU调度的基本单位,每个线程拥有自己独立的程序计数器、线程栈、寄存器。

​ 为什么有了进程还需要线程?因为一个进程中会有多个任务,如果只有一个调度单位,那么任务被阻塞时其他任务无法继续进行。因此需要有多个独立的调度单位来并发的完成这些任务。另一方面,线程比进程更加的轻量级,可以更快被创建、切换。因为进程中拥有大量资源,所以每次切换进程需要保存资源导致切换开销大。

​ 在Linux内核中,并没有把进程和线程进行区分,线程就是一种特殊的进程,都被叫做任务。都是使用task_struct结构体表示、调用fork函数,由clone方法创建。

进程间通信方式

​ 1)匿名管道通信:管道是半双工,数据只能由一个方向流动。如果双方都需要通信,那么就需要建立两个管道。且管道只能用于父子进程或者兄弟进程之间。管道对于管道两端的进程来说,就是一个文件,但它不是普通文件,其单独构成一种文件系统,只存在于内存之中。

​ 管道实质上是一个内核缓冲区,进程以先进先出的方式从缓冲区中存取文件,一端存,一端取。管道的局限在于其只支持单向数据流、只能用于有亲缘关系的进程中、没有名字用以标志、管道的缓冲区有限、管道传送的是无格式字节流导致管道两端需要事先约定好数据格式。

​ 2)有名管道通信:有名管道提供了一个路径名与之关联,以有名管道的文件形式存在于文件系统中,这样即使不存在亲缘关系的进程也可以通过该路径访问管道通信。有名管道把名字放在文件系统中,内容放在内存中。

​ 所以管道(无论是否有名)本质就是一种特殊类型的文件,用于先进先出的读写。

​ 3)信号量:信号量的本质是一个计数器,用于多进程对共享数据对象的存取,不以传输数据为主要目的,而是以保护共享资源作为主要目的,使得资源在一个时刻只能被一个进程独享。常作为一种锁机制作为进程间以及同一进程内不同线程之间的同步手段。比如可以用于买票场景,每次办理一次业务,信号量就-1,信号量为0时就是没有车票了,进程便挂起。

​ 4)消息队列:消息队列是在两个不相关进程间传递数据的一种方式,本质是一种消息链表,可以独立于独写两端,即使进程终止消息队列也不会消失。且消息队列可以有优先级,可以随机读取,每个消息队列在内核中由队列ID进行标志。

​ 5)共享内存:共享内存是两个或多个进程共享同一块内存。最快的一种IPC,因为进程是直接对内存进行存取。因为多个进程同时操作,所以需要处理同步问题,信号量与共享内存常结合在一起使用。所以服务器和客户端进行通信可以由共享内存+信号量+消息队列共同实现

​ 6)套接字:socket可以在本地单机的跨进程通信,也可以在不同计算机上通过网络连接完成跨进程通信。套接字是支持TCP/IP的网络通信的基本操作单元。套接字的特性由3个属性组成:域、端口、协议。域指定传播介质,最常用的是AF_INET,表示使用英特网,第二种常见的是AF_UNIX,表示UNIX文件系统。同一主机的不同进程被赋予了不同端口号。套接字协议在因特网中有三种:a)流套接字:通过TCP/IP实现,提供一个有序、可靠、双向字节流的连接。而且有出错后重发机制。b)数据报套接字:不需要建立连接与维持连接,通过UDP/IP实现,对发送长度有限制。数据报作为一个单独的网络消息被传输,可能出现丢失、乱序等情况,因为UDP不是一个可靠协议,但是速度快。c)原始套接字:可以操纵较低层次的协议,如IP,ICMP。

​ 在套接字中TCP是没有长度限制的,因为TCP是基于流的协议,但是一般由于链路层的数据帧是1500字节限制,IP数据包则是1500-IP头的大小限制(8)。一般TCP的长度不要超过IP数据包内容长度(1500-IP头-TCP头),这样就不需要对单个TCP包进行分割。

共享内存相关的文件夹

kill信号,以及Linux信号

​ -9是无条件终止,不加-9默认是SIGTERM,以正常方式来终止。ctrl c会发送SIGINT信号,当cpu从内核切回到用户态,就会处理该信号,该信号的默认处理动作是结束进程。ctrl+z是SIGSTP,ctrl+\是SIGOUT,SIGOUT在程序终止后会产生core dump文件。

​ 信号是用来通知进程发生了异步事件。1-31是不可靠信号,34-64是可靠信号,这是因为linux的信号部分继承自unix,而unix因为早期设计导致信号可能会丢失(比如同时来N个信号,但是进程只能处理一个),所以linux在后续中引入了可靠信号32-64,进程无法及时处理的信号会去排队(双链表队列里等着),修复了信号丢失问题,但早期的信号就没法修复了。

​ 信号的发送可以由键盘按键、系统调用、硬件异常、程序错误、kill命令等方式进行生产与发送。

​ 信号的检测时机在内核态切换到用户态时检测,把未决信号(存在pending里,发送过来还没处理的信号)传递给用户态进行处理。进程也可以选择屏蔽某些信号,这些信号就会进入阻塞状态。

​ 信号的处理有两种:默认处理(如终止,终止+dump,忽略)、自定义。

namespace与cgroup

​ cgroup用于资源控制、namespace用于访问隔离。Cgroup是Control group的简称,是Linux提供的一个特性用于限制和隔离一组进程对系统资源的使用,如设备权限、CPU使用率、内存上限、网络带宽等。在Cgroup之前,只能对一个进程做资源限制,有了Cgroup则可以对进程进行任意分组。

​ Namespace是将内核的全局资源做封装,使得每个namespace都有一份独立的资源,因此不同进程在各自的namespace中对同一种资源的使用互不干扰。namespace能隔离的包括:IPC、网络资源、文件系统挂载点、进程ID、主机名和域名、隔离用户和用户组。与命名空间相关的三个系统调用:clone(创建新的namespace,由clone创建的新进程就位于这个新的namespace里,根据flag参数选取namespace类型)、unshare(为已有进程创建新的namespace)、setns(把某个进程放进已有namespace里)

execve与system的区别

docker相关

docker泄露宿主机信息

1.通过反弹shell可以泄露宿主机ip

2.伪文件系统,如/proc和/sys目录下有宿主机的资源信息(如cpuinfo,内存使用情况,加载内核模块,内核版本等),通过这些信息可以达到和目标容器同驻的目的,以及在某些资源使用高峰的时候DDoS

3.生成镜像时可能携带一些密钥/口令相关

4.docker都是走内部网关,可以考虑arp中间人攻击其他容器

docker建议禁用的syscall

add_key: 阻止容器使用不在命名空间的内核密钥环

bpf:拒绝将潜在的持久性bpf程序加载到内核中

clone:拒绝clone新的namespace

create_module:不能在容器中对内核模块进行操作

kexec_load:不能在容器中加载内核模块并执行

docker逃逸

1.内核漏洞,如脏牛

2.特权模式启动,可以把宿主机的目录挂载进来

3.docker自己的软件有漏洞