The count RNG and the runs
The number of GUIDs per post comes from one long-lived C# Random per app start, seeded with the machine's uptime. It identifies runs, restarts and reboots.
The mask digit comes from new Random(DateTime.Now.Millisecond).Next(9), a fresh Random for
every post. The other number in each filler post, how many GUIDs it contains, comes from a second Random:
one created once when A858's program starts and then asked for one number per scheduled post. With the right seed,
the count of every post in a run can be reproduced, including runs that have never been decrypted.
Reading the count without decrypting
For the 2012–2013 posts that decrypt, the count N is the number of GUIDs in the decrypted list. The later posts (from 2013-10-16) have never been decrypted, but N can still be read off their length. Every filler post is N GUIDs of 32 hex characters, encrypted with 3DES, plus one 8-byte padding block, then posted as hex. So the post is exactly 64·N + 16 hex characters long. All 6,120 filler posts after the cutover fit this exactly. The other posts from that period are the manual posts (hand-made, and the three account-setup posts) and one status marker. The tiny 16-character posts are the N = 0 case: an empty list, which is only padding.
So even undecrypted posts carry a number, and lined up in posting order those numbers can be matched against an RNG.
The last 16 characters are the padding. A GUID list is always a whole number of 8-byte blocks, so .NET's default
PKCS7 padding adds one full block of eight 08 bytes, and every one of the 3,434 scheduled filler posts before the cutover
that decrypts ends in exactly that block. Under one key and ECB mode that block always encrypts the same way: all 487
posts under MD5 of the name end in 5dacffba8ff64dbd, and all 268 under MD5 of the name twice share a tail too. After
the cutover, none of the 6,010 posts checked shares its last 16 characters with another, so they were not encrypted with
one fixed key in ECB mode, as the earlier filler was. That rules out the simplest kind of key, not the others: a different key for every post, or
CBC mode with an IV that was never posted.
The count is Next(lo, hi) from a long-lived Random
Take the run that starts with 195ea8 (2013-02-24 20:51 UTC), which posted every 66 minutes. The GUID counts of its first posts are:
60 48 40 60 56 44 48 36 48 44 32 40 44 60 32 ...
Every count in this period is a multiple of 4 between 32 and 60. That is 4 * Next(8, 16), and the counts come out
in order from:
var random = new Random(983934859);
random.Next(); // one draw when the app starts
for (var i = 0; i < 15; ++i)
Console.Write(4 * random.Next(8, 16) + " ");
// 60 48 40 60 56 44 48 36 48 44 32 40 44 60 32
It does not stop after 15. The run lasted until 2013-03-09 (267 timer slots), and every surviving post lands on the right number when placed in its 66-minute slot. Missing slots are posts that never made it to Reddit or were lost; each one still used up its draw. A single seed cannot fit hundreds of posts by chance: each post has 8 possible values, so 259 posts are worth about 777 bits against a search of about 231 seeds.
The seed is large here, not 0–999, because this Random is created with new Random() and no argument. In .NET
Framework that uses Environment.TickCount: the number of milliseconds since Windows booted.
Different settings over time
The count code was rebuilt in December 2012 – January 2013, and it changed how many draws the program makes (see the draws that were never posted below). After that, the range changes whenever A858 restarted the program or changed its settings:
| Period | Count per post |
|---|---|
| Jun 2012 | Next(8, 24) |
| Jun – Dec 2012 | Next(8, 64) |
| Dec 2012 – Jan 2013 | fixed 64 |
| Jan – Aug 2013 | 4 * Next(8, 16) |
| Aug – Oct 2013 (into the first undecrypted week) | 4 * Next(1, 12) |
| Nov 2013 – Aug 2014 | Next(0, hi), hi between 20 and 184 per run |
| mid 2014 – mid 2015 | mostly one fixed N per run (40, 42, 51, 80, …) |
| Aug 2015 | Next(0, hi) with 2 extra hidden draws per post |
| Nov 2015 – Feb 2016 | Next(10, hi), hi between 24 and 80 per run |
The first undecrypted stage (1okyzu, 2013-10-16) uses exactly the same 4 * Next(1, 12) as
the decrypted posts just before it. The "new era" after the October 2013 outage is the same program, restarted with
a new key, not a different program.
Draws that were never posted
The count stream also draws for some slots that never produce a post, and how many depends on the build:
- 2012 build (
Next(8, 24), thenNext(8, 64)). One draw per post attempt and nothing else. Launching the program and changing its schedule draw nothing, so the first post of a run is draw 0 (40 of the 41 runs). The 37 schedule changes and pauses in 2012 used no extra draw. - From the December 2012 – January 2013 build. The program also draws once when it starts, and that draw is never posted, so the first post of a run is draw 1 (35 of the 36 runs whose first draw is known). Schedule changes in 2013 to 2015 drew once more in 6 of 7 cases in the placed runs. The companion account's run of January 2016, a second instance started seven seconds after a main run, skips its draw 0 in the same way.
- Both builds. A slot whose post did not survive, because Reddit did not accept it or it was removed before it was archived, would still use its draw. None turned up in the placed runs. The gaps that first looked like lost posts were posts of the same run filed under another label, and they now sit in their runs.
These readings come from the timing and the draw positions, not from anything the program shows directly. A few runs do not fit: 1okyzu starts at draw 2, and one 2012 run starts at draw 1. What the startup draw is used for is still unknown. Its value does not show in the counts, the GUID seeds, the timing, the title clock, the archive metadata or the post-cutover salts tested so far.
The seed tells you when the machine booted
Since the seed is milliseconds since boot, app start − seed gives the boot time. For the Feb 2013 run, 983,934,859 ms is 11 days 9 h 19 min, which puts the boot at 2013-02-13 10:26–11:32 UTC (the range comes from not knowing exactly when in the first timer slot the app started).
- seed
- probable
- implied boot
2013-02-13 is the day after Patch Tuesday, and that happens again and again: boots come out, for example, as 2012-07-11, 2012-08-15, 2012-10-10, 2013-01-09, 2013-02-13, 2013-06-12, 2013-11-14, 2013-12-11, 2014-03-12, 2014-04-10 and 2014-05-15, each one or two days after Microsoft's monthly update Tuesday, as expected for a Windows machine that reboots for updates. Several runs in a row often share the same boot; some runs end when the machine reboots, others when only the app is restarted.
Two more details back this up:
- From July 2012 to 2014, every recovered seed is a multiple of 15.625 ms, rounded down: the 64 Hz tick of
Environment.TickCounton Windows of that era. All 57 seeds from that period fit it; by chance each one would only have a 6.4 % chance. - On 2012-06-26/27, the first days of the automated posts, there are eight separate app starts from two different boot times, so two machines were running the program at once.
A prediction that came true
On Reddit, the 25-minute run of November 2015 appeared to start with 3tjz5s on 2015-11-20 11:03. Its counts fit
Next(10, 40) with seed 6,038,486, but only if 144 draws had already happened before that post. The next run's
seed (December 2015, 3v2sif) independently puts a reboot at Nov 17 20:51–21:51, and
6,038,486 ms is 1 h 41 min, so this app started on Nov 17 roughly between 22:30 and 23:30. Counting back 143 slots of
25 minutes from Nov 20 11:03 lands on Nov 17 23:28, inside that window: the app had made 143 posts nobody had seen.
Those posts turned up later in the Arctic Shift archive: 143 posts from 2015-11-17 23:27 to 11-20 10:38, every 25 minutes, with exactly the predicted counts. The full run (run 70) is 704 posts long and every one of them matches.
Every filler post is accounted for
Before the cutover (3,438 GUID-list filler posts, 2011 → 2013-10-08):
| What | Posts |
|---|---|
| Reproduced by a seed | 3,296 |
| Body removed, placed by its count (1158zr) | 1 |
| Fixed N = 64 (Dec 13 2012 – Jan 1 2013) | 91 |
| Short app starts, too few values to pin a seed | 46 |
| Single posts just before an app start | 4 |
Not counted here: 42 operator test posts (bursts seconds or a minute apart, often with Pacific-time titles), 32 hand or tool lists from before the scheduled app and one single test post. They are manual posts, listed on the manual posts page.
After the cutover (6,120 length-law filler posts, 2013-10-16 → 2016):
| What | Posts |
|---|---|
| Reproduced by a seed (21 runs) | 3,175 |
| Same, but posted with an empty body (the seed still drew a count) | 8 |
| Fixed N (41 runs, one per app start) | 2,921 |
| Probable seed (Nov 13 2015) | 5 |
| Short pieces and single test posts, each explained | 11 |
The three account-setup posts of 2014-02-25 are manual posts, not filler, so they are not in this table.
Details from these passes:
- Status markers. In summer 2012 A858 also posted near-empty "marker" GUIDs (the batch markers).
00000000-0000-4000-0000-0000000000NNopens a batch of NN posts and an all-zero GUID closes it. In 13 of the 18 batches from that summer the number of filler posts in between is exactly NN. The batch with NN = 13 posted 11 posts, stalled for 8 hours and then got 3 posts from elsewhere; it is followed by the two odd markers with405Cinstead of4000on 2012-08-13, the eve of Patch Tuesday. What405Cmeans is still open. - A single from April 2013. 1c4e07 (11 April 2013, count 60) is shown as a single post, not as part of the run after it. Its count can be read either as draw 95 of the previous run's stream or as draw 0 of the new run, and the timing cannot choose between them, so it is left out of both. It was posted under the previous run's settings, and the new run's first post is 1c6pey, at draw 1. A check of its count against the startup draw fits, but the same fit turns up by chance in four of the other 33 runs, so it decides nothing.
- Short runs. Four or nine posts are too few to pin a seed on their own. But the boot time bounds when the app started, and the first post follows the start within one posting interval, so together they narrow the seed to a window small enough to search completely. That gives probable seeds for three runs: a five-post burst on 1 July 2012 (seed 293,378,646), the four-post batch of 1 August 2012 (seed 377,361,921) and the nine daily posts from 13 February 2013 (seed 33,600,187). All three are now runs with a probable seed on the runs page, but none is certain, because a chance fit in a window that size cannot be ruled out. The runs page gives each of these seeds and the search behind it, and the other short pieces that have no seed or only candidates.
- Start times. When each run started, and the weekday and time-of-day pattern in those starts, is on the runs page.
- Idle and resume.
y4ylhand the ten posts after it (2012-08-13) are draws 74–84 of a run that had stopped 28.7 hours earlier: the program sat idle without drawing, then carried on with the sameRandom, a new interval and a new title time zone. - Empty posts. On Apr 7–8 2014 eight posts are empty even though the app drew counts for them; on Mar 31 – Apr 2 2014 a separate app start posted 18 empty posts every 3 hours. "Post nothing" was a setting.
- Test starts. After a long pause the first post is often a test: account-setup posts on 2014-02-25, a single
post on 2015-03-12 in a four-week outage, and
3sp5w7on 2015-11-13 after 73 days of silence. - Pacific-time titles are not a sign of a hand-made post after the cutover. Three fully scheduled runs use them, and their titles follow US daylight saving: 1psxd6 goes from −7 to −8 when it ends on 3 November 2013. So a title is the operator's clock time, written in a time zone with its daylight-saving rules. A run keeps one zone until its settings change, and the run pages mark each change.
- 1rq36r (Nov 29 – Dec 11 2013) is the rest of run 55, not a run of its own. Run 55 posted
hourly until 23 November (draw 191). After a gap of nearly six days, draw 192 went out at 18:52 on 29 November,
and from 19:00 that day the posts came every 2 hours, on the hour. These are the 141 posts of 1rq36r. The interval
and the range changed together at that point. Run 55's seed, 135,104,921, draws
Next(0, 184)for draws 1–192. From draw 193 on, the sameRandomdrawsNext(0, 124)instead, and all 141 counts match at their draws (194–334). Draw 193 is not posted. The earlier fits that used one range for the whole stream found nothing.
What this means
- The count carries no message. It is an RNG seeded by the machine's uptime; the run settings (range, fixed N, interval) are typed in by the operator.
- The length of a run is not drawn either. The seeded runs are 4 to 704 posts long (median 37), with no round totals, GUID budgets or whole-day lengths among them. A run ends when the app is stopped or the machine reboots.
- It does not give the key for the undecrypted posts.
- It does give a solid timeline: when A858's machine booted, when the program was restarted, when several copies ran at once, and when posts are missing. Every run has its own page.
Check it yourself
This script ports the .NET Framework Random (including its int32 overflow, which matters for large seeds),
regenerates the start of the Nov 2015 run and compares it with the posts from Arctic Shift, using the length of each
post to get N. No decryption is involved.
import json, re, urllib.request
def net_random(seed):
# .NET Framework System.Random(seed), including its int32 overflow
M = 2147483647
w = lambda x: (x + 2**31) % 2**32 - 2**31
sa = [0] * 56
mj = w(161803398 - seed)
sa[55], mk = mj, 1
for i in range(1, 55):
ii = 21 * i % 55
sa[ii], mk, mj = mk, w(mj - mk), mk
if mk < 0: mk = w(mk + M)
for _ in range(4):
for i in range(1, 56):
sa[i] = w(sa[i] - sa[1 + (i + 30) % 55])
if sa[i] < 0: sa[i] = w(sa[i] + M)
a, b = 0, 21
while True:
a = a % 55 + 1
b = b % 55 + 1
r = w(sa[a] - sa[b])
if r == M: r -= 1
if r < 0: r = w(r + M)
sa[a] = r
yield r / M
def next_range(rng, lo, hi):
return int(next(rng) * (hi - lo)) + lo
# Nov 2015 run: seed 6,038,486, one draw at startup, then Next(10, 40) per 25-minute slot
rng = net_random(6038486)
next(rng)
counts = [next_range(rng, 10, 40) for _ in range(40)]
url = ('https://arctic-shift.photon-reddit.com/api/posts/search?subreddit=A858DE45F56D9BC9'
'&after=2015-11-17T23:00:00&before=2015-11-18T12:00:00&limit=100&sort=asc'
'&fields=id,created_utc,selftext')
req = urllib.request.Request(url, headers={'User-Agent': 'a858-count-check'})
posts = json.load(urllib.request.urlopen(req))['data']
t0 = posts[0]['created_utc']
for p in posts:
n = (len(re.sub(r'[^0-9A-Fa-f]', '', p['selftext'])) - 16) // 64
slot = round((p['created_utc'] - t0) / 1500)
print(p['id'], 'slot', slot, 'N =', n, 'predicted', counts[slot])
3t82b2 slot 0 N = 10 predicted 10
3t8601 slot 1 N = 38 predicted 38
3t89g7 slot 2 N = 31 predicted 31
...