HackThisSite - Extended Basic Mission 14

Challenge

Sam was trying to make a program to show how 1337 he is. But the output isn't always correct. Help him fix his program so he can impress his friends.

Sam 写了个程序想显得自己很 1337,但输出总是不对;帮他把程序修好。

关卡页给出完整的类源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package org.hackthissite.missions.extbasic;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExtBasic14 {

private final ExecutorService executorService = Executors.newFixedThreadPool(100);
private static final int MAX = 1337;
private int timeToGetLeet = 0;

ExtBasic14() throws InterruptedException {
for (int i = 0; i < MAX; i++) {
executorService.execute(new Runnable() {
public void run() {
incrementLeetness();
}
});
}
executorService.shutdown();
while (!executorService.isTerminated()) {
Thread.sleep(500);
}
System.out.println(timeToGetLeet);
}

private void incrementLeetness() {
int obfusticatedIncremental = timeToGetLeet;
obfusticatedIncremental = obfusticatedIncremental + 1;
timeToGetLeet = obfusticatedIncremental;
}

/**
* @param args
*/
public static void main(String[] args) throws InterruptedException {
new ExtBasic14();
}

}

Solution

MAX = 1337,构造函数把 1337 个任务提交进一个 100 线程的固定池,每个任务只调用一次 incrementLeetness()shutdown 等池终止后打印 timeToGetLeet。程序想要的输出是 1337。

被点名的 incrementLeetness() 是一个三步的读-改-写:

1
2
3
4
5
private void incrementLeetness() {
int obfusticatedIncremental = timeToGetLeet;
obfusticatedIncremental = obfusticatedIncremental + 1;
timeToGetLeet = obfusticatedIncremental;
}

而且 timeToGetLeet 是普通实例字段,没有 volatile,整个方法也没有任何锁。

多线程交错执行这段代码时会出现经典的丢失更新:

1
2
3
4
5
6
线程 A: obfusticatedIncremental = timeToGetLeet    // 读到 0
线程 B: obfusticatedIncremental = timeToGetLeet // 也读到 0
线程 A: obfusticatedIncremental = 0 + 1
线程 A: timeToGetLeet = 1
线程 B: obfusticatedIncremental = 0 + 1
线程 B: timeToGetLeet = 1

A、B 各做了一次自增,计数却只从 0 走到 1:B 读到的旧值 0 在 A 写回之后仍然被写回,A 的那次增量被覆盖。100 个线程争抢同一个三段序列,丢失的更新累积起来,System.out.println 打出的值就稳定地低于 1337。

用 OpenJDK 17.0.20.1:原始类原样编为 broken,只把方法声明改成 private synchronized void 编为 fixed,各跑 10 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ javac -d broken/out broken/org/hackthissite/missions/extbasic/ExtBasic14.java
$ javac -d fixed/out fixed/org/hackthissite/missions/extbasic/ExtBasic14.java
$ for i in $(seq 1 10); do java -cp broken/out org.hackthissite.missions.extbasic.ExtBasic14; done
1333
1336
1332
1335
1337
1335
1335
1335
1337
1335
$ for i in $(seq 1 10); do java -cp fixed/out org.hackthissite.missions.extbasic.ExtBasic14; done
1337
1337
1337
1337
1337
1337
1337
1337
1337
1337

broken 十次落在 1332–1337 之间(只有两次偶然凑满),fixed 十次全是 1337。两份源码的唯一差异是那个 synchronized

1
2
3
4
5
private synchronized void incrementLeetness() {
int obfusticatedIncremental = timeToGetLeet;
obfusticatedIncremental = obfusticatedIncremental + 1;
timeToGetLeet = obfusticatedIncremental;
}

synchronized 加在实例方法上,等价于整段方法体在 this 的监视器锁内执行:同一时刻只有一个线程能进入读-改-写序列,丢失的更新随之消失。

playit 的 pass 字段比对的是修正后的整行方法声明,大小写敏感:

1
private synchronized void incrementLeetness() {

formkey 每次加载关卡页都会变,取页面和提交必须在同一次运行里完成;提交必须带 Referer: <关卡页>,否则模板页按无效 referer 丢弃。会话 cookie 从环境变量 HTS_COOKIE 读入,不落盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#!/usr/bin/env python3
"""HackThisSite Extended Basic 14 (playit) live solver.

The level ships a Java class whose counter is incremented from 1337 tasks on a
100-thread pool. ``timeToGetLeet`` is a plain (non-volatile) instance field
and ``incrementLeetness`` is a non-atomic read-modify-write, so concurrent
workers lose updates and the printed counter lands below 1337. The pinned
answer is the corrected method declaration, submitted verbatim.

Usage:
export HTS_COOKIE='HackThisSite=<mission-cookie>'
cd <hts-workspace>/challenges/hts-playit && uv run python extbasic-14/solve.py
"""
import os
import re
import urllib.parse
import urllib.request

UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36")
BASE = "https://www.hackthissite.org"
LEVEL = BASE + "/missions/playit/extbasic/14/"
SUBMIT = BASE + "/missions/extbasic/template.php"
ANSWER = "private synchronized void incrementLeetness() {"
COOKIE = os.environ["HTS_COOKIE"]
OUT = os.path.dirname(os.path.abspath(__file__))

def fetch(url):
req = urllib.request.Request(url, headers={"Cookie": COOKIE, "User-Agent": UA})
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")

def main():
page = fetch(LEVEL)
formkey = re.search(r'name="formkey" value="([^"]+)"', page).group(1)
lvl = re.search(r'name="lvl" value="([^"]+)"', page).group(1)
body = urllib.parse.urlencode(
{"formkey": formkey, "lvl": lvl, "pass": ANSWER}).encode()
req = urllib.request.Request(
SUBMIT, data=body,
headers={"Cookie": COOKIE, "User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": LEVEL})
resp = urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")
open(os.path.join(OUT, "submit_resp.html"), "w", encoding="utf-8").write(resp)
print("formkey=%s lvl=%s" % (formkey, lvl))
print("submit response bytes:", len(resp))
if "Congratz" in resp:
print("[+] accepted - the category is reported complete")
elif "/missions/playit/extbasic/14" in resp:
print("[+] accepted - go-on marker present in response")

if __name__ == "__main__":
main()

运行:

1
2
3
4
5
$ export HTS_COOKIE='HackThisSite=<mission-cookie>'
$ cd <hts-workspace>/challenges/hts-playit && uv run python extbasic-14/solve.py
formkey=EkQSIZ58VEAE5WHfCkHKKx5xJkZtg5RtauGvVojU lvl=14
submit response bytes: 24074
[+] accepted - the category is reported complete

14 是本分类的最后一关,接受后提交响应里带总结语:

1
2
These are the _extended_ basics.
Congratz! You have completed all the missions in this category

Key points

  • 递增(x = x + 1)不是原子操作,多线程下必须用锁或原子类型保护;synchronized 方法等价于用 this 做互斥。
  • 只加 volatile 不够:它保证可见性与有序性,但不会让读-改-写变成原子操作,两个线程仍能交错读走同一个旧值。
  • AtomicInteger.incrementAndGet() 用 CAS 免锁达到同样的原子性;本例只需修 incrementLeetness,加 synchronized 是最小改动。
  • 竞争窗口越小越难复现:broken 十次里有两次恰好是 1337,判断修好没有要多跑几次,不能只看单次输出。
private synchronized void incrementLeetness() {