Wasting Time
- Category: misc
“Hope I could waste your time. Good luck.”
Filed under misc, but it is two challenges stacked: unwrap a PyArmor’d service to learn what it checks, then write a C quine under a blacklist that removes the preprocessor, string literals and printf at once.
Solution:
1. First contact rejects everything
$ nc wastingtime.chal.sunwaycybersecurityclub.org 1337
C source, end with a blank line:
#include <stdio.h>
int main(){ printf("hello\n"); return 0; }
Rejected source
The handout is challenge.py — PyArmor 9.2.6 (trial), four lines wrapping a 42 KB blob. Guessing the filter from outside is the time-wasting path the title advertises.
2. sys.settrace walks straight through PyArmor
The blob header is PY000000 \x00\x03\x0b — CPython 3.11 code objects, so the module only imports under 3.11 (uv python install 3.11). It imports cleanly, and module-level constants are already plaintext:
>>> import challenge as c
>>> c.BANNED, c.MAX_SOURCE, c.ROUNDS
(('#', '?', '%', '"', "'", '\\'), 3000, 20)
Function bodies are not. PyArmor 9 keeps real function objects and encrypts only co_code, decrypting in place on frame entry and re-encrypting on exit — that is what the C_ENTER_CO_OBJECT_INDEX / C_LEAVE_CO_OBJECT_INDEX builtins bracketing every body do. f.__code__.co_code from outside is ciphertext; frame.f_code.co_code from inside a live frame is not, and the runtime never blocks sys.settrace. That is the whole tool:
def tr(frame, event, arg):
co = frame.f_code
if "challenge" in co.co_filename:
codes[(co.co_name, co.co_firstlineno)] = (co, co.co_code) # decrypted here
return tr
sys.settrace(tr)
c.main() # every function that runs gets dumped
sys.settrace(None)
co.replace(co_code=...) glues the plaintext back onto the otherwise-intact code object for dis. orbit_vm and rotate_left only run after a round verifies, so they get called directly instead of reached through main. Full tool: unarmor.py.
3. What the service is really asking for
main writes the source to a temp .c, compiles it, and then deletes it before any round runs:
proc = subprocess.run(["gcc", "-std=gnu11", "-O0", "-w", "-o", binpath, cpath], ...)
os.remove(cpath) # the .c is gone before the binary ever runs
for rnd in range(1, ROUNDS + 1):
start, step = random_orbit(len(src_bytes))
seed, prog = random_u64(), make_program()
noise = os.urandom(len(src_bytes))
out = subprocess.check_output([binpath], input=header + progblob + noise, timeout=5)
if int(out.strip()) != orbit_vm(src_bytes, noise, start, step, seed, prog):
fail("Verification failed")
Each of the 20 rounds pipes in a 32-byte header (start, stride, seed, nprog as LE u64s), nprog instructions of [u8 op][u64 k], then len(source) pad bytes, and wants one decimal integer back:
def orbit_vm(source, pad, start, stride, seed, program):
acc = seed ^ 0x9E3779B97F4A7C15
idx = start
for i, p in enumerate(pad):
mixed = source[idx] ^ p # <-- source[], byte by byte
acc ^= mixed + 1
for op, k in program:
if op == 0: acc ^= k
elif op == 1: acc = (acc + k) & MASK64
elif op == 2: acc = (acc * k) & MASK64
elif op == 3: acc = rotate_left(acc, k)
elif op == 4: acc ^= ((mixed + k + i) * 0x1385B9ED001AE395) & MASK64
else: acc = (acc + (seed ^ k ^ i)) & MASK64
idx += stride
if idx >= len(source):
idx -= len(source)
return rotate_left(acc, 29) ^ 0x8F4577ED9F3F5715
random_orbit picks a stride coprime to len(source), so the walk hits every index exactly once — partial knowledge of the source is worth nothing. The VM is noise. The statement is that the binary must reproduce its own source byte for byte, from memory, after the file is gone: a quine. BANNED = # ? % " ' \ decides what kind — no preprocessor, no literals, and no %, which takes printf and the modulo with it, inside MAX_SOURCE = 3000 bytes.
4. Budgeting the encoding
With no string literals the source carries itself as an integer array: long a[]={<NUMS>};<REST>, where a[] encodes everything except the <NUMS> run, and <NUMS> is regenerated at runtime by printing a[] back out in decimal. That closes the self-reference — the array’s textual form is derivable from its own values.
Total size is |T| * (1 + r) for code text T and chars-of-<NUMS> per char of T:
| encoding | element holds | cost | r | total |
|---|---|---|---|---|
int a[]={108,111,...} |
1 char | ~3.4 per char | 3.40 | 4160 |
| 8 chars x 8 bits | 8 chars | 20 per 8 | 2.50 | 3187 |
| 9 chars x 7 bits | 9 chars | 20 per 9 | 2.22 | 2851 |
The middle row is the trap. A full 64-bit word feels like the dense option, but the top byte of an ASCII pack is 32..126, so every element is ~1.8e19 — 19 or 20 digits either way. Cost is per element, so the win is more characters per element, not more bits. Seven bits each fits 9 chars in 63 bits, still inside a signed long.
5. The program
2851 bytes, one line, no banned characters. Expanded for reading:
long a[]={4448271145916676076,7334560076886114043,...,99 elements};
typedef unsigned long u;
int getchar();int putchar();
char s[3100],w[1100],b[24],o[40];
int n,i,j,x,c,A;
u m,t,q,d,e,g,y,k[40];
void dc(u v){c=0;do b[c++]=48+(v-v/10*10),v=v/10;while(v);while(c)s[n++]=b[--c];}
u rd(){u v=0,z=0;for(;z<64;z+=8)v|=(u)getchar()<<z;return v;}
int main(){
A=sizeof a/sizeof*a;
for(i=0;i<A;i++)for(j=0;j<9;j++)w[i*9+j]=a[i]>>j*7&127; /* unpack T into w */
for(i=0;i<10;i++)s[n++]=w[i]; /* "long a[]={" */
for(i=0;i<A;i++){if(i)s[n++]=44;dc(a[i]);} /* <NUMS> */
for(i=10;w[i];i++)s[n++]=w[i]; /* "};" + the rest */
q=rd();d=rd();e=rd();g=rd(); /* start,stride,seed,nprog */
for(i=0;i<g;i++)o[i]=getchar(),k[i]=rd();
m=e^0x9E3779B97F4A7C15;
for(i=0;i<n;i++){t=s[q]^(u)getchar();m=m^t+1;
for(j=0;j<g;j++){x=o[j];y=k[j];
if(!x)m=m^y;
else if(x==1)m=m+y;
else if(x==2)m=m*y;
else if(x==3)m=m<<y|m>>64-y;
else if(x==4)m=m^(t+y+i)*0x1385B9ED001AE395;
else m=m+(e^y^(u)i);}
q=q+d;if(q>=n)q=q-n;}
m=(m<<29|m>>35)^0x8F4577ED9F3F5715;
n=0;dc(m);for(i=0;i<n;i++)putchar(s[i]);putchar(10);
}
dcis the missingprintf:v-v/10*10for the modulo, digits intos[]. It builds<NUMS>, then runs once more withnreset to 0 — the routine that writes the quine writes the answer. The orbit wrap needs no modulo either, sincestride < len.int getchar();int putchar();by hand: GCC 14 makes an implicit declaration a hard error, and-wsuppresses warnings, not errors.Thas no NUL and the pack is zero-padded, sofor(i=10;w[i];i++)self-terminates.^binds looser than|: without the parensm=(m<<29|m>>35)^Ksilently becomesm<<29|(m>>35^K).m^t+1is correctlym^(t+1), since+binds tighter.
6. Verify offline, then spend one connection
Twenty consecutive rounds behind a fail() that closes the socket means every guess costs a reconnect, so the recovered VM doubles as an oracle. ./solve.py verify runs 200 randomised rounds against it, then the real obfuscated challenge.py under 3.11 checks what the oracle cannot — lines_are_valid and gcc:
[+] 200/200 rounds match
$ (cat sol.c; echo) | python3.11 -c 'import challenge; challenge.main()'
round 01: ok ... round 20: ok
All copies match. sunway26{local_test_flag} <- FLAG env var, placeholder default
Remote, on the first connection:
round 01: ok
...
round 20: ok
All copies match. sunway26{c0pycat_n3v3r_f0rg3ts_3v3ry_s1ngl3_byt3_1t_3v3r_wr0t3}
Note the prefix: the challenge text promises sunctf26{...}, but this service emits sunway26{...}, unlike the rest of the CTF. Submit what the socket says.
Full script: solve.py
Flag: sunway26{c0pycat_n3v3r_f0rg3ts_3v3ry_s1ngl3_byt3_1t_3v3r_wr0t3}
Takeaways
- PyArmor 8/9 decrypt
co_codeon frame entry, so the plaintext exists only while a frame is live — andsys.settraceruns inside that window unblocked. A nine-line trace function plusco.replace(co_code=...)gets full disassembly with no unpacker, no debugger, no patched interpreter. Match the interpreter to the blob’s magic first. - A character blacklist is a spec, not an obstacle to route around: read the banned set as a list of things to reimplement.
- Packing into printed decimal costs per element (~20 chars for anything near 64 bits), not per bit. Fit the most characters per element, not the most bits — worth 10% of the budget here.
- A challenge that deletes your source after compiling and then asks about its bytes wants a quine, whatever the category says. The tell is a checker walking
source[]with a stride coprime to the length: coprime means full coverage, so no partial-knowledge shortcut exists.