例如这是一段简单的go商城的示例代码
package handlers
import (
"go2shop/internal/database"
"go2shop/internal/models"
"net/http"
"github.com/gin-gonic/gin"
)
...
func BuyProduct(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
ProductID uint `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 查找用户
var user models.User
if err := database.DB.Preload("Inventory").Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
return
}
// 查找商品
var product models.Product
if err := database.DB.First(&product, req.ProductID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Product not found"})
return
}
// 检查库存和余额
totalPrice := float64(req.Quantity) * product.Price
if user.Balance < totalPrice {
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient balance"})
return
}
if product.Stock < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock"})
return
}
// 更新用户余额和商品库存
user.Balance -= totalPrice
product.Stock -= req.Quantity
// 更新用户的库存
var inventoryItem models.InventoryItem
if err := database.DB.Where("user_id = ? AND product_id = ?", user.ID, product.ID).First(&inventoryItem).Error; err == nil {
inventoryItem.Quantity += req.Quantity
database.DB.Save(&inventoryItem)
} else {
newInventoryItem := models.InventoryItem{
UserID: user.ID,
ProductID: product.ID,
Quantity: req.Quantity,
}
database.DB.Create(&newInventoryItem)
}
database.DB.Save(&user)
database.DB.Save(&product)
c.JSON(http.StatusOK, gin.H{"message": "Purchase successful"})
}
...
main.go中:
...
r.POST("/buy", handlers.BuyProduct)
乍看之下没有任何问题,但其实由于Gin框架是多线程架构,就存在条件竞争的漏洞。也就是说,假设用户同时发送了两个一样的请求到服务器,多线程的Gin框架会同时进行:if user.Balance < totalPrice {
的条件判断,这个时候就会两个请求都会认为库存足够(10 >= 10)。然后两个请求分别扣减库存,最终商品库存会变为 -10,而实际上应该拒绝第二次购买。
然后两个线程会同时执行数据库的UPDATE的操作,
if err := database.DB.Where("user_id = ? AND product_id = ?", user.ID, product.ID).First(&inventoryItem).Error; err == nil {
inventoryItem.Quantity += req.Quantity
database.DB.Save(&inventoryItem)
} else {
newInventoryItem := models.InventoryItem{
UserID: user.ID,
ProductID: product.ID,
Quantity: req.Quantity,
}
database.DB.Create(&newInventoryItem)
}
就会导致只付了一碗粉的钱却吃了商家两碗粉。
这个红色部分就是造成漏洞的过程,可以看到库存被更新了两次
我这里使用的是BurpSuite的代理插件 Turbo
使用如下并发脚本:
def queueRequests(target, wordlists):
global BATCH_SIZE
BATCH_SIZE = 60
engine = RequestEngine(endpoint='http://172.16.0.96:8888',
concurrentConnections=BATCH_SIZE,
requestsPerConnection=100,
engine=Engine.THREADED,
pipeline=False,
maxQueueSize=BATCH_SIZE
)
req = '''POST /buy HTTP/1.1
Host: 172.16.0.96:8888
Connection: keep-alive
Content-Length: 51
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090c11) XWEB/11581 Flue
Content-Type: application/json
Accept: */*
Origin: http://172.16.0.96:8888
Referer: http://172.16.0.96:8888/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
x:%s
{"username":"testuser","product_id":1,"quantity":1}'''
for i in range(10):
gate_id = str(i)
for x in range(BATCH_SIZE):
engine.queue(req, '0.000', gate=gate_id)
engine.openGate(gate_id)
time.sleep(0.5)
def handleResponse(req, interesting):
xtime= req.response.split('\r\n\r\n')[1]
req.label = xtime
table.add(req)
def completed(reqsFromTable):
diffs = []
time.sleep(1)
print len(reqsFromTable)
for i in range(len(reqsFromTable)):
if i % BATCH_SIZE != 0:
continue
entries = []
for x in range(BATCH_SIZE):
entries.append(float(reqsFromTable[i+x].label))
entries.sort()
diffs.append(entries[-1] - entries[0])
diffs.sort()
print('Best: '+str(min(diffs)))
print('Mean: '+str(mean(diffs)))
print('Stddev: '+str(stddev(diffs)))
print('Median: '+str(diffs[len(diffs)/2]))
print('Range: '+str(max(diffs)-min(diffs)))
handler.setMessage(str(sum(diffs)/len(diffs)))
这里就出现了不止一次的成功响应
那作为开发者该如何防止这种情况的发生呢?
线程锁就是在同时只允许一个线程删改数据,具体实现机制分为读写锁和互斥锁,这两种锁的区别在于:
这里我们应该采用互斥锁来保证安全:
func BuyProduct(c *gin.Context) {
var req struct {
ProductID uint `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 从 JWT 中获取用户名
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// 查找用户
userMutex.Lock() // 加锁保护用户资源
var user models.User
if err := database.DB.Preload("Inventory").Where("username = ?", username.(string)).First(&user).Error; err != nil {
userMutex.Unlock()
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
return
}
// 查找商品
productMutex.Lock() // 加锁保护商品资源
var product models.Product
if err := database.DB.First(&product, req.ProductID).Error; err != nil {
productMutex.Unlock()
userMutex.Unlock()
c.JSON(http.StatusBadRequest, gin.H{"error": "Product not found"})
return
}
// 检查库存和余额
totalPrice := float64(req.Quantity) * product.Price
if user.Balance < totalPrice {
productMutex.Unlock()
userMutex.Unlock()
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient balance"})
return
}
if product.Stock < req.Quantity {
productMutex.Unlock()
userMutex.Unlock()
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock"})
return
}
// 更新用户余额和商品库存
user.Balance -= totalPrice
product.Stock -= req.Quantity
// 更新用户的库存
var inventoryItem models.InventoryItem
if err := database.DB.Where("user_id = ? AND product_id = ?", user.ID, product.ID).First(&inventoryItem).Error; err == nil {
inventoryItem.Quantity += req.Quantity
database.DB.Save(&inventoryItem)
} else {
newInventoryItem := models.InventoryItem{
UserID: user.ID,
ProductID: product.ID,
Quantity: req.Quantity,
}
database.DB.Create(&newInventoryItem)
}
// 保存更改
database.DB.Save(&user)
database.DB.Save(&product)
productMutex.Unlock() // 解锁商品资源
userMutex.Unlock() // 解锁用户资源
c.JSON(http.StatusOK, gin.H{"message": "Purchase successful"})
}
具体流程图:
这个就很好理解了,就是鉴权不充分嘛
例如有的新手开发者(比如我)就会直接从用户传参中获取username从而判断具体是那个用户来操作:
还是回到刚刚buyProducts
的hanlder的例子:
func BuyProduct(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
ProductID uint `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"`
}
var user models.User
if err := database.DB.Preload("Inventory").Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
return
}
这里就是通过用户传参来判断是那个用户来购买了那样东西,所以假设A用户传参了如下payload:
{"username":"B","product_id":1,"quantity":1}
他就会帮助B用户买id为1的商品了,还有一个更有危害的例子,比如返回个人信息的接口是通过用户传参来判断的话,那是否可以通过遍历用户id从而获取到所有人的身份信息呢?答案是肯定的
这个问题的核心是太过信任用户的传参了,常见的解决方式是通过校验的cookie,比如jwt(json web token
)来实现身份的判定,比如我这里创建一个用于校验的中间件:
package middleware
import (
"go2shop/internal/models"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
)
// 定义全局变量 jwtKey
var jwtKey []byte
// 初始化函数,用于从环境变量中加载 JWT 密钥
func init() {
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-key"
}
jwtKey = []byte(jwtSecret)
}
// AuthMiddleware 是一个 Gin 的中间件,用于验证 JWT
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 从请求头中获取 Authorization 字段
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// 提取 token
tokenStr := parts[1]
claims := &models.Claims{}
// 解析 JWT
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
// 验证签名算法是否为 HS256
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrInvalidKeyType
}
return jwtKey, nil
})
// 检查解析是否成功以及 token 是否有效
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// 将用户信息存入上下文,供后续处理使用
c.Set("username", claims.Username)
c.Next()
}
}
然后在main.go中使用它:
func main() {
// 初始化数据库
database.InitDatabase()
// 初始化 Gin 引擎
r := gin.Default()
r.LoadHTMLGlob("templates/*")
// 路由
r.POST("/register", handlers.RegisterUser)
r.GET("/register", func(c *gin.Context) {
c.HTML(http.StatusOK, "register.html", gin.H{
"title": "用户注册",
})
})
r.POST("/login", handlers.LoginUser)
r.GET("/login", func(c *gin.Context) {
c.HTML(http.StatusOK, "login.html", gin.H{
"title": "用户登录",
})
})
auth := r.Group("/store")
auth.Use(middleware.AuthMiddleware())
{
r.GET("/products", handlers.GetProducts)
r.POST("/buy", handlers.BuyProduct)
r.POST("/sell", handlers.SellProduct)
r.POST("/getflag", handlers.GetFlag)
r.POST("/getbalance", handlers.GetUserBalance)
r.POST("/getinventory", handlers.GetUserInventory)
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "store.html", gin.H{
"title": "商城",
})
})
}
// 启动服务器
r.Run(":8080")
}
这里像是在store后面的路由就是受到保护的路由。
但是你以为这就完了?
虽然go的gin框架没有这个漏洞,但是这个洞还是挺有意思的(没想到吧,目录穿越不止可以用来读取文件)。
就比如早期版本的apache shiro:
奇安信攻防社区-浅谈Apache shiro 权限绕过漏洞(CVE-2023-34478)
大致原理就是规范路由在鉴权之后,导致鉴权失效。比如:
GET /login/../admin/dashboard
在鉴权中间件看来:/login
开头,嗯,是普通路由,过。
但是规范后:
GET /admin/dashboard
就是后台地址了,这样就绕过鉴权了
这其中就包括水平越权和垂直越权,其实二者是一回事,就是后端鉴权不充分的,也就是鉴了但是没有完全鉴,俗称后端开发有点鉴,我来举个例子:
例如这里有个专门重置密码的api:
func UpdatePassword(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"` // 用户名
NewPassword string `json:"new_password" binding:"required"` // 新密码
}
// 绑定 JSON 数据
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 查找用户
var user models.User
if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
return
}
// 对新密码进行哈希处理
hashedPassword := req.NewPassword
// 更新用户密码
user.Password = hashedPassword
if err := database.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}
在主函数中做了鉴权处理:
package main
...
auth.Use(middleware.AuthMiddleware())
{
r.POST("/resetpwd", handlers.UpdatePassword)
}
看上去很好对吧,在重置密码前先检查是否有登录,其实这里犯了安全中的大忌。
假设我是用户A,我觊觎用户B的用户,我这里首先用自己的cookie过middleware.AuthMiddleware
的鉴权,所以我能够成功访问/resetpwd
路由,但注意我实际上可以通过
Username string `json:"username" binding:"required"`
这里直接传参B就能修改B的密码了,但要主要不是所有不同cookie的返回相同数据接口都是水平越权,还是得结合具体的业务,比如:
func GetProducts(c *gin.Context) {
var products []models.Product
if err := database.DB.Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch products"})
return
}
c.JSON(http.StatusOK, products)
}
你看,这个就是列出所有的商品接口,但其实是个人来买东西都是需要看的,所以这里就不构成越权。
那具体是该如何解决呢?
你会发现上面的从jwt
中提取用户名是能够很好的防止篡改,因为攻击者无法在没有key伪造jwt,换言之,每次修改密码,我都会间接的通过jwt再次鉴权一次,这就是提高了鉴权的颗粒度。但是这个操作只能够防止黑客通过传参冒充其他用户,阻止不了在后端访问数据库。
比如这里有个上架商品的路由:
func AddProduct(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Price float64 `json:"price" binding:"required"`
Stock int `json:"stock" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
product := &models.Product{
Name: req.Name,
Price: req.Price,
Stock: req.Stock,
}
if err := database.DB.Create(product).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create product"})
return
}
c.JSON(http.StatusOK, product)
}
在主函数中也是进过了鉴权:
auth := r.Group("/store")
auth.Use(middleware.AuthMiddleware()){
r.POST("/addproduct", handlers.AddProduct)
}
但是这里没有做任何校验该用户是否能够访问数据库,就能够导致任意的用户添加商品。比如我给自己添加一个价格为-1000
元的商品然后嘎嘎买,每次买我的余额都会+1000
,实现刷钱。那作为开发者该如何防范?
最简单的,我在每次执行该业务的时候都去添加这样一条
...
if currentuser=="admin" {
//业务逻辑
}
这种写死的方式固然安全,但是在实际业务中,我们经常会涉及到一个业务的权限的动态变更,所以更好的方式是用一个专门的数据库来存
我最基础的用户类型设计如下:
package models
import "gorm.io/gorm"
// InventoryItem 用户库存中的商品
type InventoryItem struct {
gorm.Model
UserID uint `json:"user_id"` // 外键,关联 User 表
ProductID uint `json:"product_id"` // 外键,关联 Product 表
Quantity int `json:"quantity"` // 商品数量
}
// User 用户模型
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique;not null" json:"username"`
Password string `json:"-"`
Balance float64 `json:"balance"`
Inventory []InventoryItem `gorm:"foreignKey:UserID" json:"inventory"` // 用户库存,持久化字段
}
我可以在其中添加一条专门用于记录权限的数据类型并且与用户类型关联,并且通过Role来链接:
package models
import "gorm.io/gorm"
// Role 角色模型
type Role struct {
gorm.Model
Name string `gorm:"unique;not null" json:"name"` // 角色名称,例如 "Admin", "User"
Description string `json:"description"` // 角色描述
Users []User `gorm:"many2many:user_roles;" json:"users"` // 与用户的多对多关系
Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions"` // 与权限的多对多关系
}
// Permission 权限模型
type Permission struct {
gorm.Model
Name string `gorm:"unique;not null" json:"name"` // 权限名称,例如 "ManageUsers", "ViewProducts"
Description string `json:"description"` // 权限描述
Roles []Role `gorm:"many2many:role_permissions;" json:"roles"` // 与角色的多对多关系
}
// User 用户模型
type User struct {
gorm.Model
Username string `gorm:"unique;not null" json:"username"`
Password string `json:"-"`
Balance float64 `json:"balance"`
Inventory []InventoryItem `gorm:"foreignKey:UserID" json:"inventory"` // 用户库存
Roles []Role `gorm:"many2many:user_roles;" json:"roles"` // 与角色的多对多关系
}
// InventoryItem 用户库存中的商品
type InventoryItem struct {
gorm.Model
UserID uint `json:"user_id"` // 外键,关联 User 表
ProductID uint `json:"product_id"` // 外键,关联 Product 表
Quantity int `json:"quantity"` // 商品数量
}
而在处理具体的业务比如addproduct
的时候
添加这样代码:
user := currentUser.(*models.User)
hasPermission := checkUserPermission(user, "AddProduct")
if !hasPermission {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to perform this action"})
return
}
这个checkUserPermission
函数具体操作为去数据库中先检查一边是否有对应的权限:
func checkUserPermission(user *models.User, permissionName string) bool {
// 预加载用户的角色和角色的权限
var dbUser models.User
err := database.DB.Preload("Roles.Permissions").First(&dbUser, user.ID).Error
if err != nil {
return false
}
for _, role := range dbUser.Roles {
for _, permission := range role.Permissions {
if permission.Name == permissionName {
return true
}
}
}
return false
}
这个就是RBAC原则了,即Role-Based Access Control,基于角色的访问控制。它通过将权限分配给角色,并将用户与角色关联,从而实现对系统资源的访问控制。RBAC 的核心思想是 “用户 -> 角色 -> 权限”,即用户不直接拥有权限,而是通过角色间接获得权限。这样就可以灵活简洁的预防越权的产生
从最简单的倒卖漏洞入手
如果商家没有校验购买数量,那我就可以买-1个苹果,这样商家反而会倒贴我钱。这时候有的开发者就说了,那不简单,我只要限制用户输入必须为正数不就好了吗?
白帽子微微一笑,事情并没那么简单
故名思意,就是在数据转化的过程中出现了漏洞,这其实是所有强类型语言的通病。例如在2023年的0xGame新生赛中,有道题目Goshop的一部分:
func BuyHandler(c *gin.Context) {
s := sessions.Default(c)
user := users[s.Get("id").(string)]
data := make(map[string]interface{})
c.ShouldBindJSON(&data)
var product *Product
for _, v := range products {
if data["name"] == v.Name {
product = v
break
}
}
if product == nil {
c.JSON(200, gin.H{
"message": "No such product",
})
return
}
n, _ := strconv.Atoi(data["num"].(string))
if n < 0 {
c.JSON(200, gin.H{
"message": "Product num can't be negative",
})
return
}
if user.Money >= product.Price*int64(n) {
user.Money -= product.Price * int64(n)
user.Items[product.Name] += int64(n)
c.JSON(200, gin.H{
"message": fmt.Sprintf("Buy %v * %v success", product.Name, n),
})
} else {
c.JSON(200, gin.H{
"message": "You don't have enough money",
})
}
}
其中:
if user.Money >= product.Price*int64(n)
这里虽然检查商品数量不能够为负数,但是并没有限制商品数量最多有多大就直接把用户的传参n
转化为int64,所以当我输入一个比2^64-1还大的整数比如:n=9223372036854775808
就会在后续的处理中由正数变负数。到这里user.Money -= product.Price * int64(n)
就会余额增加。还是被攻破了。
开发者:好好好,这么玩是吧?那我每个数据都做类型校验,总没有漏洞了把?
比如一个打赏机制:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type OrderRequest struct {
ProductID uint32 `json:"product_id" binding:"required"`
Price uint32 `json:"price" binding:"required"`
Tip uint32 `json:"tip"`
}
type Order struct {
OrderID uint32 `json:"order_id"`
ProductID uint32 `json:"product_id"`
Price uint32 `json:"price"`
Tip uint32 `json:"tip"`
TotalAmount uint32 `json:"total_amount"`
}
var orderCounter uint32 = 0
// CreateOrder 创建订单的处理函数
func CreateOrder(c *gin.Context) {
var req OrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 计算总金额(潜在的溢出风险)
total := req.Price + req.Tip
orderCounter++
order := Order{
OrderID: orderCounter,
ProductID: req.ProductID,
Price: req.Price,
Tip: req.Tip,
TotalAmount: total,
}
c.JSON(http.StatusOK, order)
}
func main() {
r := gin.Default()
r.POST("/create_order", CreateOrder)
r.Run(":8080")
}
这会我们的开发同志可算长了个心眼,处处都对用户传参做了校验:我们再去使用超级大的整数传参的时候就直接报了错:
┌──(technerd㉿LAPTOP-0A5TQ0RL)-[~]
└─$ curl -X POST http://localhost:8080/create_order -H "Content-Type: application/json" -d '{"product_id": 2, "price":3000, "tip": 4294967296}'
{"error":"json: cannot unmarshal number 4294967296 into Go struct field OrderRequest.tip of type uint32"}
でも,这个生成订单的时候可是没有判断生成总金额是否是符合规范的最终造成了溢出,
┌──(technerd㉿LAPTOP-0A5TQ0RL)-[~]
└─$ curl -X POST http://localhost:8080/create_order -H "Content-Type: application/json" -d '{"product_id": 2, "price":3000, "tip": 4294964297}'
{"order_id":4,"product_id":2,"price":3000,"tip":4294964297,"total_amount":1}
┌──(technerd㉿LAPTOP-0A5TQ0RL)-[~]
└─$
一块钱,给主播打赏42亿元.jpg
这个其实是类似于sql注入中二次注入的思路,在业务执行过程中出现了漏洞,不得不让人感慨安全的开发属实不容易啊
7 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!