Pi Network smart contract: lỗ hổng trial vô hạn miễn phí

Audit chi tiết repo PiNetwork/SmartContracts commit 74c48c6. 3 lỗ hổng logic trong subscription contract: trial abuse bypass, unbounded storage, is_active dead write. Có PoC và PR fix.

Pi Network smart contract: lỗ hổng trial vô hạn miễn phí
TL;DR (cho người mắc chứng khó đọc): Repo PiNetwork/SmartContracts commit 74c48c6 có 3 lỗ hổng logic trong subscription contract viết bằng Rust cho Soroban. Quan trọng nhất: bất kỳ subscriber nào cũng có thể lấy trial vô hạn miễn phí bằng cách revoke allowance trên token contract sau mỗi lần subscribe, không cần quyền admin, không cần exploit token. Bài này là audit từng dòng code, có 2 PoC Rust pass test, cộng PR fix đã gửi upstream.
Pi Network smart contract audit: infinite free trial bypass vulnerability

Chiều thứ sáu mình định đóng laptop thì thấy notification trên GitHub. Pi Network vừa push repo smart contract đầu tiên của họ, PiNetwork/SmartContracts. Mình click vào vì hai lý do rất khác nhau.

Lý do thứ nhất nằm trong điện thoại. Thằng em họ, Phương Tuấn trong họ mình, 97 tuổi, từ 2021 tới giờ vẫn gọi video mỗi tuần khoe "đào được thêm Pi". Nó tin khi mainnet mở, một Pi sẽ đổi được một chiếc xe máy. Mình không biết nói gì nhiều. Đã thử giải thích 3 lần và cả 3 lần đều kết bằng "thôi anh không hiểu đâu, em theo lâu rồi em biết."

Lý do thứ hai là tò mò nghề nghiệp. Pi Network chạy trên fork của Stellar, smart contract viết bằng Rust cho Soroban. Mình đã đọc kha khá code Soroban trong năm qua, và "subscription contract" là một domain khó hơn vẻ ngoài: pull-payment, allowance, trial logic, state machine phân nhánh theo auto_renew. Mình mở repo định đọc 10 phút để coi họ làm cách nào.

Bốn tiếng sau mình vẫn ngồi. Lúc đó mình đã tìm được cách để một subscriber bất kỳ lấy trial miễn phí vô hạn, không cần quyền admin, không cần exploit token, không cần MEV. Chỉ cần try_transfer_from fail đúng một lần, và đúng một giao dịch approve(0) để tạo ra cái fail đó.

Bài này kể lại 4 tiếng đó. Có phần kỹ thuật khá dài. Nhưng cuối bài mình muốn quay lại chuyện của nó Phương Tuấn.

Update 2026-04-18: Sau khi viết xong draft này, mình đã gửi PR fix lên upstream: PiNetwork/SmartContracts#4. Thêm key persistent TrialUsed(subscriber, service_id) để track trial consumption độc lập với subscription lifecycle. 51/51 test pass, bao gồm 3 regression test cover cả hai đường tấn công.
alt text
Thật ra việc open pull request về lỗ hổng bảo mật này sai nguyên tắc ngành. Nhưng tôi muốn xem họ phản ứng như thế nào với lỗ hổng này. Nếu họ không fix thì sẽ là một Red Flag rất lớn

1. Bối cảnh

1.1 Pi Network bước vào thế giới smart contract

Pi Network suốt nhiều năm chỉ là một mobile app với token chưa có mainnet thực. Khi mainnet mở, họ buộc phải xây smart contract primitives để ecosystem có thể "làm được gì đó" ngoài transfer cơ bản. Thời điểm mình đọc repo, PiNetwork/SmartContracts chỉ có đúng một contract:

contracts/
└── subscription/
    ├── Cargo.toml
    ├── README.md
    ├── src/
    │   ├── lib.rs      (925 dòng)
    │   └── test.rs     (944 dòng)
    └── test_snapshots/

Một recurring subscription contract. Merchant đăng ký dịch vụ, subscriber subscribe và cho phép contract pull tiền định kỳ. Rất giống Stripe Subscriptions, chỉ là on-chain.

1.2 Soroban là gì, kể cho người không làm blockchain

Nếu bạn chưa rõ Soroban, đọc phần này. Dev có thể nhảy sang 1.3.

Trong tiếng anh thì Soroban là cái này

Lớp 1. Blockchain là cuốn sổ công khai. Hình dung một cuốn sổ kế toán mà cả làng ai cũng giữ một bản y hệt. Ai chuyển tiền cho ai, dòng "A chuyển 10k cho B" được ghi vào. Không ai sửa được dòng đã viết. Các cuốn sổ phổ biến: Bitcoin, Ethereum, Stellar, Solana... mỗi cuốn có luật chơi riêng.

Lớp 2. Smart contract là cái máy bán hàng trong sổ. Thêm một ý Phương Tuấnởng vào cuốn sổ: chương trình tự chạy sống luôn trong đó. Bạn viết một đoạn code kiểu "nếu có người bỏ 5k vào khe, nhả một chai nước ra". Đoạn code đó ai cũng thấy, không ai tắt được, chạy đúng như viết. Subscription contract của Pi chính là một máy như vậy: "merchant đặt dịch vụ 10 Pi/tháng, user nào đăng ký thì mỗi tháng máy tự lấy 10 Pi".

Lớp 3. Mỗi blockchain có một "loại máy" khác nhau. Cùng là máy bán hàng, nhưng máy ở chợ Hàn Đà Nẵng khác máy ở chợ Bến Thành, khác ngôn ngữ, khác cơ chế. Phương Tuấnơng tự, Ethereum có loại máy tên EVM viết bằng Solidity; Stellar có loại máy tên Soroban viết bằng Rust. Pi Network mượn công nghệ Stellar, nên contract Pi chạy trên Soroban.

Soroban khác EVM ở ba điểm quan trọng cho bài này. Mình sẽ gọi tên theo số để reference lại sau.

  1. Ký nhận từng bước, không ký một phát cho tất cả. Ở Ethereum, khi bạn đưa thẻ cho một contract, nó có thể âm thầm chuyền thẻ sang 10 contract khác mà bạn không biết. Ở Soroban, mỗi lần thẻ bị chạm (contract nào đi nữa), bạn phải ký xác nhận từng cái riêng biệt, trước khi giao dịch chạy. Giống như ra quán cà phê, bạn nhìn trước hóa đơn ghi rõ "sẽ quẹt thẻ ở máy A, B, C" rồi ký một phát cho cả ba. Nhân viên không thể tự quẹt thêm máy D.
  2. Dữ liệu có "tiền thuê tủ gửi đồ". Ethereum: gửi một hộp vào kho, nó giữ vĩnh viễn, miễn phí. Soroban: gửi hộp thì kho bảo "đóng tiền thuê 30 ngày, hết hạn tôi vứt thật". Dữ liệu hết TTL bị xóa thật sự, không phải về lý thuyết. Mỗi lần có ai chạm vào subscription (merchant gọi process(), user cancel...), contract tự gia hạn giùm. Nếu service bị bỏ hoang đủ lâu, dữ liệu có thể biến mất. Đây là nguồn của những bug lạ mà Ethereum không có khái niệm Phương Tuấnơng đương.
  3. Trần tài nguyên cứng cho mỗi giao dịch. Ethereum dùng gas: trả nhiều chạy lâu, lý thuyết loop triệu vòng nếu đủ ngân sách. Soroban thì khác. Mỗi giao dịch có trần cứng về số dòng trong kho được đọc/ghi. Cụ thể theo SLP-0001 hiện tại là 100 reads / 50 writes / transaction, và toàn ledger 500 reads / 250 writes. Đóng thêm tiền cũng không phá trần được. Hệ quả: contract buộc phải cắt việc thành từng đợt (pagination). Hàm process() của Pi có tham số offsetlimit không phải vì design tốt, mà vì đây là ép buộc của platform.

Vì sao Rust?

Soroban chọn Rust thay vì Solidity có lý do bảo mật cụ thể. Rust có type safety và checked arithmetic tốt hơn. Các lỗi huyền thoại thời 2018 như integer overflow (vụ BEC Token mất hết giá trị thị trường trong vài phút) gần như bị chặn mặc định bởi compiler. Dev muốn viết lỗi đó cũng khó.

Nhưng đây là điểm quan trọng của toàn bộ bài này: Rust chống được lỗi kỹ thuật cấp thấp, không chống được lỗi logic cấp cao. Lỗ hổng mình sắp kể không phải lỗi Rust, không phải lỗi Soroban. Contract compile sạch, mọi test pass, arithmetic kiểm tra kỹ. Lỗi nằm ở một giả định business logic sai, một giả định không bao giờ được compiler hay test suite bắt giúp.

1.3 Mô hình subscription

README mô tả chu trình thế này:

alt text

Subscriber                 Contract                    Token Contract           Merchant
    |                         |                              |                     |
    |-- subscribe() --------->|                              |                     |
    |                         |-- approve(N periods) ------> |                     |
    |                         |-- transfer(price) ---------> |---> merchant        |
    |                         |<-------------- process() ----|                     |
    |                         |-- transfer_from(sub->merch)->|                     |
    |                         |                              |                     |
    |-- cancel() ------------>|  sets auto_renew=false       |                     |

Luồng tiền là pull-payment: subscriber ký approve() một lần cho contract, sau đó merchant gọi process() định kỳ để kéo tiền.

Bốn biến thể subscribe tạo ra bốn cách hành xử khác nhau:

Kịch bản Thanh toán ngay? Approval
Không trial, auto_renew=true Có (1 period) approve_periods
Không trial, auto_renew=false Có (1 period) 1 period
Có trial, auto_renew=true Không approve_periods
Có trial, auto_renew=false Không Không (trial only)

Bốn ô này không chỉ là UX, mà là attack surface matrix mà mình sẽ quay lại ở mục phân tích chính. Để ý ô thứ ba: "Có trial, auto_renew=true, không thanh toán ngay". Đây là ô mình đã ngồi nhìn lâu nhất.

1.4 Các lỗ hổng cho người không chuyên, giải thích bằng quán cà phê

Trước khi vào kỹ thuật, mình muốn kể các lỗ hổng bằng hình ảnh một quán cà phê. Nếu bạn là dev quen blockchain, nhảy sang mục 2. Nếu bạn đọc tới đây vì tò mò (ví dụ như thằng em của mình, mặc dù mình biết thằng em sẽ không đọc vì bận đi hát), cứ ở lại.

Minimalist illustration of a subscription coffee shop storefront

Hình dung một quán cà phê có chương trình subscription:

  • Merchant = chủ quán
  • Subscriber = khách
  • Contract = cô nhân viên tự động chạy theo sổ quy trình được in sẵn
  • Token contract = ngân hàng, giữ tiền của cả khách lẫn chủ quán
  • Allowance = giấy ủy quyền khách ký, cho phép cô nhân viên rút tối đa X đồng/tháng trong Y tháng

Khách đăng ký, mỗi tháng cô nhân viên ra ngân hàng dùng giấy ủy quyền rút tiền chuyển sang tài khoản chủ quán.

[Critical] Lỗ hổng #1: Trial miễn phí vô hạn

Quán có chương trình trial 7 ngày miễn phí. Để tránh ai cũng dùng thử mãi, quán ra luật: "dùng thử rồi, muốn quay lại phải cam kết đăng ký tháng (auto-renew = true)".

Khi bạn trial xong, cô nhân viên ghi vào sổ subscription của bạn: "khách này đã dùng trial. Auto-renew: true." Sau 7 ngày cô ra ngân hàng rút tiền tháng đầu. Rút thành công → bạn trở thành khách trả tiền bình thường.

Chuyện xấu ở đâu? Bạn có thể chặn cô rút tiền. Cách dễ nhất: bạn tự ra ngân hàng bảo "tôi rút giấy ủy quyền đã ký", ngân hàng hủy. Cô không biết, chỉ phát hiện khi ra ngân hàng thì thấy "không còn giấy, không rút được".

Trong sổ quy trình, cô xử lý tình huống này thế nào?

"Không rút được tiền → ghi vào sổ: auto_renew: false."

Và đây là bug: cô không ghi "khách đã dùng trial" vào một chỗ riêng. Dòng "đã dùng trial" chỉ nằm trong subscription record của bạn, mà subscription đó giờ đã hết hạn.

Sau ngày thứ 8, bạn quay lại quán.

Bạn nói "Tôi muốn đăng ký." Cô hỏi "Anh có subscription đang hoạt động không?" Bạn trả lời "Không." Cô đáp "OK, anh đăng ký được."

Bạn nói tiếp "Cho tôi trial nhé." Cô nhìn sổ. Subscription cũ đã hết hạn, không có chỗ nào khác ghi "đã dùng trial". Cô cấp trial mới.

Bạn có 7 ngày miễn phí nữa. Lặp vô hạn.

Infinite loop of customer re-entering trial through a revolving door

Điều khiến đây là bug thật, không phải feature? README của contract khẳng định rõ "ngăn trial miễn phí vô hạn". Code viết ra với ý đồ đó. Nhưng code không enforce được ý đồ, giống quán treo biển "cấm hút thuốc" nhưng không lắp chuông báo khói. Biển không tự thi hành nó.

Ai bị thiệt? Chủ quán. Mỗi vòng lặp, họ vẫn phải phục vụ cà phê (cost thật: hạt, sữa, điện, nhân công) cho một khách không bao giờ trả. Ở scale nhỏ thì nhẹ. Nhưng nếu "khách" là một kẻ chuyên nghiệp tạo 1000 địa chỉ, 1000 × (365/7) ≈ 52 ngàn trial/năm. Với sản phẩm số (API, streaming, compute), cost-to-serve không hề nhỏ.

Về mặt kỹ thuật: cô nhân viên = hàm subscribe(). Sổ = storage của contract. "Ghi vào chỗ riêng" = key TrialUsed(subscriber, service_id) mà fix PR đã thêm vào. "Rút giấy ủy quyền" = gọi token.approve(contract, 0, 0) trực tiếp lên token contract. "Cô phát hiện không rút được" = try_transfer_from trả Err. Không có chi tiết nào bị đơn giản hóa đến mức sai, mọi ánh xạ đều 1-1 với source code.

[HIGH] Lỗ hổng #2: Cuốn sổ danh sách khách phình không giới hạn

Cô nhân viên có nhiều cuốn sổ. Một cuốn ghi "danh sách tất cả khách đã đăng ký dịch vụ A", dùng để chủ quán buổi sáng đọc báo cáo. Một cuốn ghi "subscription của khách X".

Vấn đề: khi khách đăng ký lại sau hết hạn, cô thêm một dòng mới. Khi khách cancel, cô không gạch bỏ dòng cũ. Mỗi hoạt động, sổ dài thêm một dòng, không bao giờ co lại.

Hệ quả: chủ quán sáng nào cũng muốn đọc cả sổ để báo cáo. Đến một ngày, sổ quá dày không đọc hết trong một buổi, và báo cáo fail. Sổ vẫn ở đó, chỉ là không còn công cụ đọc được.

Stack of ledger books growing taller, overwhelming the reader

Về kỹ thuật: "đọc cả sổ trong một buổi" = thực thi một transaction Soroban có giới hạn cứng về số lần đọc ledger (nhớ đặc điểm ③ ở mục 1.2). Hàm get_merchant_subsget_subscriber_subs không có pagination. Một ngày đẹp trời list đủ dài để transaction cost vượt trần, revert, không có cách nào gọi lại, không có cách nào compact list.

Lỗ hổng này kết hợp với #1 rất đẹp: mỗi vòng trial abuse thêm một dòng vào sổ, attacker tự DoS báo cáo của merchant.

[VUI VẺ] Lỗ hổng #3: Công tắc "Đóng cửa" chỉ là chữ in cứng

Quán có cơ chế tạm ngưng không? Có, trên lý thuyết. Trong "sổ mô tả service" có một ô: "đang hoạt động: yes/no". README bảo dùng để chủ quán tạm ngưng nhận khách mới khi có sự cố.

Vấn đề: ô này luôn in sẵn "yes" và không có chốt để xoay sang "no". Code không có hàm nào cho phép merchant đổi giá trị. Ô đó là trang trí.

A light switch painted flat on the wall, impossible to flip

Hệ quả: nếu có sự cố (ví dụ merchant phát hiện lỗ hổng #1 đang bị exploit), họ không có nút "Đóng cửa". Cách duy nhất là đợi admin (Pi Network) upgrade contract.

Về kỹ thuật: field is_active: bool được set true duy nhất một lần trong register_service và đọc trong subscribe. Grep toàn source không có setter. Error variant ContractError::ServiceNotActive unreachable.

⚠️ Quan sát thêm: Chủ chuỗi có thể đổi hợp đồng bất cứ lúc nào

Đây không phải "lỗ hổng" theo nghĩa thông thường, mà là đặc quyền admin được code cho phép. Nhưng đáng biết nếu bạn là subscriber.

Mỗi khách đã ký giấy ủy quyền cho phép cô nhân viên rút tối đa X tiền/tháng × Y tháng. Ví dụ 10 Pi × 12 tháng = 120 Pi. Chủ chuỗi (Pi Network, giữ admin key) có quyền viết lại sổ quy trình bất cứ lúc nào qua hàm upgrade(). Không cần hỏi khách, không thời gian chờ, không multisig. Upgrade xong áp dụng ngay giao dịch kế tiếp.

Nếu admin key bị rò hoặc chủ đổi ý, sổ quy trình có thể viết thành: "lấy ngay 120 Pi của mọi khách, không đợi tháng". Cô nhân viên cầm giấy ủy quyền hợp lệ. Ngân hàng cho rút. N × 120 Pi biến mất trong một giao dịch.

Với 10,000 khách × 120 Pi = 1.2 triệu Pi trong một cú sweep.

A giant hand with a master key sweeping coins from multiple wallets

Tổng kết cho non-tech

# Nói gọn Ai bị thiệt Mức độ Đã fix?
1 Trial miễn phí vô hạn Merchant (revenue + cost) [Critical] Cao PR #4 đã gửi
2 Sổ danh sách phình → báo cáo fail Merchant (tooling) [HIGH] Trung Chưa
3 Không có công tắc "Đóng cửa" Merchant (incident response) [VUI VẺ] Thấp Chưa
π ($3.14) Admin đổi code bất cứ lúc nào Mọi subscriber Trust Me Bro Không có trong code, governance

Nếu bạn không phải dev, có thể dừng ở đây và nhảy đến mục 8. Phần giữa là technical dense.

2. Mổ xẻ kiến trúc contract

2.1 Storage layout

pub enum DataKey {
Admin, // Instance
Token, // Instance
NextServiceId, // Instance
NextSubId, // Instance
Service(u64), // Persistent
MerchantServices(Address), // Persistent: Vec<u64>
Sub(u64), // Persistent
SubscriberSubs(Address), // Persistent: Vec<u64>
ServiceSubs(u64), // Persistent: Vec<u64>
SubServicePair(Address, u64), // Persistent: u64 (dedup)
}

Soroban có hai loại storage: Instance (gắn với WASM, rất rẻ, giới hạn tổng bytes khiêm tốn, dùng cho config toàn cục) và Persistent (per-key, có TTL riêng, dùng cho domain data).

Để ý SubServicePair(Address, u64). Đây là dedup index ánh xạ (subscriber, service_id) → sub_id. Một cặp chỉ có một giá trị. Nhưng ServiceSubs(service_id)SubscriberSubs(address) lại là Vec<u64> append-only. Hai cấu trúc này mâu thuẫn nhau: dedup không đồng nghĩa list index không phình. Đây chính là nguồn của Finding #2.

2.2 Tính toán TTL động

fn ttl_extend_for_period(period_secs: u64) -> u32 {
let ledgers = period_secs.saturating_mul(2) / SECS_PER_LEDGER;
...
core::cmp::max(capped, PERSISTENT_TTL_EXTEND_MIN)
}

Logic: giữ dữ liệu sống ít nhất 2 chu kỳ billing. Thiết kế này đúng cho subscription hàng tháng, vì bump cố định 518,400 ledgers (~30 ngày) không đủ cho gói quý 90 ngày. Nhưng TTL dynamic tạo ra một tác dụng phụ bảo mật thú vị: dữ liệu có thể chết nếu merchant không gọi process() trong thời gian dài. Điều này sẽ quay lại ở Finding #1.

2.3 Approval ledger: bucket rounding

const LEDGER_BUCKET: u32 = 720;
let raw_expiration = env.ledger().sequence().saturating_add(capped_ledgers);
let max_expiration = env.ledger().sequence().saturating_add(max_ttl);
let capped = core::cmp::min(raw_expiration, max_expiration);
let expiration_ledger = (capped / LEDGER_BUCKET) * LEDGER_BUCKET;

Đây là đoạn "đặc sản Soroban", làm tròn ledger hết hạn approval xuống bội số của 720 (~1 giờ). Mục đích: để giá trị ổn định giữa simulateexecute. Trong Soroban, client sim transaction trên RPC trước khi submit; nếu sim và execute ra approval khác nhau, signature không khớp.

Trade-off hiển nhiên: approval hết hạn sớm hơn mong muốn tối đa ~1h. Edge case: nếu capped < 720 (chỉ xảy ra trên testnet mới tinh với sequence thấp), kết quả sẽ là expiration_ledger = 0, tức approval hết hạn ngay lập tức. Trên mainnet với sequence hàng chục triệu, không reachable. Ghi nhận, không phải lỗ hổng.

2.4 Auth model trong subscribe

pub fn subscribe(env: Env, subscriber: Address, service_id: u64, auto_renew: bool)
-> Result<Subscription, ContractError>
{
subscriber.require_auth();
...
}

Trong Soroban, subscriber.require_auth() là cổng. Sau đó contract gọi token.approve(&subscriber, ...)token.transfer(&subscriber, ...). Các sub-call này tự động yêu cầu subscriber authorize, client phải pre-sign toàn bộ cây auth. Không thể "lừa" subscriber subscribe hộ được.

Điểm thú vị, cũng là mấu chốt của finding chính: subscriber có thể luôn gọi trực tiếp token.approve(&subscriber, &contract, 0, 0) sau khi subscribe. Đó là giao dịch một cấp, chỉ cần subscriber sign. Theo Stellar Asset Contract spec, hàm approve(from, spender, amount, expiration) chỉ yêu cầu from.require_auth(). Subscriber là from, không có contract subscription nào trong auth path. Subscription contract không có cách nào ngăn chặn điều này. Nhớ sự thật này.

Stellar Asset Contract spec: Unprivileged mutators require authorization from the Address that spends or allows spending their balance, áp dụng cho approve, transfer, burn. Signature fn approve(env: Env, from: Address, spender: Address, amount: i128, ...)

2.5 Process: batch charge với failure isolation

Hàm process() là tim của contract. Đây là source thật từ lib.rs dòng 685-761 tại commit 74c48c6:

Source code process() tại commit 74c48c6. Highlight vàng nhóm 1 (dòng 685-690): contract gọi try_transfer_from để kéo tiền từ subscriber sang merchant. Highlight vàng nhóm 2 (dòng 750-761): nhánh else khi payment_result KHÔNG ok, contract set sub.auto_renew = false, lưu subscription lại, tăng counter failed, và emit event chg_fail. Không có logic nào đánh dấu subscription này là "đã dùng trial".

Đọc từng nhóm highlight:

Highlight 1 (dòng 685-690): try_transfer_from(contract, subscriber, merchant, price). Đây là cách contract kéo tiền: nó không có private key của subscriber, nó dùng allowance mà subscriber đã approve trước đó. Dùng variant try_ thay vì transfer_from thẳng, để khi một subscriber fail không làm revert cả batch. Hợp lý khi merchant có hàng nghìn subscribers.

Highlight 2 (dòng 750-761): đây là nhánh else khi payment_result.is_ok() == false. Contract xử lý như sau:

  1. sub.auto_renew = false (dòng 751). Tắt auto-renew để không spam charge fail mỗi lần process() chạy tiếp.
  2. env.storage().persistent().set(&sub_key, &sub) (dòng 752). Lưu subscription với state mới.
  3. Tăng counter failed, emit event chg_fail để merchant biết.

Ba bước này không có bước thứ Phương Tuấn là đánh dấu "subscriber này đã dùng trial". Đó là gap. State của subscription sau khi fail trông giống hệt state của một subscription đã paid xong rồi cancel: auto_renew = false, service_end_ts đã qua. Contract không có trường phân biệt "chưa từng charge được" với "đã charge được 6 tháng rồi cancel".

Khi mình đọc đến dòng 751 lần thứ ba, mình khựng lại. Vì auto_renew = false cũng chính là state mà dedup gate trong subscribe() đang check để cho phép re-subscribe. Đó là moment mình gõ grep tìm had_trial.

2.6 Dedup + trial abuse check, tim điểm lỗi

Đây là source thật của phần check trong subscribe(), lib.rs dòng 335-363 tại commit 74c48c6:

Source code fn subscribe() dedup check tại commit 74c48c6. Highlight vàng nhóm 1 (dòng 341-357): contract đọc SubServicePair(subscriber, service_id) để lấy sub_id cũ, rồi đọc tiếp toàn bộ Subscription record cũ, rồi lấy trường existing.trial_period_secs > 0 để quyết định had_trial. Highlight vàng nhóm 2 (dòng 361-363): trial abuse gate chỉ block khi had_trial && !auto_renew && service.trial_period_secs > 0.

Đọc chậm đoạn này. Có bốn điều đang được kiểm tra:

  1. Dedup gate (existing.auto_renew || now < existing.service_end_ts): Ngăn một subscriber có hai subscription active cùng lúc cho cùng service. Chỉ cho re-subscribe khi subscription cũ đã hết hạn và đã hủy.
  2. had_trial flag: true nếu subscription cũ từng có trial_period_secs > 0.
  3. Trial abuse gate: chỉ fire khi had_trial && !auto_renew && service.trial_period_secs > 0.
  4. Implicit: nếu subscription cũ bị TTL garbage-collect, get::<_, u64>(&pair_key) trả None, had_trial = false, gate không fire.

Vấn đề: gate số 3 chỉ chặn path auto_renew=false. README viết:

"a subscriber who already used a free trial cannot re-subscribe without auto_renew=true, preventing infinite free trials."

Logic tác giả: "với auto_renew=true, merchant sẽ thu được tiền ở kỳ đầu sau trial, nên trial không còn miễn phí." Nhưng logic này đặt niềm tin vào một giả định không có trong code: merchant sẽ luôn thu được tiền.

Thực tế, subscriber có thể không đủ balance khi process() chạy. Có thể revoke allowance ngay sau khi subscribe() trả về. Token có thể bị frozen (asset Stellar có thể có flag AUTH_REVOCABLE_FLAG). Ở mọi trường hợp, try_transfer_from fail, contract set auto_renew = false, subscription hết hạn ở trial_end_ts, subscriber re-subscribe với auto_renew=true, và được cấp trial mới.

Đó là lúc mình đóng trình duyệt lại và đi pha cà phê. Đến khi quay lại, viết một cái test.

3. Finding chính: Infinite Free-Trial Bypass

3.1 Hypothesis

Trial abuse prevention được README tuyên bố không thành công khi attacker có thể cố ý làm fail payment đầu tiên sau trial.

3.2 State machine

Gọi S là trạng thái subscription sau khi subscribe với trial + auto_renew=true:

S0: {auto_renew=true, service_end_ts=W, next_charge_ts=W, trial_period_secs=W}
W = trial_end_ts (ví dụ 1 tuần)

Tấn công diễn ra theo các bước sau. Diagram dưới đây show flow qua ba swim-lane (Attacker, Contract, Token SAC) với năm mốc thời gian:

Attack flow: infinite free-trial bypass qua ba lane Attacker/Contract/Token, với exploit step t=0+ε revoke allowance và trial abuse re-subscribe ở t=W+1

Bước quan trọng nhất là t = 0+ε (hộp đỏ): attacker gọi token.approve(contract, 0, 0) trực tiếp trên SAC để revoke allowance. Contract không nhận được bất kỳ event nào từ bước này vì nó xảy ra ở layer token, không phải layer subscription. Đây là "pull-payment blind spot": contract cho rằng allowance sẽ còn vì nó tự set ở bước subscribe, nhưng subscriber có quyền điều chỉnh allowance bất cứ lúc nào mà không cần contract biết. Mũi tên đứt màu xanh lá ở dưới cùng là điểm attacker quay lại gọi subscribe(auto_renew=true) lần hai và trial abuse gate lại cho qua, hoàn thành vòng lặp.

Chi tiết state transitions ở từng mốc:

t=0: subscribe(auto_renew=true, trial=W)
-> State: S0
-> Contract gọi approve(price * approve_periods)
-> Allowance: price * approve_periods
-> Balance: unchanged

t=0+ε: Attacker gọi token.approve(contract, 0, 0) TRỰC TIẾP
-> Allowance: 0
-> Contract không biết, không có event nào fire từ contract

t=W: Trial hết hạn, subscription vẫn "active" vì now < service_end_ts
-> Attacker tiếp tục sử dụng dịch vụ trong khoảng (0, W)

t=W+1: Merchant gọi process(merchant, service_id, 0, limit)
-> Loop gặp subscription của attacker
-> auto_renew=true, now >= next_charge_ts -> không skip
-> try_transfer_from fail (allowance=0)
-> sub.auto_renew = false
-> sub.service_end_ts giữ nguyên = W
-> Emit "chg_fail"

t=W+1: State: {auto_renew=false, service_end_ts=W, trial_period_secs=W}
-> is_subscription_active = false (now >= W)
-> Dedup gate: existing.auto_renew=false AND now >= service_end_ts -> PASS
-> had_trial = (existing.trial_period_secs > 0) = true
-> Trial abuse gate: had_trial=true, auto_renew_new=true -> KHÔNG FIRE
-> subscribe(auto_renew=true) SUCCESS -> Trial mới

t=W+1: Quay về S0 với sub_id mới. Lặp.

Cost mỗi vòng lặp: một giao dịch subscribe (vài trăm stroops), một giao dịch token.approve(0) (vài trăm stroops). Thời gian thực: đúng bằng trial_period_secs, vì attacker phải đợi trial kết thúc mới subscribe tiếp được, do dedup gate yêu cầu service_end_ts đã qua.

3.3 Validation bằng PoC

Đã thêm hai test vào contracts/subscription/src/test.rs:

PoC #1. Qua drain balance

#[test]
fn poc_trial_abuse_bypass_via_payment_failure() {
let s = setup();
let svc = register_trial_service(&s);

let sub1 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub1.auto_renew, true);
assert_eq!(sub1.trial_end_ts, WEEK);

// Rút hết balance trước khi trial kết thúc
let drain = s.token.balance(&s.subscriber);
s.token.transfer(&s.subscriber, &s.admin, &drain);

advance_time(&s.env, WEEK + 1);
let result = s.client.process(&s.merchant, &svc.service_id, &0, &100);
assert_eq!(result.failed, 1);

let sub_after = s.client.get_subscription(&s.subscriber, &sub1.sub_id);
assert_eq!(sub_after.auto_renew, false);

// Mint lại balance (thực tế attacker chỉ cần chuyển tiền về)
s.token_admin.mint(&s.subscriber, &INITIAL_BALANCE);

// Re-subscribe: cấp trial mới
let sub2 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub2.trial_period_secs, WEEK);
assert_ne!(sub2.sub_id, sub1.sub_id);
}

PoC #2. Qua revoke allowance (đơn giản hơn, không cần chuyển tiền)

#[test]
fn poc_trial_abuse_bypass_via_allowance_revoke() {
let s = setup();
let svc = register_trial_service(&s);

let sub1 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);

// Revoke allowance trực tiếp trên token contract
s.token.approve(&s.subscriber, &s.contract_addr, &0, &0);

advance_time(&s.env, WEEK + 1);
let result = s.client.process(&s.merchant, &svc.service_id, &0, &100);
assert_eq!(result.failed, 1);

// Re-subscribe: trial mới, balance KHÔNG thay đổi
let sub2 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub2.trial_period_secs, WEEK);
assert_eq!(s.token.balance(&s.subscriber), INITIAL_BALANCE);
}

Kết quả:

running 3 tests
test test::poc_trial_abuse_bypass_via_allowance_revoke ... ok
test test::poc_trial_abuse_bypass_via_payment_failure ... ok
test test::poc_service_subs_unbounded_growth ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

48 test hiện có vẫn pass. PoC Phương Tuấnơng thích hoàn toàn với test harness sẵn có, không cần setup bất thường.

3.4 Impact, cố gắng quantify

Đây là phần khó nhất. Cost-to-serve một trial thật rất khác giữa các loại dịch vụ, và mình không có data nội bộ của bất kỳ merchant Pi nào. Dưới đây là phân tích mình làm được, với confidence levels rõ ràng.

3.4.1 Impact trực tiếp: doanh thu trial-vs-paid

Gọi T là độ dài trial, P là giá một chu kỳ, D là lifetime expected sau convert, C là conversion rate trial→paid.

Doanh thu kỳ vọng từ một user hợp pháp:

Expected revenue per trial = C * (D / period_secs) * P

Doanh thu từ một attacker: 0.

Nhưng chưa phải con số cuối. Attacker có thể tạo N địa chỉ Stellar với cost rất thấp: keypair tạo offline là free, mỗi account cần fund 1 XLM để "exist" on-chain (2 base reserves × 0.5 XLM). Ở giá XLM hiện tại ~$0.10, 100 địa chỉ tốn đúng $10. Số trial song song = N × (service_uptime / T).

Ví dụ: trial 7 ngày, period 30 ngày, price 10 Pi, service uptime 365 ngày, attacker tạo 100 địa chỉ ($10 one-time). Số trial lạm dụng/năm: 100 × (365/7) ≈ 5214. Nếu conversion thật là 18.2% (benchmark B2C SaaS opt-in 2026 theo 1Capture) và ARPU 3 tháng, mỗi trial thật đáng giá 0.182 × 3 × 10 = 5.46 Pi. Tổn thất lý thuyết nếu attacker đánh chiếm chỗ: 5214 × 5.46 ≈ 28,469 Pi/năm/service/100 địa chỉ.

Nhưng tổn thất thực không phải "revenue lẽ ra thu từ attacker" (attacker không định trả bao giờ), mà là cost-to-serve. Nếu trial cấp 7 ngày video streaming không giới hạn, cost có thể hàng chục Pi/trial. Nếu trial cấp API key với quota 100k request/ngày, attacker có 700k request miễn phí mỗi chu kỳ.

Impact là flat: mỗi service khác nhau, nhưng lỗ hổng là vĩnh viễn cho đến khi contract upgrade.

3.4.2 Impact gián tiếp

Khi merchant phát hiện trial abuse sau triển khai:

  • Không có cơ chế on-contract để blacklist địa chỉ. Subscriber chỉ cần chờ service_end_ts rồi sub lại.
  • Không có is_active setter, tức không thể tạm khóa service để patch (Finding #3).
  • Merchant chỉ còn cách chịu lỗ hoặc admin upgrade (cần admin key, cần re-deploy).

Nếu đây là contract "flagship" đầu tiên được Pi Network tung ra cho dev clone/fork, lỗi sẽ replicate ra hàng loạt app. Subscription-on-chain chưa phổ biến nên mình không tìm được precedent audit nào bắt đúng class bug này, nhưng đây là dạng state-machine bypass mà bất kỳ auditor serious nào cũng phải trace qua fail-path của process(). Việc lỗi tồn tại ở commit initial công khai đáng cho mình dừng lại một chút.

3.4.3 Kết hợp với các finding khác

  • Với Finding #2: Mỗi vòng trial abuse thêm một entry vào ServiceSubsSubscriberSubs. Vài trăm cycle sau, get_merchant_subs của merchant vượt Soroban resource limit. Attacker tự DoS dashboard của merchant.
  • Với Finding #3: Merchant không thể tạm khóa service. Không còn đường phản ứng incident.
  • Nhánh TTL expiration: ngay cả khi fix Finding #1 bằng cờ "TrialUsed", nếu cờ đó không được bump TTL đủ dài thì sau 30 ngày không hoạt động nó bị GC, và bypass quay lại. Điểm này mình gần miss khi viết fix; mình sẽ quay lại ở mục 7.

3.4.4 Impact lên subscriber hợp pháp

Gián tiếp nhưng có thật. Nếu merchant phản ứng bằng cách bỏ trial hoàn toàn để chặn abuse, user thật mất quyền thử sản phẩm. Tragedy of the commons kinh điển.

3.5 Severity

Theo CVSS 3.1 adapted cho smart contract:

Metric Value
Attack Vector Network (public)
Attack Complexity Low
Privileges Required None (any subscriber)
User Interaction None
Scope Unchanged
Confidentiality None
Integrity Low (contract state)
Availability Low (merchant revenue)

Severity: Medium đến High (6.5 đến 7.5) tùy cost-to-serve.

Trong taxonomy Immunefi v2.3, bug này nằm ở ranh giới. Nếu frame là "theft of contract-owed service/compute" thì fit High: Theft of Unclaimed Yield. Nếu frame là griefing (merchant mất doanh thu, không mất token đã có), fit Medium. Payout Medium trên bounty lớn thường 1 đến 10k USD, High thường 10 đến 100k USD. Bài này mình vẫn giữ Medium vì không có Pi rời ví merchant, chỉ có revenue denial cộng cost-to-serve.

Immunefi Severity Classification v2.3, Smart Contracts: High level bao gồm Theft of unclaimed yield và Theft of unclaimed royalties. Medium bao gồm Griefing và Smart contract unable to operate due to lack of token funds. Critical loại trừ unclaimed yield.

4. Finding #2: Unbounded per-subscriber / per-service index lists

// Trong subscribe:
let mut sub_ids: Vec<u64> = env.storage().persistent()
.get(&ss_key).unwrap_or_else(|| Vec::new(&env));
sub_ids.push_back(sub_id); // <-- append-only
env.storage().persistent().set(&ss_key, &sub_ids);

Không có nơi nào trong lib.rs làm pop_back hoặc remove. Khi subscriber re-subscribe (sau hết hạn hoặc sau trial abuse cycle), sub_id mới được cấp (next_sub_id chỉ tăng), append vào vector. DataKey::Sub(old_sub_id) vẫn tồn tại trong storage, SubServicePair được overwrite để trỏ về sub_id mới.

Hàm Pagination? Tác động
process() Tolerate, nhưng read cost tăng
get_merchant_subs Không Fail khi list lớn (Soroban resource)
get_subscriber_subs Không Fail khi subscriber có nhiều record

Merchant đối mặt với ngã Phương Tuấn khó chịu: hàm reporting chính của họ sẽ ngừng hoạt động khi service đủ phổ biến.

#[test]
fn poc_service_subs_unbounded_growth() {
let s = setup();
let svc = register_default_service(&s);

for i in 0..5u64 {
let sub = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
s.client.cancel(&s.subscriber, &sub.sub_id);
advance_time(&s.env, MONTH * (i + 1) + 1);
}

let subs_after = s.client.get_merchant_subs(&s.merchant, &svc.service_id);
assert_eq!(subs_after.len(), 5);

let my_subs = s.client.get_subscriber_subs(&s.subscriber);
assert_eq!(my_subs.len(), 5);
}

Một subscriber duy nhất, sau 5 cycle re-subscribe, đã tạo 5 entries trong cả hai list. Scale với N subscribers và K cycle/subscriber, kích thước list = N × K.

Fix hợp lý: thêm pagination get_merchant_subs(merchant, service_id, offset, limit). Hoặc khi re-subscribe, reuse sub_id cũ thay vì cấp mới, overwrite DataKey::Sub tại chỗ. Hoặc cleanup: khi cycle kết thúc, xóa sub_id ra khỏi list trong subscribe() mới.

5. Finding #3: is_active là dead write

$ grep -nE 'is_active|ServiceNotActive' contracts/subscription/src/lib.rs
35:    ServiceNotActive = 12,          // Error định nghĩa
71:    pub is_active: bool,            // Field định nghĩa
272:            is_active: true,       // SET duy nhất (trong register_service)
336:        if !service.is_active {    // READ duy nhất (trong subscribe)
337:            return Err(ContractError::ServiceNotActive);

Không có hàm set_active, deactivate_service, pause_service. Field luôn true. Error ServiceNotActive unreachable. Đây là một lỗi rất ngớ ngẩn với một sản phẩm block chain ...

Technical impact bằng không, code chạy đúng như không có field. Operational impact: README tuyên bố "Whether new subscriptions can be created", tuyên bố sai. Merchant không thể tạm ngưng nhận sub mới. Khi phát hiện lỗi (ví dụ Finding #1), merchant hoặc chịu, hoặc admin upgrade.

Security impact là false assurance: nếu integrator đọc field này và tin, họ có thể hiển thị UI "service active" mà không có cơ chế đảm bảo.

Fix: bổ sung hàm set_service_active(merchant, service_id, active) + kiểm tra is_active ở cả toggle_auto_renew, extend_subscription, process (không chỉ subscribe). Runtime pause là capability hữu ích cho incident response.

6. Admin upgrade: một trust boundary không có rail

Không phải finding độc lập, nhưng đáng nêu. upgrade():

pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash.clone());
...
}

Ba thứ vắng mặt ở đây đáng lưu ý. Đầu tiên là timelock, không có. Upgrade tức thời, admin sign một phát là code mới áp dụng cho transaction kế tiếp, subscriber không có cửa sổ để react. Thứ hai là transfer admin cũng không. Admin key nếu mất là contract thành unupgradeable vĩnh viễn, nếu lộ là attacker có full control. Thứ ba là không có renounce_admin, không có con đường "freeze" để bảo chứng immutability cho user.

Hệ quả cụ thể: admin có thể drain allowances. Mỗi subscriber đã cấp cho contract allowance = price × approve_periods. Admin có thể upgrade sang WASM độc hại gọi transfer_from tất cả subscribers về một địa chỉ. Với approve_periods = 12 và price = 10 Pi, mỗi subscriber expose 120 Pi. Ở quy mô 10,000 subscribers: 1.2 triệu Pi một cú sweep.

Đây không phải tình huống lý thuyết. Trên EVM, Beanstalk mất $182M tháng 4/2022 chính xác vì upgrade path không có timelock: attacker mua đủ voting power qua flash loan trong một block rồi execute malicious proposal. Và pattern "unlimited approval + admin upgrade = drain" lặp lại đủ nhiều để revoke.cash trở thành một dịch vụ stand-alone. Soroban chưa có precedent vì ecosystem còn trẻ, không phải vì class vuln này không áp dụng được.

Đây là trust decision nằm ở governance layer. Khuyến nghị deploy-time mà mình nghĩ tối thiểu cần: admin là multisig 3-of-5 key holders được công khai, timelock wrapper trước khi upgrade apply, và sau khi contract stable thì chuyển admin sang DAO treasury hoặc null address. Nếu Pi Network thực sự muốn subscriber tin contract, ba thứ này không thể thiếu.

7. Fix

Tôi biết là chẳng ai dở hơi đi fix bugs cho ... PI Network cả. Nhưng tôi làm việc đó ...

7.1 Gốc của lỗi

Cơ chế trial-abuse dựa trên giả định "auto_renew=true ⇒ merchant sẽ thu được tiền". Giả định không được enforce. Token contract (Stellar Asset Contract) không yêu cầu contract subscription đồng ý khi subscriber tự gọi approve(0); đó là hành động một cấp của chủ địa chỉ.

Cách fix đúng là tách biệt tracking trial consumption khỏi subscription lifecycle. Trial dùng rồi thì đánh dấu ở một key riêng, sống độc lập với subscription active hay hết hạn.

7.2 Ba thay đổi

Change #1. Thêm key persistent riêng:

pub enum DataKey {
...
SubServicePair(Address, u64),
TrialUsed(Address, u64), // <-- MỚI
}

Change #2. Kiểm tra và ghi đè has_trial trong subscribe():

let trial_used_key = DataKey::TrialUsed(subscriber.clone(), service_id);
let trial_already_used = env.storage().persistent().has(&trial_used_key);
if trial_already_used {
bump_persistent(&env, &trial_used_key, service.period_secs);
}

// Trial chỉ được cấp một lần / (subscriber, service)
let has_trial = service.trial_period_secs > 0 && !trial_already_used;

Người đã dùng trial, nếu re-subscribe, fall-through sang nhánh paid (charge ngay kỳ đầu, không có trial_end_ts).

Change #3. Ghi cờ với TTL dài nhất có thể:

if has_trial {
env.storage().persistent().set(&trial_used_key, &true);
let max_ttl = env.storage().max_ttl().saturating_sub(1);
env.storage().persistent().extend_ttl(
&trial_used_key,
PERSISTENT_TTL_THRESHOLD,
max_ttl,
);
}

Đây là chi tiết mình suýt miss. Nếu chỉ dùng bump_persistent(..., period_secs) thông thường, sau ~30 ngày service ít hoạt động, cờ bị GC, bypass quay lại ở timescale chậm hơn. Dùng max_ttl - 1 loại path này.

Logic cũ had_trial && !auto_renew && service.trial_period_secs > 0 được remove. Giờ không còn phân biệt auto_renew=trueauto_renew=false khi gating trial: cả hai đều sub được, cả hai đều không được cấp trial lần hai.

UX cải thiện side-effect (cố ý): Trước fix, một subscriber thật đã dùng trial, muốn quay lại, nếu sub với auto_renew=false sẽ bị chặn bằng AlreadySubscribed, họ buộc phải dùng auto_renew=true. Sau fix, cả hai option available; auto_renew=false sẽ charge ngay một kỳ. Friendlier UX, vá đúng bug.

7.3 Regression tests

Ba test mới trong contracts/subscription/src/test.rs:

Test Kịch bản Kỳ vọng sau fix
test_trial_not_regranted_after_payment_failure Subscriber revoke allowance, PoC gốc Re-subscribe → trial_period_secs=0, charge PRICE ngay
test_trial_not_regranted_after_balance_drain Subscriber drain balance + mint lại trước re-sub Re-subscribe → trial_period_secs=0, charge PRICE ngay
test_paid_subscriber_does_not_set_trial_used Subscribe service không trial, cancel, sub service khác có trial Service mới cấp trial bình thường (flag per-service)

Test thứ ba quan trọng, nó đảm bảo cờ TrialUsed là per-(subscriber, service), không rò rỉ giữa các service khác nhau.

Test pre-existing test_subscribe_trial_abuse_blocked được update (vốn codify side-effect của bug cũ, không phải semantic đúng).

Kết quả: 51/51 test pass (48 baseline + 3 regression).

7.4 Trước / sau

Chiều Trước fix Sau fix
Attack cost 2 tx / vòng + wait trial_period_secs N/A, đường khai thác đóng
Attacker payoff ∞ trial free / địa chỉ × N địa chỉ 1 trial / (subscriber, service), vĩnh viễn
Merchant loss Cost-to-serve × ∞, revenue denial vĩnh viễn Cost-to-serve × 1 (giá trial legit)
User hợp pháp Rủi ro mất trial nếu merchant tắt trial để chặn abuse Giữ quyền trial; UX re-subscribe friendlier
Contract state TrialUsed chưa tồn tại; had_trial đọc từ subscription record cũ TrialUsed(sub, svc) ghi một lần, tồn tại suốt max_ttl
Governance risk Merchant không có incident response Không đổi, Finding #3 vẫn còn

Nếu PR merge:

  • Deploy mới sẽ không có bypass.
  • Deploy đang chạy không tự migrate; admin phải upgrade() contract. Subscriber chưa bị đánh dấu TrialUsed sẽ được đánh dấu lazily ở lần subscribe trial tiếp theo, không ảnh hưởng subscriber hợp pháp đang hoạt động.
  • Finding #2 và #3 ngoài scope PR này. Mình sẵn sàng gửi follow-up nếu maintainer yêu cầu.

7.5 Disclosure

  • Phát hiện ngày 2026-04-17 khi review commit 74c48c6.
  • Blog kỹ thuậttechnical report viết cùng ngày.
  • PR fix gửi ngày 2026-04-18 kèm writeup đầy đủ trong PR description.
  • Pi Network không có SECURITY.md, không có điểm liên hệ security chính thức được công bố. PR public trở thành channel disclosure mặc định.
  • Vì lỗ hổng không phải direct theft of funds (Medium severity) và bất kỳ researcher đủ kiến thức đọc code đều có thể phát hiện trong một buổi chiều, mình đánh giá public PR ngay là phù hợp. Delay không mang lại protection thêm cho user.

Nếu Pi Network triển khai bug bounty sau này, mình sẵn lòng đăng ký claim theo quy trình.

8. Context rộng hơn: Pi Network và "đào" trong Pi

8.1 "Đào" trong Pi thực sự là gì

Real mining with pickaxe on one side, a single finger tapping a phone on the other

Phân biệt hai định nghĩa của từ này.

Đào trong blockchain thật (Bitcoin, Monero...): máy tính thực thi phép toán hash để giải puzzle. Có chi phí điện, chi phí phần cứng. Output là khối mới thêm vào chain, thợ đào nhận phí và reward. Có block explorer công khai verify được.

"Đào" Pi (cả trước và sau khi mainnet open): app mobile, user nhấn nút một lần mỗi 24 tiếng. Không có phép toán nào chạy trên thiết bị, điện thoại không nóng hơn, pin không hao hơn. Điểm đáng chú ý: chính Pi Network tự xác nhận điều này trong FAQ. Tức đây không phải claim của người ngoài, mà là self-description.

Pi Network FAQ chính thức tự xác nhận: Pi does not affect your phone's performance, drain your battery, nor use your network data any more than other regular apps. Instead of burning energy, as proof of work cryptocurrencies like Bitcoin do, Pi secures its ledger when members vouch for each other as trustworthy.

Không có chain công khai verify được số dư cho đến năm 2025. Cơ chế tăng "reward" phụ thuộc vào số user bạn refer và duy trì active. Công thức theo wiki chính thức của Pi: base rate 0.0025641 π/h (Jan 2026), +20%/member Security Circle (trần 5 members), +25% per active referral, không trần. Một multiplier referral không giới hạn là định nghĩa cấu trúc của MLM, không phải mining.

Pi Network Community Wiki: 25% boost to their respective individual Pioneer base mining rates với mỗi active Referral Team member. Ec là count của active Referral Team members, không giới hạn trần. Formula M = I(B,L,S) + E(I) + N(I) + A(I) + X(B) có component E scale tuyến tính theo số lượng người được mời.

Gọi hành động trong Pi là "đào" là một lựa chọn ngôn ngữ có ý đồ. Nó mượn từ vựng của mining thật để thừa hưởng uy tín kỹ thuật, nhưng không có bản chất của mining thật. Khi bác Phương Tuấn nói "bác đào được 5,000 Pi", bác đang dùng từ với nghĩa đã bị làm loãng. Từ "đào" trong đầu bác khớp với ảnh thợ mỏ đào than, không khớp với động tác nhấn nút mỗi ngày.

8.2 Những red flag công khai

Nhiều điểm sau đã được cộng đồng tech nhắc lại nhiều lần. Mình tóm tắt trung lập:

Chậm mở mainnet, KYC chặn rút. Pi launch 2019. Enclosed Mainnet mở ngày 28/12/2021 nhưng token bị khóa trong Phương Tuấnờng lửa. Open Mainnet chỉ mở ngày 20/02/2025, tức sau lần launch đầu 5 đến 6 năm. Ngay cả sau khi mở mainnet, user phải qua KYC của Pi (không phải exchange). Theo phân tích của Cointelegraph tháng 9/2025, tại thời điểm đó có khoảng 44 triệu users stuck ở trạng thái "tentative KYC", lớn hơn số đã qua. Có user báo hoàn thành KYC sau 5 năm vẫn chưa migrate được. Bác Phương Tuấn nộp KYC năm 2023, đến 2026 vẫn chưa qua.

Không có whitepaper kỹ thuật đầy đủ. Whitepaper Pi (2019 + addendum 2021) phần lớn là marketing và tokenomics, cite lại Stellar Consensus Protocol paper nhưng không có formal proof cho "trust graph from Security Circles", vốn là cơ chế sybil resistance chính của Pi. So với Bitcoin whitepaper 9 trang có đủ proof hay Ethereum Yellow Paper 40+ trang với formal math notation, khác biệt về độ chín kỹ thuật rõ đến mức có thể đo được bằng mắt.

Code không công khai đầy đủ trong thời gian dài. Suốt giai đoạn "enclosed mainnet", source code node không mở. User không cách nào verify tuyên bố của đội ngũ. Repo PiNetwork/SmartContracts mà mình đang review là một trong những đợt code đầu tiên được công khai. Đây là năm 2026, nhiều năm sau launch. Lỗ hổng trial-abuse ở trên tồn tại ngay trong commit initial của repo đó.

Node chạy trên máy cá nhân, "quasi-federated". Trong Stellar gốc, validator là các tổ chức công khai với danh tính verify. Trong Pi, node được "bầu chọn" qua cơ chế không minh bạch. Pi claim 400K+ nodes, nhưng theo phân tích của BeInCrypto, chỉ có 3 SuperNodes ban đầu do core team chạy ở Canada và Finland, sau đó tăng lên khoảng 41 đến 42 SuperNodes theo PiScan. Tức số node thực sự validate giao dịch thấp hơn con số PR vài bậc. Câu hỏi "ai thực sự validate giao dịch Pi" chưa có câu trả lời kiểm chứng được.

Cảnh báo từ cơ quan chức năng Việt Nam. Nền tảng pháp lý là công văn 5747/NHNN-PC ngày 21/07/2017 khẳng định tiền ảo không phải phương tiện thanh toán hợp pháp. Văn bản này không nhắc tên Pi, nhưng mọi cảnh báo sau này đều viện dẫn. Ủy ban Chứng khoán Nhà nước ngày 24/09/2023 nhắc đích danh Pi:

Bài viết VnEconomy 24/09/2023: "Ủy ban Chứng khoán cảnh báo nhà đầu Phương Tuấn không mua tiền mã hóa Pi, không ném tiền vào các công ty chứng khoán 'ma'". Highlight: "tiền mã hóa (Pi, USDT, BUSD,…) trên các sàn giao dịch chứng khoán không phải do Sở giao dịch chứng khoán Việt Nam [cấp phép]".

Tiếp theo là Công an TP Hà Nội ngày 03/03/2025, ra cảnh báo ngay sau Open Mainnet, gọi Pi là có dấu hiệu "lôi kéo, đa cấp":

Báo Dân Trí 03/03/2025: "Công an cảnh báo rủi ro liên quan Pi Network". Nội dung nhắc rõ: "Công an TP Hà Nội đã đưa ra cảnh báo đối với người dân về việc đầu Phương Tuấn vào tiền số Pi Network" và "đồng Pi chưa có tính ứng dụng thực tế. Giá trị hiện nay là tự định giá và làm nhiều người bị hiểu lầm về giá trị thật của đồng tiền ảo này".

Ở nước ngoài, điểm đáng chú ý nhất là cảnh báo chung ngày 05/12/2025 của 7 hiệp hội tài chính Trung Quốc (NIFA, China Banking Association, Securities Association of China, Asset Management Association of China, China Futures Association, China Association for Public Companies, Payment and Clearing Association of China), gọi Pi là "valueless" coin:

VnExpress International 08/12/2025: "7 Chinese financial associations label Pi Network cryptocurrency 'valueless'". Highlight: "Seven major Chinese financial associations have issued a joint warning about cryptocurrency risks, citing Pi Coin as an example of a valueless virtual asset", và "They use the guise of stablecoins, valueless coins (like Pi Coin), real-world asset tokens, and 'mining' to engage in illegal fundraising, pyramid schemes and transfer of profits".

Pi thường bị nhắc tên trong các cảnh báo về mô hình MLM trá hình. Điều đó không đồng nghĩa chính thức kết luận là scam, nhưng đủ để các cơ quan cẩn trọng. (Mình đã tìm nhưng không có evidence công khai về cảnh báo của regulator Thổ Nhĩ Kỳ hay SEC Philippines nhắc đích danh Pi. Nếu bạn có link chính thức, mình welcome update.)

KYC quy mô lớn. Để "rút" Pi, user phải gửi CMND/CCCD/passport + selfie. Chính sách lưu trữ và bảo vệ data này chưa được audit công khai. Với quy mô hàng chục triệu user, đây là kho dữ liệu PII khổng lồ, có giá trị độc lập với bản thân token.

8.3 Con cá nào sẽ phải trả gió ? (Ai mất tiền)

Câu hỏi đơn giản bị bỏ qua: tiền mà user "đào" được sẽ đổi thành giá trị thật từ đâu?

Với Bitcoin, câu trả lời là: từ người mua BTC trên thị trường, những người tin BTC sẽ tăng giá hoặc cần BTC để dùng. Có cung cầu công khai, có sàn minh bạch.

Với Pi, câu trả lời không cần đoán. Mở CoinGecko ra là thấy:

CoinGecko Pi Network page tại thời điểm 19/04/2026: giá $0.1732, Market Cap $1.77B, 24 Hour Trading Vol $19.76M, Circulating Supply 10.19B Pi, Total Supply 15.68B Pi, Max Supply 100B Pi. All-Time High $2.99 ngày 26/02/2025, giảm 94.2%. Biểu đồ 1 năm -73.2%.

Hãy đọc chậm các con số này:

  • Giá từ ATH giảm 94.2% trong 14 tháng kể từ khi Open Mainnet. Mỗi Pi bạn "đào" được hôm nay bán ra bằng 1/16 so với người bán ngày đầu.
  • Daily volume ~$19.76M, tức tổng tất cả giao dịch Pi trên mọi sàn trong 24 giờ.
  • Circulating supply 10.19B / Max supply 100B, chỉ mới 10% được "phát ra thị trường". Còn 90B Pi nữa sẽ được unlock theo schedule mining và lockup. Với rate unlock gần đây ~4.6M Pi/ngày, mỗi ngày thêm ~$800k worth of new supply vào một thị trường chỉ hấp thụ $19.76M volume.

Đây không phải "liquidity thấp" một cách mơ hồ. Đây là cung chảy vào nhanh hơn cầu có thể hấp thụ. Nếu một phần nhỏ những user đã đào quyết định bán cùng lúc, không có bên mua nào đủ lớn. Giá sẽ rơi không phải vì "thị trường xấu", mà vì toán học đã được niêm yết công khai trên CoinGecko.

A small cup overflowing with water from a giant faucet above

Giá trị thực phụ thuộc vào "ứng dụng được xây trên Pi", và repo mình vừa review là một trong những viên gạch đầu tiên, với chất lượng như phân tích trên.

Kết luận trung thực nhất mình rút ra được: với một người non-tech đang đào Pi nhiều năm, rủi ro thực tế là thời gian họ bỏ ra và KYC họ giao, chứ không phải tiền thật bị mất. Nhưng đó đã là cost không nhỏ. Đặc biệt với người lớn tuổi, KYC lộ có thể dẫn tới giả mạo danh tính, vay tiêu dùng trá hình. Mình đã thấy vài ca ở Việt Nam năm qua, không phải với Pi trực tiếp, nhưng là pattern chung.

8.4 Vì sao bug kỹ thuật ở trên lại liên quan

Có vẻ không liên quan. Một bug trong subscription contract thì ảnh hưởng gì đến Phương Tuấn đang đào Pi?

Liên quan theo cách gián tiếp. Contract mình vừa review là ví dụ mẫu được Pi Network công khai cho dev. Nếu dev Pi build app subscription dựa trên fork contract này, trial abuse bug sẽ replicate. Đặc biệt admin upgrade không có governance rail (mục 6) nghĩa là: user tin contract sẽ hành xử như code viết, nhưng bất kỳ lúc nào admin cũng có thể upgrade sang logic khác. Đây là tính chất rất phổ biến của các app "crypto sơ khởi" mà user non-tech không được cảnh báo đầy đủ.

Khi repo code chính thức của một dự án đã có lỗi logic cơ bản tồn tại ở commit initial công khai (không phải draft, không phải prototype, mà là commit đầu tiên được show cho thế giới), đó là dữ kiện về văn hóa review nội bộ và về độ tin cậy của các tuyên bố kỹ thuật khác của dự án.

Mình không biết cái dữ kiện này có đủ để Phương Tuấn dừng đào Pi không. Có lẽ không. Niềm tin được xây trong 5 năm không rã bởi một bài blog.

8.5 Nếu bạn có một Phương Tuấn trong gia đình

Two people sitting together quietly, a smartphone on the table between them

Mình không viết "lời khuyên cho người đào Pi". Phương Tuấn không đọc bài này, và nếu đọc thì đã không đào đến năm thứ 5. Mình viết cho bạn, người có thể đang có một Phương Tuấn trong họ mình và đang nghĩ nên làm gì.

Vài thứ mình học được:

  1. Đừng cãi số học. Phương Tuấn đã bỏ 5 năm. Nếu bạn cãi, nó đang bảo vệ 5 năm đó, không bảo vệ Pi Network. Cãi càng dữ, càng kiên định.
  2. Hỏi về KYC thay vì hỏi về token. Token là chuyện Phương Tuấnơng lai, KYC là chuyện hiện tại. Nó đã gửi ảnh CMND đi đâu, bản copy còn nằm ở đâu, nếu có ai đó vay tiêu dùng bằng CMND của nó thì nó phát hiện ra như thế nào. Đây là câu hỏi cụ thể hơn, có hành động cụ thể hơn.
  3. Đừng để giới thiệu thêm người. "Referral boost" là thứ làm mô hình đa cấp trông có vẻ hợp lệ. Nếu mô hình sụp, người vào sau chịu thiệt nhiều nhất. Những người vào sau qua tay Phương Tuấn sẽ là bạn của nó, hàng xóm của nó. Nó sẽ là người đầu tiên họ tìm.
  4. Cẩn trọng với "KYC lần 2". Nếu app bất ngờ yêu cầu xác thực lại với thông tin mới như số tài khoản ngân hàng, OTP, CVV, đó là bước mà scam thường đi. Không phải Pi Network bây giờ đang làm chuyện đó; nhưng là pattern cần canh.
  5. Không mua bán Pi OTC ngoài luồng chính thức. Phần lớn scam xảy ra ở đây, target chính là người lớn tuổi.

Đây là áp dụng nguyên tắc mà Richard Feynman viết: "The first principle is that you must not fool yourself, and you are the easiest person to fool." Trong công nghệ, không bao giờ nên tin vào tuyên bố mà không có khả năng kiểm chứng.

Nhưng Feynman viết cho scientist, không cho Phương Tuấn. Phương Tuấn không muốn verify, nó muốn hy vọng. Một người 62 tuổi, nghỉ hưu với 4.5 triệu/tháng, có quyền hy vọng. Mình không đủ thẩm quyền đạo đức để Phương Tuấnước hy vọng đó bằng một bài blog dài.

Mình chỉ có thể làm thứ mình làm được: đọc code, kể lại những gì mình thấy, và gửi PR fix.

9. FAQ

Q: Tóm tắt một câu?
A: Subscriber có thể lấy trial miễn phí vô hạn bằng cách cố ý làm payment fail sau mỗi lần trial. Cơ chế chống abuse của contract giả định "auto_renew=true ⇒ merchant sẽ thu được tiền" nhưng giả định này không được enforce.

Q: Cần quyền admin hay exploit token không?
A: Không. Chỉ cần một địa chỉ Stellar thường. Mọi bước đều là hàm public của contract hoặc token contract, subscriber tự sign được.

Q: Attacker có "steal" được Pi của merchant không?
A: Không, nghĩa hẹp. Không có Pi rời ví merchant. Cái mất là doanh thu lẽ ra thu được + cost-to-serve của trial (compute, bandwidth, API quota). Severity Medium vì không phải direct theft.

Q: Pi Network đã biết chưa?
A: PR fix công khai ngày 2026-04-18: PiNetwork/SmartContracts#4. Repo không có SECURITY.md, nên PR public đóng vai trò disclosure channel. Khuyến nghị Pi Network mở bug bounty / security contact.

Q: Mình là merchant đang cân nhắc deploy contract này, làm gì?
A: Ba lựa chọn:

  1. Chờ patch chính thức cho Finding #1 trước khi cấp trial.
  2. Fork & patch theo hướng dẫn mục 7, audit lại, deploy bản của bạn.
  3. Bỏ trial. Nếu business cho phép, giá rẻ của kỳ đầu có thể thay thế trial.

Tuyệt đối đừng deploy as-is với trial nếu cost-to-serve mỗi trial không thấp.

Q: Mình là subscriber, có rủi ro gì?
A: Rủi ro gián tiếp. Merchant có thể bỏ trial để chặn abuse, bạn mất quyền thử. Thêm nữa, vì admin có full upgrade rights (mục 6), nếu bạn đã approve(12 periods), lý thuyết admin độc hại có thể drain. Khuyến nghị: chỉ approve với approve_periods nhỏ (1 đến 2), hoặc revoke allowance khi không dùng.

Q: Đây có phải lần đầu Pi Network bị phát hiện lỗ hổng?
A: Repo PiNetwork/SmartContracts mới công khai, blog này là audit độc lập trên commit initial. Trong phạm vi mình biết, đây là finding đầu tiên được publish cho repo này. Các phần rộng hơn của Pi (mobile app, KYC pipeline, consensus) thì mình không có position để đánh giá.

Q: Sao viết "đào" trong ngoặc kép?
A: Vì "đào/mining" trong Pi không khớp với nghĩa mining trong blockchain kỹ thuật (mục 8.2). Ngoặc kép để người đọc thấy khác biệt, không phải mỉa mai.

Q: Pi Network có phải scam không?
A: Không phải câu hỏi kỹ thuật. Một số yếu tố red flag (referral-heavy, KYC data collection quy mô lớn, thiếu minh bạch kỹ thuật) nhất quán với mô hình MLM / data harvesting. Một số yếu tố khác (mainnet đã mở thật, token có giá trên một số sàn) nằm ngoài pattern scam cổ điển. Câu hỏi "có phải scam không" nên để cơ quan điều tra, không phải blog kỹ thuật, kết luận. Khuyến nghị neutral: hành xử với Pi như một high-risk experiment, không phải như một khoản đầu Phương Tuấn.

Q: Tác giả có conflict of interest không?
A: Không. Không hold Pi, không làm việc cho đối thủ, không hold vị thế nào trên các sàn niêm yết Pi. Toàn bộ research dựa trên code public và test harness public.

Q: Reproduce PoC?
A:

git clone --depth=1 https://github.com/PiNetwork/SmartContracts .
rustup target add wasm32v1-none
cargo build --target wasm32v1-none --release -p subscription
# Copy test từ poc/test_with_pocs.rs vào contracts/subscription/src/test.rs
cargo test -p subscription --lib
poc_

Test pass = lỗ hổng được xác nhận. ~30 giây trên máy trung bình.

Q: Còn lỗ hổng nào chưa viết?
A: Review này chỉ cover subscription contract ở commit hiện tại. Các phần khác của Pi ecosystem (mobile app, node software, KYC pipeline, DEX integrations) chưa được audit trong scope này. Researcher khác welcome mở rộng.

Q: Mình không đồng ý với mục 8, comment được không?
A: Rất welcome. Mục 8 dựa trên thông tin công khai (cảnh báo nhà nước, báo tech, phân tích cộng đồng crypto) và trải nghiệm cá nhân với bác. Nếu bạn có thông tin chính xác hơn hoặc nghĩ mình đang nhầm ở chỗ nào, chỉ ra cụ thể là tốt nhất, mình sẽ update blog.

Cuối cùng, chúc các bạn bấm tia sét vui vẻ và 1 Pi sớm lên 7 tỷ 2

Bình.