0%

背景

k8s中各组件和kube apiserver通信时的认证和鉴权 中提到”NodeRestriction准入插件”,实际上它是一个”准入控制器”。

“准入控制器”是一个重要的概念,在istio、apisix、某些安全产品中都有用到。

本文简要记录一下以下内容:

  • “准入控制器”是什么
  • 怎么开启”准入控制器”
  • 从源码浅析”准入控制器”

本文使用的k8s集群是用kubekey搭建,命令是./kk create cluster –with-kubernetes v1.21.5 –with-kubesphere v3.2.1

分析

“准入控制器”是什么?

它有点类似”插件”,为apiserver提供了很好的”可扩展性”。

请求apiserver时,通过认证、鉴权后、持久化(“api对象”保存到etcd)前,会经过”准入控制器”,让它可以做”变更和验证”。

“变更”可以修改”api对象”,比如istio用来实现pod注入。”验证”可以用来校验”api对象”,比如 校验当前集群是否有足够多的资源满足”api对象”、校验当前提交的”pod对象”是否合法。

怎么开启”准入控制器”?

1
2
3
root@ip-172-31-14-33:~/kubernetes-1.21.5/_output/bin# ./kube-apiserver --help |grep admission-plugins
...
--enable-admission-plugins strings admission plugins that should be enabled in addition to default enabled ones (NamespaceLifecycle, LimitRanger, ServiceAccount, TaintNodesByCondition, Priority, DefaultTolerationSeconds, DefaultStorageClass, StorageObjectInUseProtection, PersistentVolumeClaimResize, RuntimeClass, CertificateApproval, CertificateSigning, CertificateSubjectRestriction, DefaultIngressClass, MutatingAdmissionWebhook, ValidatingAdmissionWebhook, ResourceQuota). Comma-delimited list of admission plugins: AlwaysAdmit, AlwaysDeny, AlwaysPullImages, CertificateApproval, CertificateSigning, CertificateSubjectRestriction, DefaultIngressClass, DefaultStorageClass, DefaultTolerationSeconds, DenyServiceExternalIPs, EventRateLimit, ExtendedResourceToleration, ImagePolicyWebhook, LimitPodHardAntiAffinityTopology, LimitRanger, MutatingAdmissionWebhook, NamespaceAutoProvision, NamespaceExists, NamespaceLifecycle, NodeRestriction, OwnerReferencesPermissionEnforcement, PersistentVolumeClaimResize, PersistentVolumeLabel, PodNodeSelector, PodSecurityPolicy, PodTolerationRestriction, Priority, ResourceQuota, RuntimeClass, SecurityContextDeny, ServiceAccount, StorageObjectInUseProtection, TaintNodesByCondition, ValidatingAdmissionWebhook. The order of plugins in this flag does not matter.

根据命令行帮助可以知道,默认会开启17个”准入控制器”。

1
2
3
4
>>> a="NamespaceLifecycle, LimitRanger, ServiceAccount, TaintNodesByCondition, Priority, DefaultTolerationSeconds, DefaultStorageClass, StorageObjectInUseProtection, Persis
tentVolumeClaimResize, RuntimeClass, CertificateApproval, CertificateSigning, CertificateSubjectRestriction, DefaultIngressClass, MutatingAdmissionWebhook, ValidatingAdmissionWebhook, ResourceQuota"
>>> len(a.split(","))
17

也可以用–enable-admission-plugins开启额外的”准入控制器”。

这些”准入控制器”介绍可以查看 使用准入控制器 文档。在我的实验环境中,可以看到额外开启了NodeRestriction准入控制器,它实现了apiserver对kubelet请求的权限控制。

1
2
root@ip-172-31-14-33:~# ps aux|grep kube-apis
root 9567 4.8 7.8 1381988 613048 ? Ssl 03:49 17:48 kube-apiserver ... --enable-admission-plugins=NodeRestriction ...

从源码浅析”准入控制器”

我们可以通过”断点调试”结合源码分析,验证前面说的两个结论:

  • 请求先经过认证、鉴权,然后经过”准入控制器”
  • 默认开启17个”准入控制器”;加上NodeRestriction就是18个

“认证、日志审计、鉴权”在apiserver中都是以filter的形式存在,而”准入控制器”有点像包装了一层servlet。

1
2
3
4
5
6
7
8
9
10
func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler {
handler := filterlatency.TrackCompleted(apiHandler)
handler = genericapifilters.WithAuthorization(handler, c.Authorization.Authorizer, c.Serializer) // 鉴权
...
handler = genericapifilters.WithAudit(handler, c.AuditBackend, c.AuditPolicyChecker, c.LongRunningFunc) // 日志审计
...
handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences) // 认证
...
return handler
}

finishRequest函数中会开goroutine调用”准入控制器”
img

可以看到 admissionHandler切片长度是18,17个”默认开启的准入控制器”加上NodeRestriction
img

部分控制器代码在plugin/pkg/admission目录中,会实现Admit接口
img

总结

  • 请求先经过认证、鉴权,然后经过”准入控制器”
  • 默认开启17个”准入控制器”;NodeRestriction不是默认开启的

默认开启的”准入控制器”中有两个很特殊的,ValidatingAdmissionWebhook和MutatingAdmissionWebhook。这两个控制器让apiserver有了更多的可扩展性,实现了”动态准入控制”。

2022年4月8号,在海淀区劳动人事争议仲裁院,我这一趟”感受劳动法保护之旅”终于完结,在仲裁庭和上家公司达成和解。

我为什么想要仲裁呢?一个原因是公司给的赔偿方案是N+1,而我是想要2N+年终奖,另一个原因是我想感受下”劳动法对咱劳动者的保护”,刚好我也不太着急找工作,所以有时间仲裁。

在决定仲裁后,我被拉进了一个仲裁微信群,群里有接近20个人。群里有些同事可能有些经验,会在群里说”保存绩效截图证据”这种注意事项。不幸的是,没两天群就解散了。私信问了一个群里活跃的同事,她说因为绩效在被裁前还没出来,所以他们好几个人就不仲裁了、拿了N+1。于是我就一个人去仲裁。

仲裁时间周期比较长,从12.27号到4.8号仲裁开庭,总共去了五次,分别是递交申请材料、调解、拿举证通知、递交和交换举证材料、仲裁开庭。

仲裁过程比较简单,没有花一毛钱,我也没有请律师。仲裁厅是有律师可以咨询的,排队取号就行也不用花钱,不过服务态度一般。

仲裁厅的人特别热情,我在写第一次申请材料时不知道怎么写清楚”诉求中的事实和理由”,就用手机搜索,被巡视的工作人员瞟到后,过来给我说,”别网上瞎搜了,来,我告诉你这个怎么填”,然后就呱唧给我说一通。我当时心里想着”仲裁厅可真不错啊”,感觉来对地方了。

但是到了仲裁开庭时,就没有这么好的感受了。调解员把公司hr支出去后,单独和我谈话:如果我和公司达不成和解,后面就要去法院,这样结果得等到明年去了;即使上了法院,也不一定就能拿到年终奖;不要想着完全按劳动法来。

我感觉年终奖可能确实难拿。上家公司关于年终奖的说明是写在录用函里而不是劳动合同里,并且写的是”年终奖是浮动的,公司有最终解释权”,同时hr说他们自己都没有年终奖、在职的部门同事也说没有年终奖。看了下我现在公司的劳动合同,把年终奖是写到合同里了,没有”浮动,绩效”这些字眼,挺好。

再来说说N+1、2N中税的问题。N+1中的N叫做离职补偿金,有一个免税额度,因为上限是本市年度平均工资的三倍,比较高,所以可以认为N是不交税的。N+1中的1叫做代通知金,是要交税的。2N其中一个N是要交税的。

前几天看到一个知乎热帖下的回答 女子拒调岗「高管」变「客服」被开除,公司被判赔 59 万,如何从法律角度解读? ,感觉相对于仲裁来说,诉讼真是花时间啊。

背景

在用go操作mysql数据库时会经常见到类似下面的代码,空导入”go-sql-driver/mysql”

1
2
3
4
5
6
7
8
9
10
import (
"database/sql"
"time"

_ "github.com/go-sql-driver/mysql" // 空导入
)

// ...

db, err := sql.Open("mysql", "user:password@/dbname")

我之前对上面代码有点疑问:空导入有什么意义吗?

后面知道go在导入包时,会执行包中的init函数,所以上面的空导入会执行 github.com/go-sql-driver/mysql/driver.go 中的init函数来注册驱动

1
2
3
func init() {
sql.Register("mysql", &MySQLDriver{})
}

再后来,见到好多次类似”注册模式”的写法,逐渐能从中体会到”面向接口编程”的思想。然后就多了一个好处:在做代码审计时,因为了解这种”业务套路”,所以更容易理解代码逻辑;

下面分享三个用到这种模式的例子,分别是go sql库、go swagger库、python flask库

分析

  • 这种”注册模式”是什么?

    包含有三个角色:

    • 接口层:定义接口、提供”注册实例接口”、提供”获取服务”或者”功能接口”
    • 服务提供者:实现接口、注册”接口实现”
    • 服务使用者:调用”获取服务接口”

    这个结论是我根据三个例子总结出来的,下面来具体看看三个角色的功能

go sql

  • 是什么?

    https://github.com/go-sql-driver/mysql 文档中的例子,用户可以使用驱动名获取到”包含驱动实例的对象”

    1
    db, err := sql.Open("mysql", "user:password@/dbname") // db中包含驱动实现

    这里”用户”就是”服务使用者”。

    “接口层”是”database/sql”库,它在 database/sql/driver/driver.go 文件中定义了接口,”驱动”需要实现下面的Open方法

    1
    2
    3
    4
    type Driver interface {
    ...
    Open(name string) (Conn, error)
    }

    database/sql/sql.go中提供”注册实例接口”,”驱动”可以调用Register函数注册。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    func Register(name string, driver driver.Driver) {
    driversMu.Lock()
    defer driversMu.Unlock()
    if driver == nil {
    panic("sql: Register driver is nil")
    }
    if _, dup := drivers[name]; dup {
    panic("sql: Register called twice for driver " + name)
    }
    drivers[name] = driver
    }

    database/sql/sql.go中提供”获取服务接口”,”用户”可以调用Open函数获取DB实例

    1
    2
    3
    4
    5
    6
    7
    8
    func Open(driverName, dataSourceName string) (*DB, error) {
    driversMu.RLock()
    driveri, ok := drivers[driverName]
    driversMu.RUnlock()
    ...

    return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
    }

    “服务提供者”就是”驱动”,这里就是 github.com/go-sql-driver/mysql

    它实现了Driver接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    type MySQLDriver struct{}
    ...
    func (d MySQLDriver) Open(dsn string) (driver.Conn, error) {
    cfg, err := ParseDSN(dsn)
    if err != nil {
    return nil, err
    }
    c := &connector{
    cfg: cfg,
    }
    return c.Connect(context.Background())
    }

    并且调用Register方法注册”接口实现”,代码见github.com/go-sql-driver/mysql/driver.go

    1
    2
    3
    func init() {
    sql.Register("mysql", &MySQLDriver{})
    }

swagger

  • 是什么?

    在审计 tidb-dashboard 项目时,关注到swagger

    tidb-dashboard用swagger来提供在线的api文档服务。

  • 服务使用者

    “服务使用者”,这里是tidb-dashboard

    tidb-dashboard

    1
    2
    3
    func Handler() http.Handler {
    return httpSwagger.Handler()
    }

    如果跟进方法,就会看到调用swag.ReadDoc()方法,这个方法就是”接口层”提供的”获取服务接口”。

  • 接口层

    “接口层”就是swagger库,在 swagger.go 文件中

    定义接口

    1
    2
    3
    4
    // Swagger is an interface to read swagger document.
    type Swagger interface {
    ReadDoc() string
    }

    提供”注册实例接口”

    1
    2
    3
    4
    5
    // Register registers swagger for given name.
    func Register(name string, swagger Swagger) {
    ...
    swags[name] = swagger
    }

    提供”获取服务接口”

    1
    2
    3
    4
    5
    6
    func ReadDoc(optionalName ...string) (string, error) {
    ...
    swag, ok := swags[name]
    ...
    return swag.ReadDoc(), nil
    }
  • 服务提供者

    “服务提供者”这里是用户自己。这里用法有点特殊,tidb-dashboard仓库中没有”服务实现”相关代码,在编译tidb-dashboard项目时会生成代码。

    生成后的代码我放在了gist

    可以看到它实现了Swagger接口

    1
    2
    3
    4
    5
    6
    7
    type s struct{}

    func (s *s) ReadDoc() string {
    ...

    return tpl.String()
    }

    注册接口

    1
    2
    3
    func init() {
    swag.Register(swag.Name, &s{})
    }
  • 和第一个例子的区别

    区别在于,这个例子中,”服务提供者”和”服务使用者”都是用户自己。

    那么为什么不直接自己调自己,还经过”接口层”呢?

python flask扩展

  • 是什么?

    用户可以用flask框架的cors扩展来做跨域请求时的限制。

    插件文档中的例子如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    from flask import Flask
    from flask_cors import CORS

    app = Flask(__name__)
    CORS(app)

    @app.route("/")
    def helloWorld():
    return "Hello, cross-origin-world!"

    这里”服务使用者”不需要找”接口层”要”服务实例”

  • 接口层

    flask框架中定义了一个函数AfterRequestCallable类型

    提供”注册实例接口”,如下,只是把函数放进了列表中

    1
    2
    3
    4
    5
    @setupmethod
    def after_request(self, f: AfterRequestCallable) -> AfterRequestCallable:
    ..
    self.after_request_funcs.setdefault(None, []).append(f)
    return f
  • 服务提供者

    cors插件在flask框架基础上,提供了cors相关的安全能力。

    flask_cors/extension.py

    实现接口:cors_after_request函数是AfterRequestCallable类型具体的实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    def make_after_request_function(resources):
    def cors_after_request(resp):
    ...
    normalized_path = unquote_plus(request.path)
    for res_regex, res_options in resources:
    if try_match(normalized_path, res_regex):
    ...
    ...
    return resp
    return cors_after_request

    注册接口:在CORS实例化时,会注册实例提供安全能力

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class CORS(object):
    ...

    def __init__(self, app=None, **kwargs):
    ...
    self.init_app(app, **kwargs)

    def init_app(self, app, **kwargs):
    ...
    cors_after_request = make_after_request_function(resources)
    app.after_request(cors_after_request) # 有点注册一个中间件的感觉
  • 和前面两个例子的区别

    细品的话,可以看到这个例子和前两个例子又有很多不同:

    • 没有显式的”接口定义”,毕竟python没有接口关键字
      • 注册的是”函数”,而不是”对象”
    • “接口层”不提供”获取服务”,”服务使用者”也不需要”获取服务”
    • 由”服务使用者”注册”接口实例”,而不是”服务提供者”注册接口实例

    可以想一想为什么会有这些区别,能把这些区别”修改”回去吗?比如如果我是cors扩展库作者,我就不能在cors库里自动注册服务,让库的使用者少写几行代码吗?

总结

通过分析这三个”注册模式”的例子,我自己对”面向接口编程”有点感觉。后面感觉这种思想很基础、很常见、很实用,比如rpc、spring ioc容器、微服务的服务注册等都和这个”注册模式”很像。

如果你觉得疑惑,或者觉得我写得比较怪,推荐你找一个你熟悉的库自己分析一下。

背景

最近在研究web框架时,对”请求上下文”这个基础概念有了更多的了解,因此记录一下,包括以下内容:

  • “请求上下文”是什么?
  • web框架(flask和gin)实现”请求上下文”的区别?
  • “线程私有数据”是什么?

学习过程

  • “请求上下文”是什么?

    根据 Go语言动手写Web框架 - Gee第二天 上下文ContextContext:请求控制器,让每个请求都在掌控之中 两篇文章,可以知道从”框架开发者”的角度看,”请求上下文”包括:

    1
    2
    3
    * 请求对象:包括请求方法、路径、请求头等内容
    * 响应对象:可以用来返回http响应
    * 工具函数:可以用来更方便地操作"请求对象"和"响应对象"

    那么web框架怎么让”框架的使用者”拿到”请求上下文”呢?

  • “框架的使用者怎么”拿到”请求上下文”?

    flask框架中请求上下文是一个全局变量,而gin框架中请求上下文被当作参数传递。

    根据flask文档知道request对象包含有请求信息,可以如下获取

    1
    2
    3
    4
    5
    6
    7
    8
    from flask import request

    @app.route('/login', methods=['POST', 'GET'])
    def login():
    ...
    if request.method == 'POST':
    if valid_login(request.form['username'],
    request.form['password']):

    根据gin文档知道gin.Context实例c中包含有请求信息,可以如下获取

    1
    2
    3
    4
    5
    6
    7
    8
     router := gin.Default()

    router.GET("/welcome", func(c *gin.Context) {
    firstname := c.DefaultQuery("firstname", "Guest")
    lastname := c.Query("lastname") // shortcut for c.Request.URL.Query().Get("lastname")

    c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
    })

    从上面的使用方法可以看出来,flask和gin框架实现”请求上下文”有一些区别:

    1
    2
    * gin框架中"框架使用者"需要把"请求上下文"当作参数,显示地传递
    * flask框架中"框架使用者"只需要request这个全局变量,就能获得"请求上下文"

    于是就有两个问题:

    1
    2
    * flask的request是个全局变量,那"基于多线程实现"的服务端同时收到多个请求时,request怎么能代表当前线程处理的请求呢?
    * flask似乎对"框架使用者"来说更方便,毕竟不需要多传递一个参数。那为什么gin框架不也这么设计呢?

    第一个问题其实涉及到”线程私有数据”的概念

线程私有数据

  • 是什么?

    举个例子,下面代码中新线程看不到主线程的mydata变量,因为mydata是”主线程”和”新线程”的私有数据”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import threading
    from threading import local

    mydata = local()
    mydata.number = 42


    def f():
    if getattr(mydata, "number", None) is not None:
    print(mydata.number) # 这里会打印42吗?


    thread = threading.Thread(target=f)
    thread.start()
    thread.join()
  • threading.local是怎么实现的?

    源码中可以看到localdict是实际存放数据的对象,每个线程对应一个localdict。

    线程在读写”线程私有数据”时,会找到自己的localdict。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class _localimpl:
    ...

    def get_dict(self):
    """Return the dict for the current thread. Raises KeyError if none
    defined."""
    thread = current_thread()
    return self.dicts[id(thread)][1] # id(thread)是当前线程对象内存地址,每个线程应该是唯一的

    def create_dict(self):
    """Create a new dict for the current thread, and return it."""
    localdict = {}
    key = self.key
    thread = current_thread()
    idt = id(thread) # id(thread)是当前线程对象内存地址,每个线程应该是唯一的
    ...
    self.dicts[idt] = wrthread, localdict
    return localdict

    from threading import current_thread, RLock

    那flask框架是用了threading.local吗?

  • flask框架用了threading.local吗?

    先说结论:flask的request对象不是基于”threading.local”,而是”contextvars.ContextVar”,后者可以实现”协程私有数据”

    下面代码运行结果中,task1函数不会打印hello,可以看出来ContextVar是实现”协程私有数据”。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    from greenlet import greenlet
    from contextvars import ContextVar
    from greenlet import getcurrent as get_ident

    var = ContextVar("var")
    var.set("hello")


    def p(s):
    print(s, get_ident())

    try:
    print(var.get())
    except LookupError:
    pass


    def task1():
    p("task1") # 不会打印hello
    # gr2.switch()


    # 测试ContextVar能否支持"协程私有数据"
    p("main")
    gr1 = greenlet(task1)
    gr1.switch()

    # 测试ContextVar能否支持"线程私有数据",结论是支持
    # import threading
    # p("main")
    # thread = threading.Thread(target=task1)
    # thread.start()
    # thread.join()

    flask/globals.py中可以看到request是werkzeug库的Local类型

    1
    2
    3
    _request_ctx_stack = LocalStack()
    ...
    request: "Request" = LocalProxy(partial(_lookup_req_object, "request")) # type: ignore

    而从werkzeug/local.py源码 可以看出来werkzeug库的Local是基于contextvars.ContextVar实现的

    1
    2
    3
    4
    5
    class Local:
    ...

    def __init__(self) -> None:
    object.__setattr__(self, "_storage", ContextVar("local_storage")) # 基于contextvars.ContextVar

    所以,flask并没有用threading.local,而是werkzeug库的Local类型。也因此在”多线程”或者”多协程”环境下,flask的request全局变量能够代表到当前线程或者协程处理的请求。

总结

web框架让”框架使用者”拿到”请求对象”有两种方式,包括”参数传递”、”全局变量”。

实现”全局变量”这种方式时,因为web服务可能是在多线程或者多协程的环境,所以需要每个线程或者协程使用”全局变量”时互不干扰,就涉及到”线程私有数据”的概念。

SpringWeb中在使用”RequestContextHolder.getRequestAttributes()静态方法”获取请求时,也是类似的业务逻辑。

参考

Go语言动手写Web框架 - Gee第二天 上下文Context
flask 源码解析:上下文

背景

apisix网关之前出过一个dashboard api未授权访问漏洞:因为访问下面两个接口不需要身份认证,所以可以利用这两个接口进行rce。
image

在刚分析这个漏洞时,我有点困惑:

image

filter目录下的代码看着像是”中间件”(或者叫”过滤器”)的实现,而”中间件”应该是所有请求都会经过”中间件”的业务逻辑,那为什么访问上面的两个接口就没有经过filter.AuthenticationMiddleware中间件的认证逻辑呢?为什么访问其他接口就会经过filter.AuthenticationMiddleware中间件的认证逻辑呢?

虽然动态调试下个断点,就能看到函数调用流程,但是我还想知道”路由”和”中间件”从web框架层来看是怎么设计的。

apisix项目用到了gin框架和droplet框架,本文记录我对这两个框架”路由”和”中间件”使用和设计的研究,以解决自己的疑惑。

分析

  • 为什么其他接口就会经过filter.AuthenticationMiddleware中间件的逻辑?

    “业务代码”可以使用”gin框架提供的Use接口”注册中间件,比如下面这样

    image

    从上图中并没有看到filter.AuthenticationMiddleware中间件被注册,那么为什么其他接口就会经过auth中间件的逻辑?比如GET /apisix/admin/routes HTTP/1.1

    答案在droplet库:apisix通过droplet接口注册了filter.AuthenticationMiddleware中间件
    image

    这样当访问/apisix/admin/routes路径时,请求会经过gin框架注册的”中间件”、droplet注册的”中间件”。

    img

    img

    有一个不严谨的结论:上面的两张图中,handlers和mws数组中的所有”函数”会被依次调用。

  • 为什么/apisix/admin/migrate/export接口不会经过filter.AuthenticationMiddleware中间件的逻辑?

    /apisix/admin/migrate/export路由对应的”处理函数”并不是wgin.Wraps包装的,这样代码流程会不从gin框架转移到droplet框架

    image

    对比可以看到/apisix/admin/routes路由对应的”处理函数”是wgin.Wraps返回的,这样代码流程会从gin框架转移到droplet框架

    image

    小结:gin框架和droplet框架通过wgin.Wraps包装的func(ctx *gin.Context)函数类型连接到了一起。

  • 怎么修复的?

    从这个commit中可以看到:

    • gin框架中filter.AuthenticationMiddleware中间件被添加
    • droplet框架中filter.AuthenticationMiddleware中间件被删除

    image

总结

  • 本文只零散地记录一小部分gin和droplet框架的内部逻辑,对gin路由和中间件实现有兴趣的可以看这篇文章
  • 在分析过程中感觉”实现一个web框架”非常需要”接口”或者”函数类型”,比如net/http和gin框架的连接、gin框架和droplet框架的连接,都是依靠”接口”或者”函数类型”来通信。

参考

漏洞分析

背景

最近的工作内容会涉及到很多项目的”code review”,侧重发现”安全漏洞”。

阅读代码有时感觉有点费劲,我想原因一方面是对项目使用的”语言、库、框架”不熟悉,另一方面可能是缺少”设计模式”的知识,导致读一些框架代码时总是感觉”数据流”有些绕,所以我想学下”设计模式”提高代码审计的效率。

本文分析gin框架的Run方法,看看是否用到了什么设计模式。

分析

  • gin是什么?

    gin是go中非常流行的web框架,你可以在文档找到一个最小的例子跑起来感受一下,比如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package main

    import "github.com/gin-gonic/gin"

    func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
    c.JSON(200, gin.H{
    "message": "pong",
    })
    })
    r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
    }
  • 框架初始化

    其中gin框架的Run方法实现如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import (
    ...
    "net/http"
    ...
    )
    ...
    func (engine *Engine) Run(addr ...string) (err error) {
    ...
    err = http.ListenAndServe(address, engine) // 调用net/http包的ListenAndServe函数,同时把Engine对象注入
    return
    }

    net/http包的ListenAndServe函数实现如下

    1
    2
    3
    4
    func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe() // 阻塞,等待请求
    }

    根据这个函数调用关系可以知道,”gin框架初始化”时代码控制权从gin框架转移到net/http库

  • 当请求过来时的代码控制权是怎么样的?

    image

    从上图左侧的函数调用链可以看到:

    1
    2
    * "处理请求"时代码控制权从`net/http库`转移到`gin框架`
    * `net/http库`和`gin框架`分层非常明显:后面的业务逻辑都是`gin框架`和业务代码处理,和`net/http库`无关

    net/http库gin框架之间是通过handler.ServeHTTP(rw, req)方法跳转的,这个handler.ServeHTTP是什么呢?

    hanler是一个接口类型,定义如下

    1
    2
    3
    type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
    }

    gin框架的Engine实现了这个接口,所以最终调用的是”框架初始化”时”注入”的”Engine对象”的ServeHTTP方法。

    所以小结一下:

    1
    2
    * 框架初始化时,代码流程是 `gin` -> `net/http`
    * 请求过来时,代码流程是 `net/http` -> `gin`

    net/httpgin框架通过Handler接口通信:框架初始化时,ginnet/http注入”接口的具体实现”;处理请求时,net/http调用”接口的具体实现”

    按照我的理解,这就是设计模式中的”控制反转”和”依赖注入”。

  • “控制反转”和”依赖注入”是什么?

    “控制反转”就是代码控制权从业务代码”反转”到框架代码,对应到上面的场景,代码流程是从gin -> net/http -> gin

    “依赖注入”就是”依赖的对象”不从内部创建而是从外部传递进来。对应到上面的场景,net/http库ListenAndServe函数依赖Handler接口,这个接口的实现是gin框架传递进来的。

    那为什么需要”控制反转”和”依赖注入”呢?

  • 为什么net/http库要这么设计?

    通过接口通信的好处是”解耦”:可以把接口理解成一种规范,net/http不关心”被调用者”是如何实现规范的。

    net/http库负责把tcp数据解析成http请求对象,扔给gin库就可以了。

    如果我们也想实现一个新的go web框架,就可以实现这个接口,来处理net/http库解析好的http请求对象。

总结

  • net/http库gin框架的分层,是理解web框架的重要的点
  • 分析web框架时可以从两个场景入手:一方面是”框架初始化”,另一方面是”请求处理”
  • “控制反转”和”依赖注入”可以实现”解耦”,在框架设计时会用到

因为我不确定”学习设计模式对看项目的代码效率提升”有多大帮助,所以我想问下读者们有啥见解,欢迎给我留言。

参考

19 | 理论五:控制反转、依赖反转、依赖注入,这三者有何区别和联系?

问题背景

扫描器的基本功能包括对某个主机列表的端口做扫描,类似nmap -p 8000-9000 1.1.1.1/24.

为了实现上面的需求,曾经我写过类似下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def get_host():
ret_host_list = []
... # 从api中获取扫描主机ip
return ret_host_list


def get_port():
ret_port_list = []
...
return ret_port_list


def generate_targets():
ret_target_list = []
for host in get_host():
for port in get_port():
ret_target_list.append(
{
"host": host,
"port": port
}
)
return ret_target_list


for target in generate_targets():
...

不知道你能不能看出来问题所在:上面的代码,当扫描的主机和端口都比较少时没什么问题,但是当主机和端口很多时,就会占用大量内存。

本文记录两个问题:

  • 怎么改进上面的代码,避免内存占用过大的问题
  • 研究为什么会占用大量内存

过程

  • 复现

    我们先来写一个demo复现这个”内存占用”过大的问题

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import sys

    def generate_target():
    result = []
    for i in range(0, 256 * 256):
    for j in range(8000, 8050):
    result.append({"host":i, "port":j})
    return result

    result = generate_target()

    print("ok")
    sys.stdin.readline()

    运行上面的脚本,通过free -m命令可以观察到物理内存接近减少900M。

    如果range(8000,8050)修改成range(8000,9000),也即扫描8000-9000端口时,内存至少减少12G(因为我的测试机器只有12G的物理内存,所以只能得到这个数字)。

  • 怎么改进上面的代码,避免内存问题?

    看着像是因为生成大量的{“host”:i, “port”:j}的扫描对象,所以才占用很多内存。

    那么改进很简单,如果我们将列表改成”生成器”,就不用在generate_target函数生成所有{"host":i, "port":j}的扫描对象。

    比如在函数中用yield关键字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import sys

    def generate_target():
    result = []
    for i in range(0, 256 * 256):
    for j in range(8000, 9000):
    yield {"host":i, "port":j}

    result = generate_target()

    print("ok")
    sys.stdin.readline()

    或者用生成器表达式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import sys

    def generate_target():
    return ({"host":i, "port":j} for i in range(0, 256 * 256) for j in range(8000, 9000))

    result = generate_target()

    print("ok")
    sys.stdin.readline()

    关于”生成器”的概念,可以参考 廖雪峰的教程

    如果对”生成器”的实现感兴趣,可以参考 重新认识生成器generator

    复现脚本消耗了接近900MB的物理内存,难道真的”区区几个”dict就能占用这么多内存吗?

  • 为什么会占用大量内存?

    我们可以用pympler库来看看python程序中的对象都占用了多少内存,修改后的脚本如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    [root@instance-fj5pftdp tmp]# cat 20.py
    import sys
    from pympler import tracker, muppy, summary


    def print_mem():
    all_objects = muppy.get_objects()
    sum = summary.summarize(all_objects)
    summary.print_(sum)

    def generate_target():
    result = []
    for i in range(0, 256 * 256):
    for j in range(8000, 8050):
    result.append({"host":i, "port":j})
    return result

    result = generate_target()

    print_mem()
    sys.stdin.readline()

    image

    执行后,可以看到有3278449个dict实例,总共占用750.76MB

    3278449约等于256*256*50,和脚本中的循环次数吻合。

    那这里一个dict实例占用多少个字节呢?我们用sys.getsizeof函数可以看到,在python3.6中{}{"host":"1","port":"1"}都占用了240字节

    image

    这里一个dict实例占用240个字节,总共有3278449个实例,算一下确实会占用750MB,接近900MB

    差不多我最开始的疑问都解开了,只有最后一个疑问。

    到这里我不知道你会不会和我一样奇怪:为啥{}啥也没存储,sys.getsizeof显示占用240字节,而{"host":"1","port":"1"}明显多了点字符串,sys.getsizeof为啥仍显示占用240字节。

  • 为啥sys.getsizeof告诉我们{}占用240字节?

    这个现象是分python版本的,比如python3.8版本如下
    image

    我想如果知道sys.getsizeof是怎么计算内存占用的,我们就知道它的结果是什么意思。于是我就去翻文档和看源码。

    翻了下文档,没找到sys.getsizeof的计算过程,于是只好去看下CPython代码看下sys.getsizeof的实现。

    在Python/sysmodule.c中可以看出来:sys.getsizeof等于 __sizeof__() + GC头大小(16字节)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    size_t
    _PySys_GetSizeOf(PyObject *o)
    {
    PyObject *res = NULL;
    PyObject *method;
    Py_ssize_t size;

    ...

    method = _PyObject_LookupSpecial(o, &PyId___sizeof__); # 对象的sizeof函数
    ...
    res = _PyObject_CallNoArg(method);
    ...

    size = PyLong_AsSsize_t(res);
    ...
    if (PyObject_IS_GC(o)) # 容器对象(list、dict)会有GC头,str、int等没有GC头。GC头用来做垃圾回收
    return ((size_t)size) + sizeof(PyGC_Head);
    return (size_t)size;
    }

    下面也可以验证上面的结论

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ➜  cpython-3.8 ./python.exe
    Python 3.8.12+ (default, Jan 1 2022, 12:15:13)
    ...
    >>> [].__sizeof__()
    40
    >>> sys.getsizeof([])
    56
    >>> {"host":"1"}.__sizeof__()
    216
    >>> sys.getsizeof({"host":"1"})
    232

    __sizeof__()是什么呢?每种类型的sizeof函数实现逻辑不同,dict类型的sizeof函数就是Objects/dictobject.c中的dict_sizeof函数。

    你可以动态调试,或者翻一翻文件,最终能看到计算过程,如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    Py_ssize_t
    _PyDict_SizeOf(PyDictObject *mp) // mp就是dict的实例
    {
    Py_ssize_t size, usable, res;

    size = DK_SIZE(mp->ma_keys); // 哈希表的大小,也就是PyDictObject数据结构中dk_indices数组的大小。这个场景下是8字节
    usable = USABLE_FRACTION(size); // size的三分之二,也就是5

    res = _PyObject_SIZE(Py_TYPE(mp)); // PyDictObject数据结构的大小,48字节
    if (mp->ma_values)
    res += usable * sizeof(PyObject*);
    /* If the dictionary is split, the keys portion is accounted-for
    in the type object. */
    if (mp->ma_keys->dk_refcnt == 1)
    res += (sizeof(PyDictKeysObject) // 除两个数组外有 5 个字段,共 40 字节
    + DK_IXSIZE(mp->ma_keys) * size // dk_indices索引数组占用的大小,这个场景下是8字节
    + sizeof(PyDictKeyEntry) * usable); // 键值对数组,长度为5 。每个 PyDictKeyEntry 结构体 24 字节,共 120 字节
    return res;
    }

    关于PyDictObject数据结构,你可以参考 dict 对象,高效的关联式容器

    根据上面的内容,48+40+8+120+16刚好就是232字节,也就是python3.8下sys.getsizeof({"host":1})的结果。

总结

问题背景中的场景可能还有其他的编程方式来实现,这里我只是为了引出我学到的”生成器”和”Python内置对象的内存占用”两个知识点。这两个点都背后能扯到更多的点,比如dict容器的动态扩容、哈希表冲突的解决,如果有兴趣,推荐你可以看文章中的参考资料。

想起我以前老听说python性能不好,只以为是解释运行得慢。通过这个案例和参考资料的学习,感觉到还可以从”内存”方面比较。和c相比,python对象的内存占用是有一点多,比如空字符串c中就占用1个字节,python中占用49个字节。

生成器可以节约内存。

问题背景

之前在做扫描器时,有一个功能是:生成任务实例。

使用场景是:用户在web页面上点击启动按钮,就可以立即运行指定的任务(比如指定哪些POC对哪些资产做漏洞扫描);定期运行扫描任务

因为”生成任务实例”时可能需要比较久的时间,并且有很多其他业务逻辑,所以这里由后台进程来生成任务实例,实现”异步”的效果。

image

在线上发现一个问题:”后台创建任务实例的进程”运行一段时间后,内存占用越来越大。

虽然当时能力有限,并没有找到”内存不断增长”的原因,但是最终还是解决了这个问题。

怎么解决的

我当时的想法是,虽然找不到原因,但是重启大法好啊,我可以处理N个任务后,删除这个”后台创建任务实例的进程”再新建进程。

项目中我用的是python语言,celery框架,刚好celery提供了两个参数

1
2
3
4
5
6
7
8
9
10
11
12
root@50f41bfab20a:/# celery worker --help
...
--max-tasks-per-child MAX_TASKS_PER_CHILD, --maxtasksperchild MAX_TASKS_PER_CHILD
Maximum number of tasks a pool worker can execute
before it's terminated and replaced by a new worker.
--max-memory-per-child MAX_MEMORY_PER_CHILD, --maxmemperchild MAX_MEMORY_PER_CHILD
Maximum amount of resident memory, in KiB, that may be
consumed by a child process before it will be replaced
by a new one. If a single task causes a child process
to exceed this limit, the task will be completed and
the child process will be replaced afterwards.
Default: no limit.

可以指定内存超过多少、处理多少任务后,生成新的进程来处理任务。

我就靠这两个参数解决​了问题。

总结

后面我在做安全评估时,发现其他业务线有php语言开发的程序也是这种模式:使用子进程处理任务,处理N个任务后,”重新创建”子进程。

所以,凡是业务场景允许”进程重启”的,应该都可以用这种模式来解决”内存泄漏”。

难解决的是”长时间持续运行的程序”出现的”内存泄漏”

参考

既然每个程序占用的内存都是操作系统管理的,为什么内存泄漏还是个问题? - pansz的回答 - 知乎

之前文章结论有问题,修正后形成本文。原文如下

问题背景

之前线上的漏洞扫描器遇到一个问题:扫描刚开始时,内存占用不超过200M,但在扫描过程中,扫描器会占用超过10G以上内存。因为同一台机器上还有其他的服务,可用物理内存也只有10G左右,所以扫描过程中就没有可用内存了,机器负载(uptime命令查看)也会很高。

排查”怎么导致10G内存占用”也比较简单:因为每次内存占用过高时,都能看到机器上有上百个java -jar ysoserial.jar进程(扫描器开的),所以可以知道是 shiro-550 这个poc导致的内存消耗。

先说明一下为什么有上百个java -jar ysoserial.jar进程:从 shiro-550 代码中也可以看到,我在python中使用子进程调用ysoserial来生成shiro测试payload。因为扫描器使用协程池(gevent.pool)实现并发,所以扫描器执行到shiro poc时,会产生很多个ysoserial子进程。

本文不讨论这个问题的解决办法(你可以看Shiro-550 PoC 编写日记),而是分析为什么这里python中的子进程会消耗10G这么多的内存。

我的分析思路:写一个demo复现,然后分析demo

分析过程

  • 复现

    写个demo复现一下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # coding:utf-8
    import subprocess
    import gevent.monkey
    gevent.monkey.patch_all()

    import gevent.pool

    def test(_):
    popen = subprocess.Popen(['python', "/tmp/big.py"], stdout=subprocess.PIPE) # big.py是一个60M左右的的python文件
    print(popen.stdout.read())

    if __name__ == "__main__":
    pool = gevent.pool.Pool(100) # 100个协程

    _ = "x," * 300
    pool.map(test, _.split(","))

    其中/tmp/big.py是如下脚本生成的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # coding:utf-8

    size = 60 * 1024 * 1024 # 60M
    fname = "/tmp/big.py"

    template = """
    a="%s"
    while True:
    pass
    """

    with open(fname, "w") as f:
    f.write(template % ('x'*size))

    下面在机器上观察demo脚本对物理内存占用的影响

  • 观察

    image

    可以观察到:在执行脚本前,机器还有10G可用的物理内存;执行脚本后,生成了100个big.py进程,内存也从11203M减少到4886M。

    内存为什么会减少这么多呢?其实算一算就很容易找到原因:内存减少了约6G,而big.py脚本大小约60M、总共有100个big.py进程,所以应该是每个python big.py进程会占用60M物理内存。

    想一想也很合理:执行python big.py时,python应该是将big.py文件全部读到内存中了。

  • 回到最开始的问题

    可以推测执行java -jar ysoserial.jar命令时,java也会将ysoserial.jar文件读到内存。

    image

    又因为ysoserial.jar文件接近50M,所以有200个java -jar ysoserial.jar子进程时,就会消耗至少10G的内存。

    到这里,我的疑问也解开了。

  • 总结

    多进程执行java -jar xxx.jar或者python xxx.py时,需要注意xxx.jarxxx.py的大小会对内存占用有影响。

    看到这里,我不知道你会不会心想这个问题也太简单了吧。

    确实,在写出验证demo之后,很容易得到结论。但是在写出demo之前,我把问题想错了导致走了点弯路。下面我来说一下我走弯路时的过程以及学到的东西。

弯路

  • 最开始的思路

    其实我最开始是怀疑”内存占用10G”以上的原因是:python生成子进程时会占用和父进程一样大小的物理内存,而父进程(也就是扫描器进程)本身因为会读了一些资源文件,所以本身是占用了比较大的物理内存。这样当父进程(扫描器进程)生成200个子进程(java -jar ysoserial.jar)时,就会占用200*40M(8G)的内存。似乎这个数字也将近10G,也能差不多对应上问题背景。

    现在回过头看之前的这个原因猜测,有两点问题:

    1
    2
    * python生成子进程时因为有操作系统"写时复制"的机制,所以不会有200个子进程就占用200*40M的内存
    * `subprocess.Popen`生成的子进程内存占用和父进程无关,`multiprocessing.Process`生成的子进程内存占用和父进程是一样的

    关于”写时复制”机制,可能你和我最开始一样不了解,下面我带你来验证一下这个机制是怎么回事。

  • “写时复制”机制

    linux上和生成子进程有关的系统调用有fork、clone,这两系统调用都会有”写时复制”机制。

    按照我自己的理解,”写时复制”机制就是刚生成子进程时,子进程和父进程 关于”用户态虚拟地址”到”物理地址”的映射关系是一样的。然后,在发生写操作时(无论父进程还是子进程),操作系统都会重新映射。

    因为映射到同一个物理页,所以不会导致物理内存变少。

    我们可以用crash工具来验证一下这个机制

  • crash工具验证fork时的”写时复制”机制

    准备测试代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <sys/mman.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <string.h>

    #define SIZE 4*1024*1024
    int main(){
    void *addr = mmap(NULL, (size_t)SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); // 申请4M内存
    memset(addr, 'A', SIZE);

    printf("main: %p\n", addr);

    while(1){
    getchar();

    if (fork() == 0){
    printf("sub: %p\n", addr);
    while (1){
    sleep(5);
    }
    }
    }
    }

    通过crash工具来验证,可以看到:

    1
    2
    * 因为子进程和父进程都没有对addr内存做写操作,所以子进程和父进程中addr映射到同一个物理地址
    * 虽然代码中mmap用了`PROT_WRITE`标志,但是子进程和父进程addr内存标志中都没有RW可写标志

    image

    你也可以修改代码,在子进程中修改addr指向的内存,然后用crash观察”物理地址”和”内存标志”的变化,来体会”写时复制”。

    关于crash工具的安装和使用,你可以参考 借助crash工具理解linux系统的内存分配

  • multiprocessing.Process是否会”写时复制”?

    multiprocessing.Process是用clone系统调用而不是fork系统调用(你可以用strace命令验证一下)

    我们同样可以用crash来验证multiprocessing.Process的子进程和父进程是否会映射到同一个物理页。先说结论:multiprocessing.Process同样有”写时复制”。在得到这个结论前我差点以为multiprocessing.Process是没有”写时复制”机制的,因为我发现a变量地址对应的物理地址在”父进程”和”子进程”中是不同的。

    下面我来说一下我是怎么测试的。

    先准备测试代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    [root@instance-fj5pftdp tmp]# cat v.py
    from multiprocessing import Process

    a = b"abc"
    print("main", id(a)) # id函数返回变量a的虚拟地址

    def t():
    while True:
    pass

    p = Process(target=t, args='')
    p.start()

    然后用crash查看父子进程中a变量对应的物理地址,可以看到:父子进程a变量映射到不同的物理地址。

    image

    上面的现象让我一度以为multiprocessing.Process是没有”写时复制”机制。但是因为我之前验证了”clone系统调用”有”写时复制”,所以我怀疑multiprocessing.Process因为啥原因所以没有”写时复制”?

    为了解决上面的疑问,我在python进程的clone系统调用下断点,在刚调用clone时用crash查看页表

    image

    可以看到,在clone刚被调用时,父子进程中a变量映射到同一个物理地址。

    到目前为止有两个现象:在clone刚被调用时,父子进程中a变量映射到同一个物理地址;v.py运行后,父子进程中a变量映射到不同的物理地址。

    我猜测:clone被调用后,v.py中后面修改了a变量,导致进程中a变量地址被映射到一个新的物理地址上。因为我的猜测也符合”写时复制”机制流程,所以直觉上应该是这样。

    但是现在还有一个问题:你看我们前面的v.py代码,它并没有修改a变量。那么a变量是被谁修改了呢。

    为了搞清楚这最后一个问题,我用gdb查看a变量在内存中长什么样,然后发现父子进程a变量的”引用计数”不相同,如下ob_refcnt字段值就是”引用计数”

    “引用计数”是CPython用来做”垃圾管理”的一个机制,当对象被创建或者被当作参数传递时,对象的引用计数会加1。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [root@instance-fj5pftdp ~]# ps aux|grep v.py
    root 21465 0.0 0.0 144332 8604 pts/1 S+ 12:34 0:00 python3 v.py // 父进程
    root 21466 93.0 0.0 144332 6460 pts/1 R+ 12:34 6:19 python3 v.py // 子进程
    [root@instance-fj5pftdp ~]# gdb --batch -p 21465 -ex 'print *(PyBytesObject*) 140443328409848' // 140443328409848是a变量地址
    ...
    $1 = {ob_base = {ob_base = {ob_refcnt = 1, ob_type = 0x7fbb8a46dfa0 <PyBytes_Type>}, ob_size = 3}, ob_shash = 4892354780606192576, ob_sval = "a"}
    ...
    [root@instance-fj5pftdp ~]# gdb --batch -p 21466 -ex 'print *(PyBytesObject*) 140443328409848'
    ...
    $1 = {ob_base = {ob_base = {ob_refcnt = 3, ob_type = 0x7fbb8a46dfa0 <PyBytes_Type>}, ob_size = 3}, ob_shash = 4892354780606192576, ob_sval = "a"}
    ...

    可以推测CPython在clone之后修改了a变量的”引用计数”,因此发生了”写时复制”,父子进程中a变量地址指向的物理地址也不同。

    如果你有兴趣,可以将测试代码的a = b"abc"修改成a = b"a" * 1024 * 1024 * 1024,然后观察一下a变量的1G内存,就能发现父子进程的a变量只有第一个物理页是不同的,其他物理页都是相同的。

  • subprocess.Popenmultiprocessing.Process区别

    做完上面实验我体会的差别:subprocess.Popen会调用execve系统调用,这个系统调用应该会将”页表映射”关系都换掉,所以父进程和子进程的内存没啥关系。

总结

  • 多进程执行java -jar xxx.jar或者python xxx.py时,需要注意xxx.jarxxx.py的大小会对内存占用有影响
  • fork、clone系统调用都有”写时复制”机制,可以用crash工具来观察这个机制;”写时复制”可以节约物理内存
  • multiprocessing.Process生成子进程时,有可能因为”引用计数”被修改,所以子进程存储变量实例的第一个物理页可能和父进程不同
  • subprocess.Popen会调用execve系统调用,这个系统调用应该会将”页表”都换掉,所以父进程和子进程的内存没啥关系

在研究这个问题的过程中,我了解了CPython对象的数据结构、写时复制,希望你也有收获。

关于gdb的使用,你可以看文档


更正

上面文章中下面的结论有部分错误:

  • 多进程执行java -jar xxx.jar或者python xxx.py时,需要注意xxx.jarxxx.py的大小会对内存占用有影响

实际上java -jar xxx.jarpython xxx.py还是有些不同:xxx.jar是jvm通过mmap系统调用映射”共享文件页”到内存中,所以多个进程会共享同一份内存; xxx.py并不会被python解释器通过mmap做”文件页”映射。

image

image

原文中因为自己想当然地以为”jvm”会和”python解释器”一样,偷了点懒就没有动手验证,所以得出错误的结论。

PS:想问一下有没有读者愿意帮我校对文章内容?可以在公众号聊天框发消息给我

问题背景

  • 危险的属性(可以用作xss攻击)
    • href属性
    • src属性
    • on事件属性
    • srcdoc属性
    • xlink:href
    • action
    • formaction
    • data (object标签)

假设攻击场景如下:

1
2
3
4
5
6
7
xss漏洞输出位置如下:

<div>用户输出可控位置</div>

攻击者利用标签的危险属性执行JS,比如:

<div><a href="javascript:alert('xss');"></div> 利用href属性

验证在这个场景下厂商的安全防护策略

分析思路:

  1. 针对每个危险属性分别测试厂商的防护
  2. 改变payload,根据拦截情况推测安全防护策略

分析过程

挨个测试每个属性

  • href属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
a=<xx href="javascript:xxxaaaaa"> 拦截
a=<xx id="javascript:xxxaaaaa"> 不拦截
a=<xx idxx="javascript:xxxaaaaa"> 不拦截
说明和属性名称有关系,有一个黑名单属性list。href属性名称会进入拦截策略

a=<xx href="javascript:xxxaaaaa"> 拦截
a=<xx href="javascriptxxx:xxxaaaaa"> 不拦截
a=<xx href="javascriptxxx:alert(1)"> 不拦截
a=<xx href="xjavascript:alert(1)"> 不拦截

a=<xx href="javascript:a"> 拦截
a=<xx href="javascript:"> 不拦截
a=<xx href="javascript:a{"> 拦截


说明和属性值也有关系,href属性值 必须以javascript:开头,且后面跟随至少一个字符串。 这里属性值并没有判断JS语法正确性

小结:任意标签 && href属性 && href属性值匹配 javascript:[\s\S]+ 则拦截

  • src属性
1
2
3
4
5
a=<xxxx/src="data:text/html;x">  拦截
a=<xxxx/src="data:text/htmlx;x"> 不拦截 说明属性值一定以 data:text/html; 开头
a=<xxxx/src1="data:text/html;x"> 不拦截 说明属性名一定是 src

a=<xxxx/src="data:text/html;1"> 拦截 说明 data:text/html; 一定要有一个字符

小结:任意标签 && src属性 && src属性值匹配 data:text/html;[\s\S]+ 则拦截

  • srcdoc属性
    1
    2
    a=<xxxx/srcdoc=xxx>  拦截
    a=<xxxx/srcdoc=xx> 不拦截

小结:任意标签 && srcdoc属性 && srcdoc属性值长度大于等于3 则拦截

  • data、formaction、action、xlink:href属性
    1
    2
    3
    4
    5
    6
    7
    a=<xxx/data=javascript:x>   拦截

    a=<xxx/dataxxx=javascript:x> 不拦截

    a=<xxx/data=javascriptxx:x> 不拦截

    a=<xxx/data=javascriptxx:> 不拦截

小结:

这几个属性和前面的href属性类似

任意标签 && data属性 && data属性值匹配 javascript:[\s\S]+ 则拦截


  • 可以得出来的结论
    可以得出结论,策略如下:
  • 任意标签 && (href属性||data属性|formaction属性||action属性||xlink:href) && 相应属性值匹配 javascript:[\s\S]+ 则拦截
  • 任意标签 && src属性 && src属性值匹配 data:text/html;[\s\S]+ 则拦截
  • 任意标签 && srcdoc属性 && srcdoc属性值长度大于等于3 则拦截

验证其他猜想

  • 属性名和属性值是否分开验证?

    以下payload全部拦截

    1
    2
    3
    4
    5
    <xxx/data="data:text/html;x">
    <xxx/action="data:text/html;x">
    <xxx/formaction="data:text/html;x">
    <xxx/href="data:text/html;x">
    <xxx/xlink:href="data:text/html;x">

    可以看出来,只要属性值和属性名称命中防护规则,不论属性是否真的可以造成攻击,都会被拦截。

    所以得出结论:属性名和属性值是分开验证的,安全防护策略如下:

    • 任意标签 && (href属性||data属性|formaction属性||action属性||xlink:href属性|src属性) && 相应属性值匹配 javascript:[\s\S]+data:text/html;[\s\S]+ 则拦截

总结

可能的安全防护策略如下

  • 任意标签 && (href属性||data属性|formaction属性||action属性||xlink:href属性|src属性) && 相应属性值匹配 javascript:[\s\S]+data:text/html;[\s\S]+ 则拦截
  • 任意标签 && srcdoc属性 && srcdoc属性值长度大于等于3 则拦截