白嫖 飞书应用消息推送 代替付费短信认证

6,921 阅读14分钟

背景: 动态码认证是各类应用常用的认证方法,常见的有两种: OTP动态码 和 手机短信码。 但对于企业来说. 这两种方式实现起来都不够灵活方便, OTP需要手动绑定和解绑密钥, 手机验证码还需要去找短信服务商开通短信付费服务。 有没有一种又简单,又白嫖的方法呢? 答案是肯定的, 我们可以利用办公社交软件的 消息推送功能 来充当动态码推送。 对于企业来说,不光省去了管理 OTP 密钥的麻烦, 而且还省了一笔短信服务费用;对于用户来说也省去了打开 身份令牌APP 的操作,直接在 办公通讯APP(如飞书, 企业微信,钉钉等)操作即可, 可谓是一举多得。看完此贴,你将有能力使用飞书机器人推送消息开发一个动态码认证服务,并将使用 著名的渗透测试工具 Burp Suite 进行验证码轰炸 和 验证码爆破 的渗透性测试

飞书创建应用并发布

首先你需要登入 飞书开放平台, 这里以个人身份登录即可,不需要你有企业账号。

点击右上角的开发者后台

1709864624382.jpg

选择创建企业自建应用

1709864815291.jpg

填写好应用名称,描述,图标

f8e6d785cd2e1695fab27f8fc5b3c25.png

记录好 APPID 和 APP Secret 0f0a2c966443f58e652340fcfc5079a.png

添加应用能力,确保已添加机器人

195b493b88efbabc5c75b41efb8ee74.png

点击权限控制, 确保通过手机号(注册飞书用的手机号)可以获取到用户ID 权限打开 cc0bf30e946831a6febaec568e03767.png

确保以应用身份发送消息权限打开 3c526b62e56d2a5d455499ce85a841e.png

点击管理控制与发布,点击创建版本

e486d5540f9aca5198381c54f5ca5c2.png

填写好版本号和说明(都由自己定义,只是个标记而已),保存好以后发布即可 86e595549cc45ca1ee11fffb3f457a2.png

自此飞书方面已经准备完毕,下面是

Docker 部署 redis

我们使用 docker 部署一个简单的 redis 单节点容器, 直接命令行运行。

docker run  --name some-redis  -p 6379:6379  redis:latest

如果对 docker 不熟悉的同学可以看看我的另外两篇博客: Docker 部署 Nginx 实现一个极简的 负载均衡保姆级从0到1讲解go远程开发环境搭建(从Docker安装到使用Goland远程部署和调试

运行成功后 使用

docker ps 

应该可以看到下面这种输出:

CONTAINER ID    IMAGE             COMMAND                CREATED       STATUS         PORTS                  NAMES
467cc414d3e5   redis:latest   "docker-entrypoint.s…"   9 hours ago   Up 9 hours   0.0.0.0:6379->6379/tcp   some-redis

代码调用 飞书应用

新建工程

创建 feishu_msg_auth 文件夹,命令行输入

go mod init feishu_msg_auth
go mod tidy

随后创建各目录与文件如下:

feishu_msg_auth

    -- feishu_msg
        -- feishu_msg.go  发送飞书验证码, 校验飞书验证码
        
    -- redis_client
        -- redis_client.go, 初始化 redis 连接 , 后续使用分布式锁防止动态码轰炸那盒爆破时 需要用到  
        
    -- session_store
        -- session_store.go, 初始化 redis session
        
    -- main
        -- main.go 主函数

    go.mod
        go.sum


main.go

主函数非常简单, 初始化了 redis session store, 我们的动态码将会存于session,session最终存于 redis 中。初始化了一个 redis client, redis client 内部以及维护了一个连接池,后需防止动态码轰炸, 动态码爆破的时候需要用到 分布式锁, 需要用到 redis 连接。最后注册了两个路由,粉笔是发送动态码 和 校验动态码的 handler.

package main

import (
   "feishu_msg_auth/feishu_msg"
   "feishu_msg_auth/redis_client"
   "feishu_msg_auth/session_store"

   "net/http"
)

func main() {

   // init redis session store
   err := session_store.InitRedisStore()
   if err != nil {
      panic(err)
   }

   // init redis client
   err = redis_client.InitRedisClient()
   if err != nil {
      panic(err)
   }

   // route for send code and verify code
   http.HandleFunc("/sendFeishuMsg", feishu_msg.SendFeishuMsgHandler)
   http.HandleFunc("/verifyFeishuMsg", feishu_msg.VerifyMsgHandler)
   go func() {
      http.ListenAndServe(":2000", nil)
   }()

   select {}

}

redis_client.go

一个简单的 redis client 初始化函数

package redis_client

import (
   "errors"
   "github.com/redis/go-redis/v9"
)

var RedisClient *redis.Client

func InitRedisClient() error {
   client := redis.NewClient(&redis.Options{
      Network: "tcp",
      Addr:    "localhost:6379",
   })

   if client == nil {
      return errors.New("client==nil")
   }

   RedisClient = client
   return nil

}

session_store.go

我们初始化了一个 session store, 以后存入和取出操作都由 seesion store 完成。 存取的seesion 内容位 FeishuMsgClaim 类型,里面包含了三个字段, 消息(也就是动态码)的发送时间, 动态码, 动态码校验错误次数counter。session 的 key 以 auth_session: 开头. session 对应的 cookie 使用 lax 模式, 有效期1800秒, 对同一个 host 下所有路径生效。

package session_store

import (
   "encoding/gob"
   "fmt"
   "github.com/go-redis/redis"
   "github.com/gorilla/sessions"
   "github.com/rbcervilla/redisstore"
   "net/http"
)

type FeishuMsgClaim struct {
   LastTime    int64 // timestamp
   Code        string
   FailedCount uint8
}

var SessionStore *redisstore.RedisStore

func InitRedisStore() error {
   client := redis.NewUniversalClient(&redis.UniversalOptions{
      Addrs: []string{"localhost:6379"},
   })

 
   store, err := redisstore.NewRedisStore(client)
   if err != nil {
      fmt.Println("failed to create redis store: ", err)
      return err
   }

 
   store.KeyPrefix("auth_session:")
   store.Options(sessions.Options{
      Path:     "/",
      SameSite: http.SameSiteLaxMode,
      MaxAge:   1800,
   })

   gob.Register(FeishuMsgClaim{})

   SessionStore = store

   return nil
}

feishu_msg.go

feishu_msg.go 详细展示了如何请求飞书发送消息(也就是动态码),然后校验动态码这两个功能。我们定义了几个结构体, 分别是:

  1. SendFeishuMsgReq, 请求发送验证码请求结构, 传入手机号, 实际应用中可以传入用户名再查询手机号,我这里怎么简单怎么来。
  2. VerifyFeishuMsgReq, 请求校验验证码请求结构, 传入手机号和动态码, 实际应用中可以不传入手机号, 因为在前序过程中可以把手机号也存入 session中,我这里怎么简单怎么来。
  3. FeishuUserInfoResp, UserData,User 结构体,都是飞书返回的用户信息结构体, 根据手机号, 应用ID,应用Secret 请求飞书,返回用户信息,获取UserId.

以及定义了额外的3个常量, 分别是 feishuMsgCoolTime 代表的是发送消息的冷却时间,在冷却时间内无法再次发送消息; feishuMsgExpire 代表的是消息(也就是动态码)过期的时间; feishuMsgMaxFailCount 代表的是最大尝试校验动态码的错误次数

SendFeishuMsgHandler 作为处理发送消息(动态码)的handler, 一进来检查方法,解析请求,接着尝试获取分布式锁。随后查看是否请求处于冷却时间内, 接着根据手机号, 应用ID,应用Secret 请求飞书,返回用户信息,获取UserId,然后根据userid 再次请求飞书发送验证码。最后将动态码(包括获取的时间, 动态码本身, 错误次数初始值)存入session。有人问一定要获取分布式锁吗? 不是已经有了从 session中获取冷却时间判断是否处于冷却时间内的操作吗? 从现在的代码上说确实不需要获取 redis 分布式锁, 但实际上可能还会有其他操作,比如 session 存于 mysql 中,请求参数不是手机而是账号,根据账号去mysql 中查询手机,这些额外的操作都会消耗IO/CPU性能。如果有攻击者短时间内发送大量的请求过来,可能把中间的某一部分给打挂掉。

在函数中有这么一句:

option = append(option, lark.WithOpenBaseUrl("https://open.feishu.cn"))

其中的 open.feishu.cn, 代表的是你飞书的域名, 比如我使用的就是飞书开发者平台, 对应的就是 open.feishu.cn, 这是默认的域名。 如果你是在大公司, 你的飞书是私有化部署的, 那么就填私有化飞书的域名。

VerifyMsgHandler 作为处理发送消息(动态码)的handler, 一进来检查方法,解析请求,接着尝试获取分布式锁。随后从session 中拿出动态码进行校验,如果在验证码有效期内且校验成功且失败次数 < feishuMsgExpire返回登录成功,如果校验失败则会记录失败次数, 如果错误次数 >= feishuMsgExpire, 则会返回动态码超时。注意如果超过feishuMsgExpire次,不应该删除 session, 否则获取不到错误次数已经超过feishuMsgExpire的信息。

我们不通过 defer 来释放分布式锁,是因为我们就是想进一步的降低请求的频率,将请频繁的请求尽早地拦截下来,以免对后续的步骤造成压力。一般用户看到验证码到输入验证码是超过3秒的,或者用户看错了,再看一次动态码,再次输入,一般也超过3秒, 所以3秒是一个合情的时间,

package feishu_msg

import (
   "context"
   "crypto/rand"
   "encoding/json"
   "feishu_msg_auth/redis_client"
   "feishu_msg_auth/session_store"
   "fmt"
   "github.com/bsm/redislock"
   "github.com/gorilla/sessions"
   lark "github.com/larksuite/oapi-sdk-go/v3"
   larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
   larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
   "io"
   "math/big"
   "net/http"
   "time"
)

type SendFeishuMsgReq struct {
   Phone string `json:"phone"`
}

type VerifyFeishuMsgReq struct {
   Phone string `json:"phone"`
   Code  string `json:"passwd"`
}

type FeishuUserInfoResp struct {
   Code int      `json:"code"`
   Msg  string   `json:"msg"`
   Data UserData `json:"data"`
}

type UserData struct {
   UserList []User `json:"user_list"`
}

type User struct {
   UserId string `json:"user_id"`
   Email  string `json:"email"`
   Mobile string `json:"mobile"`
}

const feishuMsgCoolTime = 30

const feishuMsgExpire = 300

const feishuMsgMaxFailCount = 3

func SendFeishuMsgHandler(w http.ResponseWriter, r *http.Request) {

   // check method
   if r.Method != http.MethodPost {
      w.Write([]byte("Expect Post method"))
      return
   }

   // parse body
   bodyBytes, err := io.ReadAll(r.Body)
   if err != nil {
      fmt.Println("Fail to read request body")
      w.Write([]byte("Fail to read request body"))
      return
   }

   var sendReq SendFeishuMsgReq
   err = json.Unmarshal(bodyBytes, &sendReq)
   if err != nil {
      fmt.Println("Fail to convert request body into  SendSmsReq ")
      w.Write([]byte("Fail to convert request body into  SendSmsReq "))
      return
   }

   // try obtaining distributed lock
   locker := redislock.New(redis_client.RedisClient)
   ctx := context.Background()

   _, err = locker.Obtain(ctx, "send_feishu_msg:"+sendReq.Phone, 3*time.Second, nil)
   if err == redislock.ErrNotObtained {
      fmt.Println("Could not obtain send_feishu_msg lock!")
      w.Write([]byte("req too frequent"))
      return
   } else if err != nil {
      fmt.Println("obtain send_feishu_msg lock err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   // Get session
   session, err := session_store.SessionStore.Get(r, "auth")
   if err != nil {
      fmt.Println("failed getting session: ", err)
      w.Write([]byte("failed getting session"))
      return
   }

   // check if too frequency
   claim, ok := session.Values[sendReq.Phone+":feishu_msg"]
   if ok {

      feishuMsgClaim, ok := claim.(session_store.FeishuMsgClaim)
      if ok {
         waitTime := feishuMsgClaim.LastTime + feishuMsgCoolTime - time.Now().Unix()
         if waitTime > 0 {
            fmt.Println("req too frequent")
            w.Write([]byte("req too frequent"))
            return
         }
      }
   }

   // create feishu client

   var option = []lark.ClientOptionFunc{lark.WithEnableTokenCache(true)}
   option = append(option, lark.WithOpenBaseUrl("https://open.feishu.cn"))
   feishuClient := lark.NewClient("cli_a59bb317db37100e", "xxxxxx", option...) // replace the app secret of your app

   // get user info
   var userInfoReq *larkcontact.BatchGetIdUserReq
   userInfoReq = larkcontact.NewBatchGetIdUserReqBuilder().Body(larkcontact.NewBatchGetIdUserReqBodyBuilder().Mobiles([]string{sendReq.Phone}).Build()).Build()

   batchGetIdUserResp, err := feishuClient.Contact.User.BatchGetId(context.Background(), userInfoReq)
   if err != nil {
      fmt.Println("get user info failed, err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   if !batchGetIdUserResp.Success() {
      fmt.Println("!userInfoResp.Success()")
      w.Write([]byte("internal err"))
      return
   }

   // parse user info
   var userInfoResp FeishuUserInfoResp
   err = json.Unmarshal(batchGetIdUserResp.RawBody, &userInfoResp)
   if err != nil {
      fmt.Println("json.Unmarshal(userInfoResp.RawBody,&userInfoResp) err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   // generate 6 digits
   rnd, err := rand.Int(rand.Reader, big.NewInt(999999))
   if err != nil {
      fmt.Println("generate random number:", err)
      return
   }
   code := fmt.Sprintf("%06d", rnd)

   // 发送飞书验证码
   msgContent := fmt.Sprintf("[XXXXX] Your code is %s, please subimit in %d seconds.", code, feishuMsgExpire)
   msgContent = fmt.Sprintf("{"text":"%s"}", msgContent)

   sendMsgReq := larkim.NewCreateMessageReqBuilder().ReceiveIdType("open_id").
      Body(larkim.NewCreateMessageReqBodyBuilder().ReceiveId(userInfoResp.Data.UserList[0].UserId).MsgType("text").Content(msgContent).Build()).Build()

   createMessageResp, err := feishuClient.Im.Message.Create(context.Background(), sendMsgReq)
   if err != nil {
      fmt.Println("feishuClient.Im.Message.Createerr = ", err)
      w.Write([]byte("internal err"))
      return
   }

   if !createMessageResp.Success() {
      fmt.Println("!createMessageResp.Success()")
      w.Write([]byte("internal err"))
      return
   }

   // save random code into session
   msgClaim := session_store.FeishuMsgClaim{
      LastTime:    time.Now().Unix(),
      Code:        code,
      FailedCount: 0,
   }

   session.Values[sendReq.Phone+":feishu_msg"] = msgClaim

   err = sessions.Save(r, w)
   if err != nil {
      fmt.Println("save seesion err: ", err)
      w.Write([]byte("save seesion err"))
      return
   }

   // retuen random code
   fmt.Printf("send code successfully, cooli_down time is %d  \n", feishuMsgCoolTime)
   w.Write([]byte(fmt.Sprintf("send code successfully, cooli_down time is %d  \n", feishuMsgCoolTime)))

}

func VerifyMsgHandler(w http.ResponseWriter, r *http.Request) {

   // check method
   if r.Method != http.MethodPost {
      w.Write([]byte("Expect Post method"))
      return
   }

   // parse body
   bodyBytes, err := io.ReadAll(r.Body)
   if err != nil {
      fmt.Println("Fail to read request body")
      w.Write([]byte("Fail to read request body"))
      return
   }

   var verifyReq VerifyFeishuMsgReq
   err = json.Unmarshal(bodyBytes, &verifyReq)
   if err != nil {
      fmt.Println("Fail to convert request body into LoginReq ")
      w.Write([]byte("Fail to convert request body into LoginReq "))
      return
   }

   // try obtain distributed lock
   locker := redislock.New(redis_client.RedisClient)
   ctx := context.Background()

   _, err = locker.Obtain(ctx, "verify_feishu_msg:"+verifyReq.Phone, 3*time.Second, nil)
   if err == redislock.ErrNotObtained {
      fmt.Println("Could not obtain verify_feishu_msg lock!")
      w.Write([]byte("req too frequent"))
      return
   } else if err != nil {
      fmt.Println("obtain verify_feishu_msg lock err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   // get session
   session, err := session_store.SessionStore.Get(r, "auth")
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   claim, ok := session.Values[verifyReq.Phone+":feishu_msg"]
   if !ok {
      fmt.Println("Failed to get session. ")
      w.Write([]byte("Sms Code is expire. "))
      return
   }

   feishuMsgClaim, ok := claim.(session_store.FeishuMsgClaim)
   if !ok {
      fmt.Println("claim.(session_store.FeishuMsgClaim) err")
      w.Write([]byte("Sms Code is expire. "))
      return
   }

   if time.Now().Unix()-feishuMsgClaim.LastTime > feishuMsgExpire {
      fmt.Println("time.Now().Unix()-feishuMsgClaim.LastTime > feishuMsgExpire")
      w.Write([]byte("Sms Code is expire. "))
      return
   }

   if verifyReq.Code == feishuMsgClaim.Code && feishuMsgClaim.FailedCount < feishuMsgMaxFailCount {

      // verify code successfully,delete the code in seesion
      delete(session.Values, verifyReq.Phone+":feishu_msg")
      err = session.Save(r, w)
      if err != nil {
         w.Write([]byte("Failed to save session"))
         fmt.Println(err)
         return
      }

      // login successfully
      fmt.Println("Login Success ")
      w.Write([]byte("Login Success "))
      return
   }

   // code not match and try number less than feishuMsgMaxFailCount
   if feishuMsgClaim.FailedCount < feishuMsgMaxFailCount {
      feishuMsgClaim.FailedCount++

      session.Values[verifyReq.Phone+":feishu_msg"] = feishuMsgClaim
      err = session.Save(r, w)
      if err != nil {
         w.Write([]byte("Failed to save session"))
         fmt.Println(err)
         return
      }

      fmt.Printf("code not match, failed count < %d \n", feishuMsgMaxFailCount)
      w.Write([]byte("code not match"))
      return
   } else {
      fmt.Println(fmt.Sprintf("code not match, failed count >= %d \n", feishuMsgMaxFailCount))
      w.Write([]byte("code expired"))
      return
   }

}

使用 Postman 测试

main 目录下 :

go run main.go 

随后使用 postman 测试一下

发送成功返回

1709921963274.png

返回的 cookie

1709922034496.png

发送过于频繁

1709922081606.png

校验失败返回

1709922139711.png

校验成功返回

1709922245500.png

错误次数>3次

1709922172361.png

此时命令行应该也会打印错误信息如下:

code not match, failed count >= 3 

发送成功后你的飞书上应该会收到下面这条消息:

38724f42dc0cc97b7229e804aab82f4.jpg

使用 Burp Suite 进行渗透测试

下面我们将使用 著名的渗透测试工具Burp Suite对所写的动态码服务进行 动态码轰炸(也可以称作是短信轰炸, 短时间内请求发送大量消息) 和 动态码爆破的(尝试通过枚举,暴力破解动态码) 测试。

配置 postman 代理

首先我们更改postman的代理配置:

1709917391312.png 这将会把 postman 的请求转发到 127.0.0.1(localhost)的 2001端口

安装 Burp Suite

前往 Burpsuite 官网下载对应版本,正常安装即可

打开后选择 Temporary project in memory:

1709886913957.png

再选择 Use Burp defaults

1709887477479.png

配置 burpsuite 代理

1709917515121.png

1709917543356.png

打开 Burp Suite 拦截器

1709917966694.png

进行 动态码轰炸测试

还是像之前一样,使用 postman 发送请求 1709921963274.png

随后在 BurpSuite 应该可以看到 拦截到了此次请求 1709918052691.png

将拦截下来的请求送入 Intruder 1709918319092.png

1709918353445.png

将 payload 设置为 null, 所谓 null 就是不对请求进行任何修改, 只是单纯地重复发起请求。 配置好以后, 点击右上角的 Start Attack。

1709920461417.png

查看 动态码轰炸 结果

1709920494326.png

可以看到发送了11次请求, 只有第一次是请求成功的,其他次数要么是 获取不到 分布式锁而报错请求过于频繁,要么是处于动态码发送冷却时间内 报错请求过于频繁,符合预期

1709920522906.png

控制台输出:

send code successfully, cooli_down time is 30  
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
req too frequent
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!

动态码爆破测试

还是和之前一样,使用postman发送请求校验短信的请求

1709922139711.png

随后将拦截器中记录到的请求送入 intruder. 和之前的payload 不一样,我们这里需要对 password 字段进行选择,并点击 右边的Add 按钮, 这将会在接下来的 payload 配置中对 password 字段进行替换,从而实现一个个枚举暴力破解动态码。

1709922437150.png

在 payload 设置中,payload set 选择为1,代表只对一个地方进行替换, payload type 选择 simple list, 表示使用一个 列表逐个对请求进行替换。

1709943887688.png

下面的 payload settings 我导入了一个 txt 文件, 文件内容如下:

000001
000002
000003
000004
000005
000006
000007
000008
000009
000010
000011
000012
000013
000014
000015
000016
000017
000018
000019
000020
000021
000022
000023
000024
000025
000026
000027
000028
000029

文件内容很简单, 简单列举了从000000到000029, 代表 password字段会从 000000被替换到000029. 配置好以后点击右上角 Start Attack 发起攻击,观察结果。

第一次返回动态码错误(被拦截下来的那一次) 1709922552642.png

第二次错误码请求

1709944273784.png 可以看到已经被换成立 payload 中的001了, 返回 请求过于频繁。

第11次返回动态码错误

1709922825018.png

在第1次到底14次只会返回 请求过于频繁(获取不到分布式锁, 通过后台命令行日志输出 Could not obtain verify_feishu_msg lock! 可验证 ),或者是动态码错误。

第15次返回错误码过期(错误次数等于3次)

1709922846934.png

在此之后只会返回 请求过于频繁(获取不到分布式锁, 通过后台命令行日志输出 Could not obtain verify_feishu_msg lock! 可验证 ),或者是动态码过期(通过后台命令行日志输出 code not match, failed count >= 3 可验证)。 可加, 完全符合预期。

特别的是之所以我们不主动释放分布式锁,是因为我们就是想进一步的降低请求的频率,将请频繁的请求尽早地拦截下来,以免对后续的步骤造成压力。3秒是一个合情的时间,一般用户看到验证码到输入验证码是超过3秒的。

踩坑经历

使用 Burpsuite 代理 Postman 最大的坑莫过于 Burpsuite 已经拦截到了, 但是在 Burpsuite 的 interceptor 里面看不到记录,而是直接返回一个 html 页面给 postman. 把这个HTML 代码拷贝出来用浏览器打开,大概长这个样子:

1709956601676.png

这是因为请求的链路形成了环路所致。比如我本地的HTTP服务监听2000接口, postman 将请求也转发到 2000端口,被 burpsuite 拦截下来了,继续转发到 2000端口,又被 burpsuite 拦截下来了, 进入了死循环。 正确的做法是将 postman proxy 设置和 burpsuite 拦截器监听的设置 都换一个端口,比如我换成了 20001 端口,即可解决问。

巨人的肩膀:

  1. open.feishu.cn/document/se…
  2. github.com/larksuite/o…
  3. github.com/redis/go-re…
  4. github.com/rbcervilla/…