English — click to expand
Phreaking was the hacker's playground of the 1970s. Today, it's building your own local 5G network.
This repository brings up a working 5G standalone (SA) mobile network on one Linux box and one SDR. Real commercial handsets attach to it, phones ring, and texts and pictures go back and forth.
📕 The whole journey of building this network is written up as a book: Building a Local 5G Network — A Hacker's Field Guide to Mobile Networks (in Korean) · Download the PDF · Manuscript repository
![]() A VoNR call carried over our own network. HD · 5G in the status bar |
![]() A 270-character text arriving whole, not in pieces |
In the 1970s, phone phreaks whistled 2600 Hz into a handset to move switches on the far side of a continent, and through that crack they got hooked on peering into the guts of a vast telephone network. For the half-century that followed, mobile networks were the most closed territory a hacker could face. Now the core has turned into Linux processes calling one another over HTTP/2, and the base station runs on a software radio plugged into USB. If the phreaks found ways to fool the switch, we now get to own the switch. We sign up subscribers on SIMs whose secret keys we chose and burned ourselves, ring a real phone with a number we issued, and follow every message in between, from first to last, in Wireshark.
Nor does it end as a toy. The network's only way out to the internet is the Linux box's default route, so take the box and the radio somewhere the commercial network has gone dark, plug in a satellite terminal, and our cell comes alive right there. That is the real goal of this repository: a network of our own to bring up when the commercial network is down. It has been validated on 5G SA, and the actual deployment target is 4G LTE.
We publish this repository and the book because we want to leave a signpost at every spot where we got lost. We lost half a day to a single USB 2.0 cable, and watched a real phone reject, on its very first call, a call setup that had worked between our scripts for four months. We would like the next person not to throw away the same hours in the same places.
Click a photo to see it full size.
Further down, this README describes how to rebuild the same network from bare metal, step by step, with a way to verify each step. You don't have to type it in line by line, though. Open Claude Code in a clone of this repository and hand it over like this:
Rebuild this network from scratch following the rebuild guide in the README.
Show me that each step's verification passed, and stop to get my confirmation right before the base station transmits.
It will fetch the upstream project, load the images, render the configuration, provision the subscribers and bring up the core and IMS, verifying as it goes, and be done in no time. That this procedure really stands up from this repository alone was confirmed on 2026-10-15: we wiped everything down to the database volumes and rebuilt from a fresh clone from GitHub.
But the things you can hold in your hand have to be prepared by a person in advance. Claude Code builds software; it can't buy a card or tighten a cable.
- USIM — a card you can write keys to (we used the sysmoISIM-SJA5) and a PC/SC card reader. Put the Ki, OPc and ADM1 values that come with the card into
configs/secrets/lab.env, copied fromconfigs/secrets/lab.env.example. Be sure to read the USIM trap under "0. Hardware" first. - Hardware — a Linux host with a USB 3.0 port (Ubuntu 24.04), an SDR (USRP B210 or compatible), a UE (Quectel RM520N-GL 5G modem, or a real phone plus a shield box), coaxial cables, 40 dB attenuators, and 50 Ω terminators.
- Wiring and permission to transmit — connecting cables, fitting the attenuators, and checking the wiring with your own eyes before the base station transmits for the first time are a person's job. Connect directly without an attenuator and you burn the B210's receiver; let it leak into the air and you interfere with other people's communications.
| Measured | |
|---|---|
| 5G SA registration | A real Galaxy S22+ shows HOME, technology NR, PLMN 001/01 |
| Data | RTT to the UPF 25–31 ms, to the outside 54–58 ms, 0 % loss |
| Voice (VoNR) | Incoming call shown on the handset, call established, RTP in both directions |
| Video call | Video RTP sent 332 / received 2697 (20 s) |
| SMS | No length limit (UDH concatenated SMS). 270 characters merged into one message |
| Multimedia (MMS) | Both directions. A JPEG sent by the handset matches the original by SHA256 |
| RF leakage | Not detected outside the shield box (method and limits: book §15.5a) |
Radio goes through walls. The moment you radiate into the open air, you are operating a radio station that requires a licence. Under Korea's Radio Waves Act, establishing or operating an unlicensed radio station is punishable by up to 3 years in prison or a fine of up to 30 million won. Check the rules where you live.
- PLMN is fixed to the test code 001/01. Never use commercial codes (450-05 SKT / 450-06 LGU+ / 450-08 KT)
- Cross-check the frequency against commercial allocations. n78 in Korea: LG U+ 3400–3500 / KT 3500–3600 / SKT 3600–3700 MHz
- Don't trust the frequencies in upstream examples. Most default to the middle of a commercial band
- Transmit over direct coax + attenuators, terminate unused ports with 50 Ω, and use a shield box if you can
- Always start the transmit gain at the minimum
- If an SDR with an antenna is attached to the same host, make sure that device is never opened (
serial=pin)
Details: book Appendix D, "Radio and legal notice".
Follow the steps in order. Each step ends with how to verify it. If verification fails, don't move on — it will come back later as some unrelated-looking symptom.
Verification record. This procedure was actually torn down and rebuilt end to end on 2026-10-15. We removed the containers and the database volumes with
docker compose down -v, then cloned this repository fresh from GitHub and rebuilt from that alone. Result: rendered configs bit-identical to before, core NFs 5/5, 3 subscribers created, and the real handset re-registered on00101, got both its internet (10.45.x) and IMS (10.46.x) sessions, and exchanged texts.The process exposed three defects in this repository, which we fixed: the upstream path could not be found through a symlink;
cd && pwdreturned the logical path so the symlink pointed at itself; and on an empty database the PyHSS APN rows were never created, so subscriber provisioning hit a foreign-key constraint. That last kind never shows up on a lab that is already running.
| Item | What we used | Notes |
|---|---|---|
| Linux host | Ubuntu 24.04, x86_64 | Anything that runs Docker |
| SDR | Ettus USRP B210 / LibreSDR B220mini | Pin the serial of the unit that transmits |
| USIM | sysmoISIM-SJA5 | Read the trap below |
| Card reader | PC/SC CCID (Identiv SCR35xx etc.) | pcscd |
| UE ① modem | Quectel RM520N-GL + carrier board | USB, 2c7c:0801 |
| UE ② real handset | Galaxy S22+ (SM-S9060), root required | To control it inside the shield box |
| Shield box | TOJOIN MS3020-A | 4 SMA feed-throughs, filter panel |
| Attenuators | 30 dB · 40 dB SMA | Transmit path |
| Terminators | 50 Ω SMA | Every unused port |
| USB-C Ethernet adapter | RTL8153 family | Wired adb to the handset in the shield box |
| Coax cables | SMA | Longer means more loss. Record the lengths |
AliExpress "LTE Blank USIM" cards and the like will not work. The listing says
Compatible: 4G FDD LTE, WCDMA and GSM, but the actual card is 2G GSM only.
There is no EF.DIR, no AID and no ADF.USIM — only DF_GSM(7F20). Finding the ADM key doesn't help,
because the USIM application physically isn't there. We didn't know this, and only found out after
using up a card chasing the ADM key.
Telling whether a card is a USIM takes one minute. Do this before chasing ADM.
sudo ~/.venv/bin/python3 pySim-read.py -p0 | head -20
# You must see "Autodetected card type" and ADF.USIM. If not, throw that card away.The proper cards came from Europe. We ordered the sysmoISIM-SJA5 from sysmocom in Germany.
- Shop: https://shop.sysmocom.de/SIM/Cards/
- The product name must include
with ADM keys. Without the ADM key you can't write to the card; sysmocom sends ADM1 by e-mail for each order - Pick the regular card, not MFF2 (solder type)
- The only difference between 9FV and S17 is who computes the SUCI. Open5GS defaults to the null scheme, so 9FV is enough, and S17 is often out of stock
- Shipping takes a while. Order first, so you aren't waiting for cards with the whole network already built. That is exactly how our work once stalled
Full notes: docs/sim-cards.md.
sudo apt update
sudo apt install -y docker.io docker-compose-v2 git python3-pip \
pcscd pcsc-tools android-tools-adb nmap avahi-utils \
uhd-host python3-uhd zstd
sudo usermod -aG docker "$USER" # log in again afterwards
sudo uhd_images_downloader # UHD firmware/FPGA
sudo systemctl enable --now pcscd
python3 -m venv ~/.venv && ~/.venv/bin/pip install pyscardGet ModemManager out of the way. Left alone, it grabs the modem's AT port and kills the QMI session. The symptom is "it works, then drops", which makes the cause hard to find.
sudo systemctl mask ModemManager # or exclude just that device via udevVerify — docker ps runs without a permission error and uhd_find_devices sees the SDR.
python3-uhdis installed for the system Python version.import uhddoes not work inside~/.venv. Run the measurement tool (tools/leakscan.py) with the system Python, e.g./usr/bin/python3.12.
The core and IMS containers use the upstream project's compose files. Our configs are the source of truth; the upstream clone is the deployment target.
git clone <this repository> ~/work/private5g
git clone https://github.com/herlesupreeth/docker_open5gs.git /path/to/docker_open5gs
git -C /path/to/docker_open5gs checkout 1f3bcf7
ln -s /path/to/docker_open5gs ~/work/private5g/upstreamA symlink is all it takes. You don't need to copy our directory layout — the scripts look for the
upstream link first. (You can also set it with the UPSTREAM_DIR environment variable.)
Don't touch the upstream clone. Edits made there are overwritten by the next deploy.
Verify — ./bin/deploy-configs.sh --check does not complain that the upstream clone can't be found.
Pulling from ghcr.io is the normal route, but upstream may move or delete tags. In case they can't be found online, the exact images we ran are attached to this repository's release. This repository alone is enough to restore everything.
./bin/load-images.sh # downloads release images-v1, checks checksums, then docker load11 images, 4.0 GB compressed in total. Both the local tags compose uses and the ghcr tags are included.
Verify — docker images | grep docker_open5gs shows both latest and master.
Real keys and host-specific values come from this one file only. The repository holds just the template.
cp configs/secrets/lab.env.example configs/secrets/lab.env
chmod 600 configs/secrets/lab.env
$EDITOR configs/secrets/lab.env| Value | Where it comes from |
|---|---|
DOCKER_HOST_IP |
The host's LAN address (ip -br addr) |
B210_SERIAL |
serial: from uhd_find_devices. The one unit that will transmit |
PHONE_IP |
A static address for the handset in the shield box |
UE*_KI, UE*_OPC |
Values from the card supplier (sysmocom e-mail/CSV) |
UE3_ADM1, UE3_ICCID |
Used when programming the card |
Verify — ./bin/deploy-configs.sh does not report unfilled tokens.
If something is empty, it names what is missing and stops.
cd ~/work/private5g
./bin/deploy-configs.sh # render templates + deploy into the upstream tree
./bin/core-ctl.sh START # waits until NFs are 5/5
./bin/provision-subscribers.sh # "creates" 2 APNs + 3 subscribers
./bin/provision-subscribers.sh --checkprovision-subscribers.sh creates rather than restores a dump. On an empty database it builds the APNs
(internet, ims) and the subscribers from the template
configs/db/subscriber.template.json, reading values from lab.env.
It is idempotent, so running it repeatedly doesn't pile up duplicates.
Verify — --check reports 3 subscriber rows in mongo and 3 rows each in PyHSS auc/subscriber/ims_subscriber.
sysmocom sends a card list per order, as CSV or e-mail. One line is one card, and we only need four fields.
| Field | Example | Used for |
|---|---|---|
| ICCID | 894944… 19 digits |
Card identity. Cannot be changed |
| ADM1 | 8 digits | Write permission. Without it you can write nothing |
| Ki | 32 hex digits | Authentication key. Card and HSS must match |
| OPc | 32 hex digits | Operator variant |
The rest (PIN1/PUK1/PIN2/PUK2 and the nine KIC/KID/KIK values) are for OTA and unused in our lab.
These values never go into the repository. Keep them only in configs/secrets/lab.env.
ADM1 has a limited number of retries. Enter wrong values repeatedly and the card locks permanently. We lost a different card that way. Never guess.
sudo systemctl start pcscd
sudo pcsc_scan -r # is the reader visible?Get pySim.
git clone https://gitea.osmocom.org/sim-card/pysim.git ~/work/pysim
cd ~/work/pysim && ~/.venv/bin/pip install -r requirements.txtAlways read before you write. This one read tells you both "is it a USIM?" and "is ADM1 right?".
sudo ~/.venv/bin/python3 pySim-read.py -p0 | head -25Autodetected card type: sysmoISIM-SJA5→ a card we know- Further down you must see
ISIM Service TableandADF.USIM. If not, the card can't be used (section 0) - If
Service 122/123(5GS) is present, 5G SA is possible
Check ADM1 in pySim-shell, using the proper command only —
apdu --raw returns non-standard responses and still burns down the retry counter.
sudo ~/.venv/bin/python3 pySim-shell.py -p0
# (inside the shell)
verify_adm <ADM1> # no error means it passed. If wrong, it tells you "N tries left"
exitSubscriber 3 (the handset in the shield box) as the example. Values come from lab.env.
cd ~/work/pysim
set -a; source ~/work/private5g/configs/secrets/lab.env; set +a
sudo ~/.venv/bin/python3 pySim-prog.py -p0 -t sysmoISIM-SJA5 -a "$UE3_ADM1" \
-x 001 -y 01 --mnclen 2 -n P5G \
-i "$UE3_IMSI" -k "$UE3_KI" -o "$UE3_OPC" \
-M e1ffffffffffffffffffffffff06815384090067ffffffffff000000 \
--msisdn 821099994533 \
--ims-hdomain ims.mnc001.mcc001.3gppnetwork.org \
--impi "$UE3_IMSI@ims.mnc001.mcc001.3gppnetwork.org" \
--impu "sip:$UE3_IMSI@ims.mnc001.mcc001.3gppnetwork.org"What each argument means, and its traps:
| Argument | Meaning | Watch out |
|---|---|---|
-t sysmoISIM-SJA5 |
Card type | Ki lives in a card-specific file, so the type must be right |
-x 001 -y 01 --mnclen 2 |
MCC/MNC and MNC length | Also sets the MNC length in EF.AD |
-n P5G |
Service provider name (SPN) | Shown in the handset's status bar |
-i / -k / -o |
IMSI / Ki / OPc | Must match what you put in the HSS |
-M … |
Preserve the original EF.SMSP | Omit it and pySim's default SMSC (+1555…) overwrites it. Our SMSC is SIP-only and has no E.164 number |
--ims-hdomain/--impi/--impu |
ISIM identity | Required for VoNR · IMS |
| (ICCID) | — | Cannot be written on this card type. It just warns and moves on; the factory value stays |
Don't make clones (two cards with the same IMSI). The core sees one subscriber, so whichever attaches later pushes the earlier one off. Two handsets need two subscribers.
Start with what can be read back.
sudo ~/.venv/bin/python3 pySim-read.py -p0 | grep -iE 'ICCID|^IMSI|SPN|PLMNwAcT|Home Network Domain|IMS private|IMS public'Expected:
ICCID: 894944… (factory value, unchanged)
IMSI: 001011234567897
SPN: P5G
PLMNwAcT: 00f110ffff ← MCC 001 / MNC 01
Home Network Domain Name: ims.mnc001.mcc001.3gppnetwork.org
IMS private user identity: 001011234567897@ims…
IMS public user identity: sip:001011234567897@ims…
Ki cannot be read from the card. So the only check is to make the card authenticate. It is far faster, and the cause is far clearer, than plugging it into a handset and seeing "it won't attach".
① Compute the expected values for an arbitrary RAND and SQN.
cd ~/work/private5g
set -a; source configs/secrets/lab.env; set +a
python3 - <<'PY'
import os, sys
sys.path.insert(0, 'tools')
from milenage import f1, f2345, xor
ki, opc = bytes.fromhex(os.environ['UE3_KI']), bytes.fromhex(os.environ['UE3_OPC'])
rand = bytes.fromhex('00112233445566778899aabbccddeeff')
sqn, amf = bytes.fromhex('000000000020'), bytes.fromhex('8000') # SQN 32
maca, _ = f1(ki, opc, rand, sqn, amf)
res, ck, ik, ak, _ = f2345(ki, opc, rand)
print('RAND', rand.hex())
print('AUTN', (xor(sqn, ak) + amf + maca).hex())
print('expected RES', res.hex(), '\nexpected CK ', ck.hex(), '\nexpected IK ', ik.hex())
PY② Feed them to the card.
cd ~/work/pysim
printf 'select ADF.USIM\nauthenticate <RAND> <AUTN>\n' > /tmp/auth.script
sudo ~/.venv/bin/python3 pySim-shell.py -p0 --script /tmp/auth.script③ If the res/ck/ik the card returns match the computed values, Ki and OPc went in correctly.
{"successful_3g_authentication": {"res": "…", "ck": "…", "ik": "…", "kc": "…"}}④ This test advances the card's SQN. Set the HSS-side SQN higher than that so the first attach doesn't need a resynchronisation (AUTS) round trip.
docker exec mongo mongosh --quiet open5gs --eval \
'db.subscribers.updateOne({imsi:"001011234567897"},{$set:{"security.sqn":NumberLong("64")}})'
curl -s -X PATCH http://127.0.0.1:8080/auc/3 -H 'Content-Type: application/json' -d '{"sqn":64}'Reading a failure — if
authenticatereturnssynchronisation_failure, the SQN is too low, not the Ki. Raise the SQN and try again. Ifrescomes back different, that is a Ki · OPc mismatch.
- B210 TX/RX (port 1) → 30 dB attenuation → shield-box SMA feed-through → inner antenna
- B210 RX2 (port 2) → attenuator → shield-box SMA feed-through → inner antenna
- Terminate every unused port with 50 Ω. Tighten connectors fully by hand
- Write down cable lengths and attenuation values. When the link budget doesn't add up later, that record is your only clue
Details: docs/rf-cabling.md.
cd upstream && docker compose -p docker_open5gs -f srsgnb.yaml up -d
docker logs srsgnb | tail -5You should see ==== gNB started === and N2: Connection to AMF … completed.
Keep the log level at info. At warning, RACH and RRC aren't logged and you can't even tell whether
the UE is attaching. It's all_level in configs/srsran/gnb.yml.in.
Verify — docker logs srsgnb | grep -c LIBUSB is 0 and Operating over USB 3. is present.
For the modem see docs/modem.md; for the handset in the shield box see docs/shieldbox-phone.md.
The handset inside the shield box can't be controlled through a USB feed-through — the filter panel is an LC low-pass, so USB 2.0 high-speed enumeration fails (it only charges). Use wired adb over an RJ45 feed-through and a USB-C Ethernet adapter.
Three things you must do on the handset:
adb shell settings put global captive_portal_mode 0 # removes "no internet access"
adb shell settings put global captive_portal_detection_enabled 0
adb shell settings put global mobile_data_always_on 1 # wired adb would otherwise block cellularYou also need an APN (numeric=00101, apn=internet, type=default,supl,mms).
For MMS, the mmsc field must not be empty (see troubleshooting below).
./bin/selftest.sh
PID=$(docker inspect -f '{{.State.Pid}}' oainrue)
sudo nsenter -t $PID -n ~/.venv/bin/python3 tools/ims_call.py 172.22.0.21 <UE IP> both 10
python3 tools/ims_sms_mt.py 172.22.0.20 821099994533 821099994531 "rebuild check"Expected output of selftest.sh (the script reports in Korean: core start, RAN start, UE attach, tunnel
interface, user plane, external reachability, config-vs-runtime match):
1. 코어 기동 + 준비 대기 OK 코어 준비 완료 (NF 5/5, 10초)
2. RAN 기동 (n78) OK
3. UE 접속 (최대 60초) OK IPv4: 10.46.0.2
4. 터널 인터페이스 oaitun_ue1 OK 10.46.0.2
5. 사용자 평면 (UPF 게이트웨이) OK 10.46.0.1 0% packet loss
5b. 외부 통신 (참고) OK 건너뜀 — 이 UE 는 ims DNN
6. 정본 vs 런타임 설정 일치 OK 차이 0 건
=== 전체 통과 ===
selftest.shbrings the core and RAN down when it finishes. If you're working with a real handset, bring them back up afterwards with./bin/core-ctl.sh STARTand the base station.5b being "skipped" (건너뜀) is normal. This simulated UE is for IMS testing and uses the
imsDNN, which isn't routed to the internet. Check external reachability with the modem or a real handset.
Even if the log says "call established ✅", it wasn't if RTP is 0. Always look at the packet counts.
In order of how much time each actually ate during the build. All the cases and how we tracked them down are in book chapter 17, "Casebook", twenty-four of them.
The symptom looked like "ADM doesn't match", so we chased the ADM key. In fact it was a 2G card with no USIM
application at all. Check for ADF.USIM with pySim-read.py first. It takes a minute.
Another SDR with an antenna was plugged into the same host, and with no serial in the config, that one got opened.
Don't start the gNB unless device_args in gnb.yml has serial=.
And if the B210 comes up as USB 2.0, suspect the cable before the port — a USB 2.0 Micro-B cable was the
culprit and cost us half a day.
It reads like an LIR failure, but the LIR succeeded. That message lives inside a
t_check_status("(408)") block in the Kamailio config — it means the next hop timed out. The real cause was
the P-CSCF sending to the UE over TCP while our UA listened only on UDP. In IMS each hop adds headers, the
INVITE grows past 1300 bytes, and then RFC 3261 §18.1.1 sends it over TCP.
An IMS UE must listen on TCP.
Four months later the same symptom came back. A second code path that listened only on UDP was still in the tool, and it surfaced once the SDP grew. When there are two paths, only one gets fixed.
docker logs srsgnb | grep "Unsupported PDU Session" → the srsRAN gNB doesn't support IPv4v6 PDU sessions.
Change the subscriber's session type to 1 (IPv4). The handset only says "disconnected" and the core only
logs "created, deleted". Read the logs of the layer that refused — here, the base station.
IntegrityError (1452) … FOREIGN KEY (`default_apn`) REFERENCES `apn` (`apn_id`)
PyHSS subscribers reference an APN row. Create the APNs first.
bin/provision-subscribers.sh keeps that order. This trap appears only on a first build, so a lab that's
already running never sees it.
Calls between simulated UEs had worked for months. A real handset demands four things:
the AMR-WB/AMR codecs (G.711 is rejected), PRACK (183 with Require: 100rel),
QoS precondition declarations, and the SDP answer arriving in the 183, not the 200 OK.
When you get a 488, first use the Source IP in the P-CSCF log to see who rejected it.
The radio log says Re-evaluating 0 unsatisfied network requests. The Ethernet plugged in for control already
satisfies the default internet request, so Android never even requests cellular.
mobile_data_always_on=1. A case of the observing device changing what it observes.
If the APN's mmsc field is empty, the whole row is ignored (ApnSettings.load). Yet
content insert --bind mmsc:s:http://... breaks the parser at the colon in the value.
We ended up driving the APN edit screen over adb, and the dialog's "OK" alone doesn't write to the DB —
you have to press Save in the menu. And if you resend with the same Transaction-Id or the same
Content-Location URL, the handset treats it as a duplicate and drops it.
Check with mmcli -L. Mask it or exclude the device via udev. The symptom is intermittent, which makes
you suspect the RF or the core.
More than once we read empty output as success. A secret scan once passed silently, too
(--cached for git grep was placed after --, so it was taken as a path).
A check that confirms something is absent must separately prove that the check itself worked. Add a second
check of a different nature, or deliberately plant something that must be caught and see that it is.
private5g/
├── bin/ control scripts (run them from here only)
├── compose/ our docker compose files
├── configs/ source-of-truth configs — always edit here
│ ├── secrets/ real keys and host-specific values (untracked)
│ ├── db/ subscriber profile templates
│ └── _backup/ backups of local changes (untracked)
├── docs/ build, measurement and case notes, screenshots, and the book
└── upstream -> docker_open5gs clone (symlink, untracked)
| Document | Contents |
|---|---|
| docs/REBUILD.md | Rebuild procedure (the detailed version of this README) |
| docs/sim-cards.md | Buying, programming and verifying USIMs; the cards that didn't work |
| docs/shieldbox-phone.md | Controlling the handset inside the shield box; wired adb |
| docs/sim-vs-real.md | Seven gaps between simulation and real hardware |
| docs/mms-and-video.md | Video calls and MMS |
| docs/shielding-measurement.md | Measuring shield-box leakage |
| docs/rf-cabling.md | RF wiring |
| docs/frequency-plan.md | Frequency plan |
| local-5g-handbook | The book, Building a Local 5G Network (separate repository, PDF) |
| Path | Why |
|---|---|
configs/secrets/lab.env |
Real keys and host-specific values |
configs/env/env, configs/oai/nr-ue.n78.rfsim.conf, configs/srsran/gnb.yml |
Rendered from *.in |
configs/_backup/ |
Local safety copies. Contain old real keys |
docs/book/ |
Separate repository (AmesianX/local-5g-handbook) |
upstream |
Symlink to the upstream clone |
70 년대 해커들에게 프리킹이 있었다면, 지금 우리에게는 로컬 5G 가 있다.
리눅스 한 대와 SDR 한 대로 동작하는 5G 단독(SA) 이동통신망을 세운다. 상용 단말이 실제로 붙고, 전화가 울리고, 문자와 사진이 오간다.
📕 이 망을 세운 전 과정을 책으로 썼다. 『로컬 5G 망 구축하기 — 해커를 위한 이동통신 실무서』 · PDF 내려받기 · 원고 저장소
![]() 우리 망 위에서 이어진 VoNR 통화. 상태바에 HD·5G |
![]() 270 자 문자가 쪼개지지 않고 한 통으로 |
1970 년대의 폰 프리커들은 휘파람 하나로 2600 Hz 를 불어 대륙 너머의 교환기를 움직였고, 그 틈으로 거대한 전화망의 속을 들여다보는 재미에 빠졌다. 그 뒤 반세기 동안 이동통신은 해커에게 가장 닫힌 영역이었지만, 이제 코어망은 HTTP/2 로 서로를 부르는 리눅스 프로세스가 되었고 기지국은 USB 에 꽂는 소프트웨어 라디오 위에서 돈다. 프리커들이 교환기를 속일 방법을 찾았다면, 이제 우리는 교환기를 직접 가져 볼 수 있는 셈이다. 비밀 키를 우리가 정해 구운 SIM 으로 가입자를 받고, 우리가 발급한 번호로 실물 폰의 벨을 울리고, 그 사이에 오가는 모든 메시지를 와이어샤크로 처음부터 끝까지 따라가 볼 수 있다.
이 망은 놀이로 끝나지도 않는다. 인터넷으로 나가는 출구가 리눅스 박스의 기본 경로 하나뿐이니, 상용망이 끊긴 곳에 이 박스와 라디오를 들고 가서 위성 단말 하나를 물리면 그 자리에 우리 셀이 선다. 이 저장소의 실제 목표도 그것, 상용망이 두절됐을 때 가동할 자체 통신망이다. 5G SA 로 검증을 마쳤고, 실제 배치 목표는 4G LTE 다.
이 저장소와 책을 공개하는 이유는, 우리가 헤맨 자리마다 표지판을 세워 두고 싶어서다. USB 2.0 케이블 하나로 반나절을 날리고, 스크립트끼리 넉 달 동안 통하던 전화를 실물 폰이 첫 통화에서 거절하던 그 자리에서, 다음 사람은 같은 시간을 버리지 않았으면 한다.
![]() sysmoISIM-SJA5 카드 두 종(9FV·S17). PIN·PUK·IMSI 는 가렸다 |
![]() 차폐함 TOJOIN MS3020-A |
![]() 뚜껑을 연 차폐함. 이 안에 폰이 들어간다 |
![]() 필터 패널: RJ45·USB·전원 관통과 SMA 단자 |
![]() 공장 시험 성적서: 3.5 GHz 에서 −101.8 dB |
사진을 누르면 크게 볼 수 있다.
이 README 아래쪽에는 맨바닥에서 같은 망을 다시 세우는 절차가 단계마다 확인 방법과 함께 적혀 있다. 하지만 그것을 한 줄씩 손으로 따라 칠 필요는 없다. 이 저장소를 클론한 디렉터리에서 Claude Code 를 열고 이렇게 맡기면 된다.
README 의 재구축 가이드대로 이 망을 처음부터 다시 세워 줘.
단계마다 확인 방법을 통과했는지 보여 주고, 기지국 송신 직전에는 멈춰서 내 확인을 받아.
그러면 업스트림을 받고, 이미지를 올리고, 설정을 렌더하고, 가입자를 넣고, 코어와 IMS 를 띄우는 일을 확인까지 거치며 순식간에 끝내 준다. 이 절차가 정말 저장소 하나로 서는지는 2026-10-15 에 데이터베이스 볼륨까지 지운 뒤 GitHub 에서 새로 클론한 저장소만으로 다시 세워 확인했다.
다만 손에 잡히는 것은 사람이 미리 준비해 두어야 한다. Claude Code 는 소프트웨어를 세울 뿐, 카드를 사거나 케이블을 조여 줄 수는 없기 때문이다.
- USIM — 키를 써 넣을 수 있는 카드(우리는 sysmoISIM-SJA5)와 PC/SC 카드 리더. 카드와 함께 받은
Ki·OPc·ADM1 값을
configs/secrets/lab.env.example을 복사한configs/secrets/lab.env에 채워 둔다. 아래 「0. 장비」의 USIM 함정을 꼭 먼저 읽자. - 장비 — USB 3.0 포트가 있는 리눅스 호스트(Ubuntu 24.04), SDR(USRP B210 또는 호환), 단말 (Quectel RM520N-GL 5G 모뎀, 또는 실물 폰과 차폐함), 동축 케이블, 40 dB 감쇠기, 50 Ω 종단기.
- 배선과 송신 허락 — 케이블을 잇고 감쇠기를 끼우는 일, 그리고 기지국이 처음 송신하기 전에 배선을 눈으로 확인하는 일은 사람이 한다. 감쇠기 없이 직결하면 B210 수신단이 타고, 공중으로 새면 남의 통신을 방해하게 된다.
| 실측 | |
|---|---|
| 5G SA 등록 | 실물 갤럭시 S22+ 가 HOME, 기술 NR, PLMN 001/01 |
| 데이터 | UPF 까지 RTT 25 |
| 음성 (VoNR) | 단말에 수신 전화 표시, 통화 성립, RTP 양방향 |
| 영상통화 | video RTP 송신 332 / 수신 2697 (20 초) |
| 문자 | 길이 제한 없음(UDH 연결 문자). 270 자가 한 통으로 병합 |
| 멀티미디어(MMS) | 양방향. 단말이 보낸 JPEG 이 원본과 SHA256 동일 |
| 전파 누설 | 차폐함 밖에서 검출 안 됨(측정 방법과 한계는 책 15.5a) |
전파는 벽을 넘는다. 공중으로 내보내는 순간 허가가 필요한 무선국이다. 한국 전파법 기준으로 허가 없는 무선국 개설·운용은 3년 이하 징역 또는 3천만 원 이하 벌금이다.
- PLMN 은 시험용 001/01 고정. 상용 번호(450-05 SKT / 450-06 LGU+ / 450-08 KT) 절대 금지
- 주파수는 상용 할당과 교차 확인. n78 상용: LG U+ 3400
3500 / KT 35003600 / SKT 3600~3700 MHz - 업스트림 예제의 주파수를 믿지 말 것. 대부분 상용 대역 한복판이 기본값이다
- 송신은 동축 직결 + 감쇠기, 안 쓰는 단자는 50 Ω 종단, 가능하면 차폐함 안에서
- 송신 게인은 항상 최소에서 시작
- 안테나 달린 SDR 이 같은 호스트에 있다면 그 장치가 열리지 않는지 확인(
serial=핀)
자세한 것은 책 부록 D 「전파와 법규 고지」.
아래 순서대로 하면 된다. 각 단계 끝에 확인 방법을 같이 적었다. 확인이 통과하지 않으면 다음으로 넘어가지 말 것 — 뒤에서 엉뚱한 증상으로 되돌아온다.
검증 기록. 이 절차는 2026-10-15 에 실제로 전부 내리고 다시 세워서 확인했다.
docker compose down -v로 컨테이너와 데이터베이스 볼륨까지 삭제한 뒤, 이 저장소를 GitHub 에서 새로 클론해 그것만으로 재구축했다. 결과: 설정 렌더가 기존과 비트 단위로 동일, 코어 NF 5/5, 가입자 3명 생성, 실물 단말이00101에 재등록되어 인터넷(10.45.x)과 IMS(10.46.x) 세션을 모두 받고 문자까지 통했다.그 과정에서 이 저장소의 결함 세 가지가 드러나 고쳤다 — 업스트림 경로를 심볼릭 링크로 못 찾던 것,
cd && pwd가 논리 경로를 돌려줘 심볼릭이 자기 자신을 가리키던 것, 그리고 빈 데이터베이스에서 PyHSS APN 행을 만들지 않아 가입자 등록이 외래키 제약에 걸리던 것이다. 마지막 것은 이미 돌아가는 랩에서는 평생 드러나지 않는 종류다.
| 품목 | 우리가 쓴 것 | 주의 |
|---|---|---|
| 리눅스 호스트 | Ubuntu 24.04, x86_64 | 도커가 돌면 된다 |
| SDR | Ettus USRP B210 / LibreSDR B220mini | 송신용 시리얼을 고정한다 |
| USIM | sysmoISIM-SJA5 | 아래 함정을 꼭 읽을 것 |
| 카드 리더 | PC/SC CCID (Identiv SCR35xx 등) | pcscd |
| UE ① 모뎀 | Quectel RM520N-GL + 캐리어 보드 | USB, 2c7c:0801 |
| UE ② 실물 단말 | 갤럭시 S22+ (SM-S9060), 루트 필요 | 차폐함 안에서 제어하려면 |
| 차폐함 | TOJOIN MS3020-A | SMA 관통 4, 필터패널 |
| 감쇠기 | 30 dB · 40 dB SMA | 송신 경로 |
| 종단기 | 50 Ω SMA | 안 쓰는 단자 전부 |
| USB-C 랜 어댑터 | RTL8153 계열 | 차폐함 안 단말의 유선 adb |
| 동축 케이블 | SMA | 길수록 손실. 길이를 기록해 둘 것 |
알리익스프레스의 "LTE Blank USIM" 류는 쓸 수 없다. 광고에는
Compatible: 4G FDD LTE, WCDMA and GSM 이라고 적혀 있지만, 실물은 2G GSM 전용이다.
EF.DIR 도 AID 도 ADF.USIM 도 없고 DF_GSM(7F20) 만 있다. ADM 키를 알아내도 소용없다 —
USIM 애플리케이션이 물리적으로 없기 때문이다. 우리는 이것을 모르고 ADM 추적에 카드 한
장을 소모한 뒤에야 알았다.
카드가 USIM 인지는 1 분이면 가른다. ADM 을 쫓기 전에 이것부터 하라.
sudo ~/.venv/bin/python3 pySim-read.py -p0 | head -20
# "Autodetected card type" 과 ADF.USIM 이 보여야 한다. 없으면 그 카드는 버린다.제대로 된 카드는 유럽에서 왔다. 독일 sysmocom 에서 sysmoISIM-SJA5 를 주문했다.
- 구매: https://shop.sysmocom.de/SIM/Cards/
- 상품명에
with ADM keys가 있어야 한다. ADM 키 없이는 카드에 쓸 수 없고, sysmocom 은 주문 건별로 ADM1 을 이메일로 보내 준다 - MFF2(솔더 타입) 말고 일반 카드를 고를 것
- 9FV / S17 차이는 SUCI 계산 주체뿐이다. Open5GS 는 기본이 null-scheme 이라 9FV 로 충분하고 S17 은 재고가 자주 끊긴다
- 배송에 시간이 걸린다. 망을 다 세워 놓고 카드를 기다리는 일이 없도록 먼저 주문하라. 우리는 그 상태로 작업이 멈춘 적이 있다
자세한 기록은 docs/sim-cards.md.
sudo apt update
sudo apt install -y docker.io docker-compose-v2 git python3-pip \
pcscd pcsc-tools android-tools-adb nmap avahi-utils \
uhd-host python3-uhd zstd
sudo usermod -aG docker "$USER" # 재로그인 필요
sudo uhd_images_downloader # UHD 펌웨어/FPGA
sudo systemctl enable --now pcscd
python3 -m venv ~/.venv && ~/.venv/bin/pip install pyscardModemManager 를 반드시 비켜 세운다. 그대로 두면 모뎀의 AT 포트와 QMI 세션을 가로채 죽인다. 증상이 "되다가 끊긴다"라서 원인을 찾기 어렵다.
sudo systemctl mask ModemManager # 또는 udev 로 해당 장치만 제외확인 — docker ps 가 권한 오류 없이 돌고, uhd_find_devices 가 SDR 을 본다.
python3-uhd는 시스템 파이썬 버전용으로 설치된다.~/.venv안에서는import uhd가 안 된다. 계측 도구(tools/leakscan.py)는/usr/bin/python3.12처럼 시스템 파이썬으로 실행해야 한다.
코어·IMS 컨테이너는 업스트림 프로젝트의 compose 를 쓴다. 우리 설정이 정본이고 업스트림 클론은 배포 대상이다.
git clone <이 저장소> ~/work/private5g
git clone https://github.com/herlesupreeth/docker_open5gs.git /path/to/docker_open5gs
git -C /path/to/docker_open5gs checkout 1f3bcf7
ln -s /path/to/docker_open5gs ~/work/private5g/upstream심볼릭 링크만 걸면 된다. 우리 디렉터리 배치를 따라할 필요 없다 — 스크립트가
upstream 링크를 먼저 본다. (환경변수 UPSTREAM_DIR 로도 지정할 수 있다.)
업스트림 클론은 건드리지 않는다. 거기서 직접 고치면 다음 배포에 덮인다.
확인 — ./bin/deploy-configs.sh --check 가 "upstream 클론을 찾을 수 없다" 를 내지 않는다.
ghcr.io 에서 받는 것이 정석이지만, 업스트림이 태그를 옮기거나 지울 수 있다. 인터넷에서 못 구하는 상황을 대비해 우리가 실제로 돌린 이미지를 이 저장소 릴리즈에 올려 두었다. 이 저장소 하나만 있으면 복원된다.
./bin/load-images.sh # 릴리즈 images-v1 에서 받아 체크섬 확인 후 docker load이미지 11 개, 압축 합계 4.0 GB. compose 가 쓰는 로컬 태그와 ghcr 태그를 둘 다 담았다.
확인 — docker images | grep docker_open5gs 에 latest 와 master 가 모두 보인다.
실키와 호스트 고유값은 이 파일 하나에서만 온다. 저장소에는 틀만 있다.
cp configs/secrets/lab.env.example configs/secrets/lab.env
chmod 600 configs/secrets/lab.env
$EDITOR configs/secrets/lab.env| 값 | 어디서 얻나 |
|---|---|
DOCKER_HOST_IP |
호스트의 LAN 주소 (ip -br addr) |
B210_SERIAL |
uhd_find_devices 의 serial:. 송신에 쓸 그 한 대 |
PHONE_IP |
차폐함 안 단말에 줄 고정 주소 |
UE*_KI, UE*_OPC |
카드 공급사가 준 값 (sysmocom 이메일/CSV) |
UE3_ADM1, UE3_ICCID |
카드 발급에 쓴다 |
확인 — ./bin/deploy-configs.sh 가 "채우지 못한 토큰이 남았다"를 내지 않는다.
비어 있으면 무엇이 비었는지 이름을 적고 멈춘다.
cd ~/work/private5g
./bin/deploy-configs.sh # 템플릿 렌더 + 업스트림 트리로 배포
./bin/core-ctl.sh START # NF 5/5 까지 기다린다
./bin/provision-subscribers.sh # APN 2개 + 가입자 3명을 "만든다"
./bin/provision-subscribers.sh --checkprovision-subscribers.sh 는 덤프 복원이 아니라 생성이다. 빈 데이터베이스에서
APN(internet, ims)과 가입자를 템플릿
configs/db/subscriber.template.json 으로 만든다.
값은 lab.env 에서 읽는다. 멱등이라 여러 번 돌려도 중복이 쌓이지 않는다.
확인 — --check 가 mongo 가입자 3 행, PyHSS auc/subscriber/ims_subscriber 각 3 행을 보고한다.
sysmocom 은 주문 건별로 카드 목록을 CSV 또는 이메일로 보낸다. 한 줄이 카드 한 장이고, 우리에게 필요한 것은 넷뿐이다.
| 항목 | 예 | 쓰임 |
|---|---|---|
| ICCID | 894944… 19 자리 |
카드 식별. 바꿀 수 없다 |
| ADM1 | 8 자리 숫자 | 쓰기 권한. 이게 없으면 아무것도 못 쓴다 |
| Ki | 32 자리 헥스 | 인증키. 카드와 HSS 가 같아야 한다 |
| OPc | 32 자리 헥스 | 사업자 변형값 |
나머지(PIN1/PUK1/PIN2/PUK2, KIC/KID/KIK 9 개)는 OTA 용이라 우리 랩에서는 쓰지 않는다.
이 값들은 저장소에 넣지 않는다. configs/secrets/lab.env 에만 둔다.
ADM1 은 재시도 횟수가 정해져 있다. 틀린 값을 반복해 넣으면 카드가 영구히 잠긴다. 우리는 다른 카드에서 이걸로 한 장을 날렸다. 추측해서 넣지 말 것.
sudo systemctl start pcscd
sudo pcsc_scan -r # 리더가 보이는지pySim 을 받는다.
git clone https://gitea.osmocom.org/sim-card/pysim.git ~/work/pysim
cd ~/work/pysim && ~/.venv/bin/pip install -r requirements.txt쓰기 전에 반드시 읽어 본다. 이 한 번으로 "USIM 인가"와 "ADM1 이 맞는가"가 동시에 갈린다.
sudo ~/.venv/bin/python3 pySim-read.py -p0 | head -25Autodetected card type: sysmoISIM-SJA5→ 우리가 아는 카드다- 뒤쪽에
ISIM Service Table과ADF.USIM이 보여야 한다. 없으면 그 카드는 쓸 수 없다(0 절) Service 122/123(5GS) 이 있으면 5G SA 가능
ADM1 검증은 pySim-shell 에서 한다. 정식 명령으로만 하라 —
apdu --raw 는 비표준 응답을 내면서도 재시도 카운터를 깎는다.
sudo ~/.venv/bin/python3 pySim-shell.py -p0
# (쉘 안에서)
verify_adm <ADM1> # 오류가 없으면 통과. 틀리면 "N tries left" 를 알려 준다
exit가입자 3 번(차폐함 안 단말)을 예로 든다. 값은 lab.env 에서 꺼내 쓴다.
cd ~/work/pysim
set -a; source ~/work/private5g/configs/secrets/lab.env; set +a
sudo ~/.venv/bin/python3 pySim-prog.py -p0 -t sysmoISIM-SJA5 -a "$UE3_ADM1" \
-x 001 -y 01 --mnclen 2 -n P5G \
-i "$UE3_IMSI" -k "$UE3_KI" -o "$UE3_OPC" \
-M e1ffffffffffffffffffffffff06815384090067ffffffffff000000 \
--msisdn 821099994533 \
--ims-hdomain ims.mnc001.mcc001.3gppnetwork.org \
--impi "$UE3_IMSI@ims.mnc001.mcc001.3gppnetwork.org" \
--impu "sip:$UE3_IMSI@ims.mnc001.mcc001.3gppnetwork.org"각 인자의 뜻과 함정:
| 인자 | 뜻 | 주의 |
|---|---|---|
-t sysmoISIM-SJA5 |
카드 종류 | Ki 는 카드마다 다른 전용 파일에 들어가므로 종류가 맞아야 한다 |
-x 001 -y 01 --mnclen 2 |
MCC/MNC 와 MNC 자릿수 | EF.AD 의 MNC 길이까지 맞춰 준다 |
-n P5G |
사업자 이름(SPN) | 단말 상태바에 뜬다 |
-i / -k / -o |
IMSI / Ki / OPc | HSS 에 넣은 값과 같아야 한다 |
-M … |
EF.SMSP 원본 보존 | 생략하면 pySim 기본 SMSC(+1555…)로 덮인다. 우리 SMSC 는 SIP 전용이라 E.164 번호가 없다 |
--ims-hdomain/--impi/--impu |
ISIM 신원 | VoNR·IMS 에 필요 |
| (ICCID) | — | 이 카드 타입에서는 쓸 수 없다. 경고만 내고 넘어간다. 공장값을 그대로 쓴다 |
클론(같은 IMSI 두 장)은 만들지 않는다. 코어가 한 가입자로 보기 때문에 나중에 붙은 쪽이 앞선 쪽을 밀어낸다. 단말이 둘이면 가입자도 둘이어야 한다.
읽을 수 있는 것부터 본다.
sudo ~/.venv/bin/python3 pySim-read.py -p0 | grep -iE 'ICCID|^IMSI|SPN|PLMNwAcT|Home Network Domain|IMS private|IMS public'기대값:
ICCID: 894944… (공장값 그대로)
IMSI: 001011234567897
SPN: P5G
PLMNwAcT: 00f110ffff ← MCC 001 / MNC 01
Home Network Domain Name: ims.mnc001.mcc001.3gppnetwork.org
IMS private user identity: 001011234567897@ims…
IMS public user identity: sip:001011234567897@ims…
Ki 는 카드에서 읽을 수 없다. 그래서 카드에게 직접 인증을 시켜 보는 것이 유일한 검증이다. 단말에 꽂아 보고 "안 붙네" 하는 것보다 훨씬 빠르고 원인이 분명하다.
① 임의의 RAND 와 SQN 에 대해 기대값을 계산한다.
cd ~/work/private5g
set -a; source configs/secrets/lab.env; set +a
python3 - <<'PY'
import os, sys
sys.path.insert(0, 'tools')
from milenage import f1, f2345, xor
ki, opc = bytes.fromhex(os.environ['UE3_KI']), bytes.fromhex(os.environ['UE3_OPC'])
rand = bytes.fromhex('00112233445566778899aabbccddeeff')
sqn, amf = bytes.fromhex('000000000020'), bytes.fromhex('8000') # SQN 32
maca, _ = f1(ki, opc, rand, sqn, amf)
res, ck, ik, ak, _ = f2345(ki, opc, rand)
print('RAND', rand.hex())
print('AUTN', (xor(sqn, ak) + amf + maca).hex())
print('기대 RES', res.hex(), '\n기대 CK ', ck.hex(), '\n기대 IK ', ik.hex())
PY② 그 값을 카드에 넣는다.
cd ~/work/pysim
printf 'select ADF.USIM\nauthenticate <RAND> <AUTN>\n' > /tmp/auth.script
sudo ~/.venv/bin/python3 pySim-shell.py -p0 --script /tmp/auth.script③ 카드가 돌려준 res/ck/ik 가 계산값과 같으면 Ki·OPc 가 정확히 들어간 것이다.
{"successful_3g_authentication": {"res": "…", "ck": "…", "ik": "…", "kc": "…"}}④ 이 시험으로 카드의 SQN 이 올라간다. HSS 쪽 SQN 을 그보다 크게 맞춰 두면 첫 접속에서 재동기(AUTS) 왕복이 생기지 않는다.
docker exec mongo mongosh --quiet open5gs --eval \
'db.subscribers.updateOne({imsi:"001011234567897"},{$set:{"security.sqn":NumberLong("64")}})'
curl -s -X PATCH http://127.0.0.1:8080/auc/3 -H 'Content-Type: application/json' -d '{"sqn":64}'실패 시 읽는 법 —
authenticate가synchronisation_failure를 내면 SQN 이 낮은 것이지 Ki 가 틀린 게 아니다. SQN 을 올려 다시 해 본다.res가 다르게 나오면 그때가 Ki·OPc 불일치다.
- B210 TX/RX(1번) → 30 dB 감쇠 → 차폐함 SMA 관통 → 안쪽 안테나
- B210 RX2(2번) → 감쇠기 → 차폐함 SMA 관통 → 안쪽 안테나
- 안 쓰는 단자는 전부 50 Ω 종단. 커넥터는 손으로 끝까지 조인다
- 케이블 길이와 감쇠값을 적어 둔다. 나중에 링크 예산이 안 맞을 때 이 기록이 유일한 단서다
자세한 것은 docs/rf-cabling.md.
cd upstream && docker compose -p docker_open5gs -f srsgnb.yaml up -d
docker logs srsgnb | tail -5==== gNB started === 와 N2: Connection to AMF … completed 가 보여야 한다.
로그 레벨을 info 로 둔다. warning 이면 RACH·RRC 가 안 찍혀서 단말이 붙는지
판단조차 못 한다. configs/srsran/gnb.yml.in 의 all_level.
확인 — docker logs srsgnb | grep -c LIBUSB 가 0 이고 Operating over USB 3. 이 있다.
모뎀은 docs/modem.md, 차폐함 안 단말은 docs/shieldbox-phone.md.
차폐함 안 단말은 USB 관통으로 제어할 수 없다 — 필터패널이 LC 저역통과라 USB 2.0 하이스피드 열거가 실패한다(충전만 된다). RJ45 관통 + USB-C 랜 어댑터로 유선 adb 를 쓴다.
단말 쪽에서 반드시 해야 하는 셋:
adb shell settings put global captive_portal_mode 0 # "인터넷 사용 불가" 제거
adb shell settings put global captive_portal_detection_enabled 0
adb shell settings put global mobile_data_always_on 1 # 유선 adb 가 셀룰러를 막는다APN 도 넣어야 한다(numeric=00101, apn=internet, type=default,supl,mms).
MMS 까지 쓰려면 mmsc 칸이 비어 있으면 안 된다(아래 트러블슈팅 참조).
./bin/selftest.sh
PID=$(docker inspect -f '{{.State.Pid}}' oainrue)
sudo nsenter -t $PID -n ~/.venv/bin/python3 tools/ims_call.py 172.22.0.21 <UE IP> both 10
python3 tools/ims_sms_mt.py 172.22.0.20 821099994533 821099994531 "재구축 확인"selftest.sh 의 기대 출력:
1. 코어 기동 + 준비 대기 OK 코어 준비 완료 (NF 5/5, 10초)
2. RAN 기동 (n78) OK
3. UE 접속 (최대 60초) OK IPv4: 10.46.0.2
4. 터널 인터페이스 oaitun_ue1 OK 10.46.0.2
5. 사용자 평면 (UPF 게이트웨이) OK 10.46.0.1 0% packet loss
5b. 외부 통신 (참고) OK 건너뜀 — 이 UE 는 ims DNN
6. 정본 vs 런타임 설정 일치 OK 차이 0 건
=== 전체 통과 ===
selftest.sh는 끝나면서 코어와 RAN 을 내린다. 실물 단말로 작업 중이라면 끝난 뒤./bin/core-ctl.sh START와 기지국을 다시 올려야 한다.5b 가 "건너뜀"인 것은 정상이다. 이 모의 UE 는 IMS 시험용이라
imsDNN 을 쓰고, IMS 망은 외부 인터넷으로 라우팅되지 않는다. 외부 통신은 모뎀이나 실물 단말로 확인한다.
로그에 "통화 성립 ✅" 이 찍혀도 RTP 가 0 이면 성립한 게 아니다. 반드시 패킷 수를 본다.
구축하면서 실제로 시간을 가장 많이 먹은 순서다. 전체 사례와 추적 과정은 책 17 장 「사례집」 에 스물네 건으로 있다.
증상이 "ADM 이 안 맞는다"로 보여서 ADM 키를 쫓았다. 실제로는 USIM 애플리케이션 자체가
없는 2G 카드였다. pySim-read.py 로 ADF.USIM 유무부터 본다. 1 분이면 끝난다.
안테나가 달린 다른 SDR 이 같은 호스트에 꽂혀 있었고, 설정에 시리얼이 없어서 그쪽이
열렸다. gnb.yml 의 device_args 에 serial= 이 없으면 띄우지 말 것.
그리고 B210 이 USB 2.0 으로 뜬다면 포트가 아니라 케이블을 먼저 의심하라 —
USB 2.0 Micro-B 케이블이 범인이었고 반나절을 먹었다.
문구는 LIR 실패처럼 읽히지만 LIR 은 성공이었다. 그 문구는 Kamailio 설정에서
t_check_status("(408)") 블록 안에 있다 — 다음 홉이 타임아웃했다는 뜻이다. 진짜 원인은
P-CSCF 가 단말에 TCP 로 보내는데 우리 UA 가 UDP 만 듣고 있던 것이었다. IMS 는 홉마다
헤더가 붙어 INVITE 가 1300 바이트를 넘고, 그러면 RFC 3261 §18.1.1 에 따라 TCP 로 간다.
IMS 단말은 TCP 를 반드시 들어야 한다.
넉 달 뒤 같은 증상이 재발했다. 도구 안에 UDP 만 듣는 두 번째 코드 경로가 남아 있었고, SDP 가 커지자 드러났다. 경로가 둘이면 한쪽만 고쳐진다.
docker logs srsgnb | grep "Unsupported PDU Session" → srsRAN gNB 는 IPv4v6 PDU 세션을
지원하지 않는다. 가입자 세션 타입을 1(IPv4) 로 바꾼다. 단말은 "끊긴다"고만 하고
코어는 "만들었다 지웠다"만 찍는다. 거부한 계층(기지국)의 로그를 봐야 한다.
IntegrityError (1452) … FOREIGN KEY (`default_apn`) REFERENCES `apn` (`apn_id`)
PyHSS 의 가입자는 APN 행을 참조한다. APN 부터 만들어야 한다.
bin/provision-subscribers.sh 가 그 순서를 지킨다. 이 함정은 처음 구축할 때만
나타나기 때문에, 이미 돌아가는 랩에서는 평생 안 보인다.
모의 UE 끼리는 몇 달째 잘 되던 통화다. 실물은 넷을 요구한다.
AMR-WB/AMR 코덱(G.711 거절), PRACK(183 이 Require: 100rel),
QoS 전제조건 선언, 그리고 SDP 응답이 200 OK 가 아니라 183 에 온다는 것.
488 을 받으면 P-CSCF 로그의 Source IP 로 누가 거절했는지부터 가른다.
라디오 로그에 Re-evaluating 0 unsatisfied network requests. 제어용으로 꽂은
이더넷이 기본 인터넷 요청을 이미 만족시켜서 안드로이드가 셀룰러를 요청조차 안 한다.
mobile_data_always_on=1. 관측 장치가 관측 대상을 바꾼 경우다.
APN 의 mmsc 칸이 비면 그 행이 통째로 무시된다(ApnSettings.load). 그런데
content insert --bind mmsc:s:http://... 는 값의 콜론에서 파서가 깨진다.
결국 APN 편집 화면을 adb 로 조작해 넣었고, 대화상자 "확인"만으로는 DB 에 안 들어가고
메뉴의 저장을 눌러야 한다. 그리고 같은 Transaction-Id 나 같은 Content-Location URL 로
다시 보내면 단말이 중복으로 보고 버린다.
mmcli -L 로 확인. 마스크하거나 udev 로 제외한다. 증상이 간헐적이라 RF 나 코어를
의심하게 만든다.
빈 출력을 성공으로 읽은 적이 여러 번 있다. 비밀값 검사가 조용히 통과한 적도 있다
(git grep 의 --cached 를 -- 뒤에 둬서 경로 지정으로 먹혔다).
없는 것을 확인하는 검사는 검사 자체가 작동했는지를 따로 증명해야 한다. 성질이 다른
검사를 하나 더 붙이거나, 반드시 걸려야 하는 것을 일부러 넣어 걸리는지 본다.
private5g/
├── bin/ 제어 스크립트 (실행은 여기서만)
├── compose/ 우리 docker compose
├── configs/ 설정 정본 — 고칠 때는 반드시 여기
│ ├── secrets/ 실키·호스트 고유값 (추적 안 함)
│ ├── db/ 가입자 프로파일 템플릿
│ └── _backup/ 로컬 변경 백업 (추적 안 함)
├── docs/ 구축·측정·사례 문서, 화면 증거, 그리고 책
└── upstream -> docker_open5gs 클론 (심볼릭, 추적 안 함)
| 문서 | 내용 |
|---|---|
| docs/REBUILD.md | 재구축 절차 (이 README 의 상세판) |
| docs/sim-cards.md | USIM 구매·발급·검증, 쓸 수 없던 카드 기록 |
| docs/shieldbox-phone.md | 차폐함 안 단말 제어와 유선 adb |
| docs/sim-vs-real.md | 시뮬레이션과 실물의 간극 일곱 가지 |
| docs/mms-and-video.md | 영상통화와 MMS |
| docs/shielding-measurement.md | 차폐함 누설 계측 |
| docs/rf-cabling.md | RF 배선 |
| docs/frequency-plan.md | 주파수 계획 |
| local-5g-handbook | 책 『로컬 5G 망 구축하기』 (별도 저장소, PDF) |
| 경로 | 이유 |
|---|---|
configs/secrets/lab.env |
실키·호스트 고유값 |
configs/env/env, configs/oai/nr-ue.n78.rfsim.conf, configs/srsran/gnb.yml |
*.in 에서 렌더되는 결과물 |
configs/_backup/ |
로컬 안전 사본. 과거 실키가 들어 있다 |
docs/book/ |
별도 저장소 (AmesianX/local-5g-handbook) |
upstream |
업스트림 클론으로 가는 심볼릭 링크 |






