เปลือกนิวเคลียร์เหนือ ICMP

เปลือกนิวเคลียร์เหนือ ICMP

TL; DR: ฉันกำลังเขียนโมดูลเคอร์เนลที่จะอ่านคำสั่งจากเพย์โหลด ICMP และดำเนินการคำสั่งเหล่านั้นบนเซิร์ฟเวอร์ แม้ว่า SSH ของคุณจะขัดข้องก็ตาม สำหรับผู้ที่ใจร้อนที่สุดรหัสทั้งหมดก็คือ GitHub.

ข้อควรระวัง! โปรแกรมเมอร์ C ที่มีประสบการณ์เสี่ยงที่จะน้ำตาไหล! ฉันอาจจะผิดคำศัพท์ด้วยซ้ำ แต่ก็ยินดีรับคำวิจารณ์ โพสต์นี้มีไว้สำหรับผู้ที่มีแนวคิดคร่าวๆ เกี่ยวกับการเขียนโปรแกรม C และต้องการดูข้อมูลภายในของ Linux

ในความคิดเห็นของฉันครั้งแรก статье กล่าวถึง SoftEther VPN ซึ่งสามารถเลียนแบบโปรโตคอล “ปกติ” บางอย่างได้ โดยเฉพาะ HTTPS, ICMP และแม้แต่ DNS ฉันนึกภาพออกว่ามีเพียงอันแรกเท่านั้นที่ใช้งานได้ เนื่องจากฉันคุ้นเคยกับ HTTP(S) เป็นอย่างดี และฉันต้องเรียนรู้การขุดอุโมงค์ผ่าน ICMP และ DNS

เปลือกนิวเคลียร์เหนือ ICMP

ใช่ ในปี 2020 ฉันได้เรียนรู้ว่าคุณสามารถแทรกเพย์โหลดที่กำหนดเองลงในแพ็กเก็ต ICMP ได้ แต่มาช้ายังดีกว่าไม่มา! และเนื่องจากบางสิ่งบางอย่างสามารถทำได้เกี่ยวกับเรื่องนี้ จึงต้องทำให้เสร็จ เนื่องจากในชีวิตประจำวันของฉันฉันมักจะใช้บรรทัดคำสั่งรวมถึงผ่าน SSH แนวคิดของเชลล์ ICMP จึงเข้ามาในใจฉันเป็นอันดับแรก และเพื่อที่จะประกอบบิงโกแบบบุลชิลด์ที่สมบูรณ์ ฉันตัดสินใจเขียนมันเป็นโมดูล Linux ในภาษาที่ฉันมีเพียงความคิดคร่าวๆ เท่านั้น เชลล์ดังกล่าวจะไม่ปรากฏให้เห็นในรายการกระบวนการ คุณสามารถโหลดลงในเคอร์เนลได้ และจะไม่อยู่ในระบบไฟล์ คุณจะไม่เห็นสิ่งใดที่น่าสงสัยในรายการพอร์ตการฟัง ในแง่ของความสามารถ นี่เป็นรูทคิทที่ครบครัน แต่ฉันหวังว่าจะปรับปรุงมันและใช้เป็นช่องทางสุดท้ายเมื่อค่าเฉลี่ยโหลดสูงเกินกว่าจะเข้าสู่ระบบผ่าน SSH และดำเนินการอย่างน้อย echo i > /proc/sysrq-triggerเพื่อคืนค่าการเข้าถึงโดยไม่ต้องรีบูตเครื่อง

เราใช้โปรแกรมแก้ไขข้อความ ทักษะการเขียนโปรแกรมขั้นพื้นฐานใน Python และ C, Google และ เสมือน ซึ่งคุณคงไม่รังเกียจที่จะยอมเสี่ยงหากทุกอย่างพัง (ตัวเลือก - VirtualBox/KVM/อื่นๆ ในเครื่อง) แล้วไปกันเลย!

ด้านลูกค้า

สำหรับฉันดูเหมือนว่าในส่วนของลูกค้าฉันจะต้องเขียนสคริปต์ประมาณ 80 บรรทัด แต่ก็มีคนใจดีที่ทำเพื่อฉัน งานทั้งหมด. โค้ดกลายเป็นเรื่องง่ายอย่างไม่คาดคิด โดยแบ่งเป็น 10 บรรทัดสำคัญ:

import sys
from scapy.all import sr1, IP, ICMP

if len(sys.argv) < 3:
    print('Usage: {} IP "command"'.format(sys.argv[0]))
    exit(0)

p = sr1(IP(dst=sys.argv[1])/ICMP()/"run:{}".format(sys.argv[2]))
if p:
    p.show()

สคริปต์รับสองอาร์กิวเมนต์ ที่อยู่ และเพย์โหลด ก่อนที่จะส่ง เพย์โหลดจะนำหน้าด้วยคีย์ run:เราจำเป็นต้องใช้มันเพื่อยกเว้นแพ็คเกจที่มีเพย์โหลดแบบสุ่ม

เคอร์เนลต้องการสิทธิ์ในการประดิษฐ์แพ็กเกจ ดังนั้นสคริปต์จะต้องทำงานในฐานะผู้ใช้ระดับสูง อย่าลืมให้สิทธิ์ในการดำเนินการและติดตั้ง scapy เอง Debian มีแพ็คเกจที่เรียกว่า python3-scapy. ตอนนี้คุณสามารถตรวจสอบวิธีการทำงานทั้งหมดได้แล้ว

การรันและส่งออกคำสั่ง
morq@laptop:~/icmpshell$ sudo ./send.py 45.11.26.232 "Hello, world!"
Begin emission:
.Finished sending 1 packets.
*
Received 2 packets, got 1 answers, remaining 0 packets
###[ IP ]###
version = 4
ihl = 5
tos = 0x0
len = 45
id = 17218
flags =
frag = 0
ttl = 58
proto = icmp
chksum = 0x3403
src = 45.11.26.232
dst = 192.168.0.240
options
###[ ICMP ]###
type = echo-reply
code = 0
chksum = 0xde03
id = 0x0
seq = 0x0
###[ Raw ]###
load = 'run:Hello, world!

นี่คือลักษณะที่ปรากฏในการดมกลิ่น
morq@laptop:~/icmpshell$ sudo tshark -i wlp1s0 -O icmp -f "icmp and host 45.11.26.232"
Running as user "root" and group "root". This could be dangerous.
Capturing on 'wlp1s0'
Frame 1: 59 bytes on wire (472 bits), 59 bytes captured (472 bits) on interface wlp1s0, id 0
Internet Protocol Version 4, Src: 192.168.0.240, Dst: 45.11.26.232
Internet Control Message Protocol
Type: 8 (Echo (ping) request)
Code: 0
Checksum: 0xd603 [correct] [Checksum Status: Good] Identifier (BE): 0 (0x0000)
Identifier (LE): 0 (0x0000)
Sequence number (BE): 0 (0x0000)
Sequence number (LE): 0 (0x0000)
Data (17 bytes)

0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 run:Hello, world
0010 21 !
Data: 72756e3a48656c6c6f2c20776f726c6421
[Length: 17]

Frame 2: 59 bytes on wire (472 bits), 59 bytes captured (472 bits) on interface wlp1s0, id 0
Internet Protocol Version 4, Src: 45.11.26.232, Dst: 192.168.0.240
Internet Control Message Protocol
Type: 0 (Echo (ping) reply)
Code: 0
Checksum: 0xde03 [correct] [Checksum Status: Good] Identifier (BE): 0 (0x0000)
Identifier (LE): 0 (0x0000)
Sequence number (BE): 0 (0x0000)
Sequence number (LE): 0 (0x0000)
[Request frame: 1] [Response time: 19.094 ms] Data (17 bytes)

0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 run:Hello, world
0010 21 !
Data: 72756e3a48656c6c6f2c20776f726c6421
[Length: 17]

^C2 packets captured

เพย์โหลดในแพ็คเกจการตอบกลับไม่เปลี่ยนแปลง

โมดูลเคอร์เนล

ในการสร้างเครื่องเสมือน Debian คุณจะต้องมีอย่างน้อย make и linux-headers-amd64ที่เหลือจะมาในรูปแบบของการพึ่งพาอาศัยกัน ฉันจะไม่ระบุรหัสทั้งหมดในบทความ คุณสามารถโคลนมันได้บน Github

การตั้งค่าตะขอ

อันดับแรก เราต้องการสองฟังก์ชันเพื่อโหลดโมดูลและยกเลิกการโหลด ไม่จำเป็นต้องมีฟังก์ชั่นการขนถ่าย แต่แล้ว rmmod มันจะไม่ทำงาน โมดูลจะถูกยกเลิกการโหลดเมื่อปิดเท่านั้น

#include <linux/module.h>
#include <linux/netfilter_ipv4.h>

static struct nf_hook_ops nfho;

static int __init startup(void)
{
  nfho.hook = icmp_cmd_executor;
  nfho.hooknum = NF_INET_PRE_ROUTING;
  nfho.pf = PF_INET;
  nfho.priority = NF_IP_PRI_FIRST;
  nf_register_net_hook(&init_net, &nfho);
  return 0;
}

static void __exit cleanup(void)
{
  nf_unregister_net_hook(&init_net, &nfho);
}

MODULE_LICENSE("GPL");
module_init(startup);
module_exit(cleanup);

เกิดอะไรขึ้นที่นี่:

  1. ไฟล์ส่วนหัวสองไฟล์ถูกดึงเข้ามาเพื่อจัดการโมดูลเองและ netfilter
  2. การดำเนินการทั้งหมดต้องผ่าน netfilter คุณสามารถตั้งค่า hooks ในนั้นได้ ในการดำเนินการนี้ คุณจะต้องประกาศโครงสร้างที่จะกำหนดค่า hook สิ่งที่สำคัญที่สุดคือการระบุฟังก์ชันที่จะถูกดำเนินการเป็น hook: nfho.hook = icmp_cmd_executor; ฉันจะไปที่ฟังก์ชันนี้ในภายหลัง
    จากนั้นฉันตั้งเวลาดำเนินการสำหรับแพ็คเกจ: NF_INET_PRE_ROUTING ระบุเพื่อประมวลผลแพ็กเกจเมื่อปรากฏครั้งแรกในเคอร์เนล สามารถใช้ได้ NF_INET_POST_ROUTING เพื่อประมวลผลแพ็กเก็ตเมื่อออกจากเคอร์เนล
    ฉันตั้งค่าตัวกรองเป็น IPv4: nfho.pf = PF_INET;.
    ฉันให้ความสำคัญกับตะขอของฉันเป็นอันดับแรก: nfho.priority = NF_IP_PRI_FIRST;
    และฉันลงทะเบียนโครงสร้างข้อมูลเป็น hook จริง: nf_register_net_hook(&init_net, &nfho);
  3. ฟังก์ชั่นสุดท้ายจะถอดตะขอออก
  4. มีการระบุใบอนุญาตอย่างชัดเจนเพื่อให้คอมไพเลอร์ไม่บ่น
  5. ฟังก์ชั่น module_init() и module_exit() ตั้งค่าฟังก์ชันอื่นๆ เพื่อเริ่มต้นและสิ้นสุดโมดูล

กำลังดึงข้อมูลเพย์โหลด

ตอนนี้เราจำเป็นต้องดึงข้อมูลเพย์โหลดออก ซึ่งกลายเป็นงานที่ยากที่สุด เคอร์เนลไม่มีฟังก์ชันในตัวสำหรับการทำงานกับเพย์โหลด คุณสามารถแยกวิเคราะห์ส่วนหัวของโปรโตคอลระดับที่สูงกว่าได้เท่านั้น

#include <linux/ip.h>
#include <linux/icmp.h>

#define MAX_CMD_LEN 1976

char cmd_string[MAX_CMD_LEN];

struct work_struct my_work;

DECLARE_WORK(my_work, work_handler);

static unsigned int icmp_cmd_executor(void *priv, struct sk_buff *skb, const struct nf_hook_state *state)
{
  struct iphdr *iph;
  struct icmphdr *icmph;

  unsigned char *user_data;
  unsigned char *tail;
  unsigned char *i;
  int j = 0;

  iph = ip_hdr(skb);
  icmph = icmp_hdr(skb);

  if (iph->protocol != IPPROTO_ICMP) {
    return NF_ACCEPT;
  }
  if (icmph->type != ICMP_ECHO) {
    return NF_ACCEPT;
  }

  user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
  tail = skb_tail_pointer(skb);

  j = 0;
  for (i = user_data; i != tail; ++i) {
    char c = *(char *)i;

    cmd_string[j] = c;

    j++;

    if (c == '')
      break;

    if (j == MAX_CMD_LEN) {
      cmd_string[j] = '';
      break;
    }

  }

  if (strncmp(cmd_string, "run:", 4) != 0) {
    return NF_ACCEPT;
  } else {
    for (j = 0; j <= sizeof(cmd_string)/sizeof(cmd_string[0])-4; j++) {
      cmd_string[j] = cmd_string[j+4];
      if (cmd_string[j] == '')
	break;
    }
  }

  schedule_work(&my_work);

  return NF_ACCEPT;
}

เกิดอะไรขึ้น:

  1. ฉันต้องรวมไฟล์ส่วนหัวเพิ่มเติม คราวนี้เพื่อจัดการส่วนหัว IP และ ICMP
  2. ฉันตั้งค่าความยาวบรรทัดสูงสุด: #define MAX_CMD_LEN 1976. ทำไมตรงนี้? เพราะคอมไพเลอร์บ่นเรื่องนี้! พวกเขาแนะนำฉันไปแล้วว่าฉันต้องเข้าใจ stack และ heap สักวันหนึ่งฉันจะทำเช่นนี้อย่างแน่นอนและอาจแก้ไขโค้ดด้วยซ้ำ ฉันตั้งค่าบรรทัดที่จะมีคำสั่งทันที: char cmd_string[MAX_CMD_LEN];. ควรมองเห็นได้ในทุกฟังก์ชั่น ฉันจะพูดถึงเรื่องนี้โดยละเอียดในย่อหน้าที่ 9
  3. ตอนนี้เราต้องเริ่มต้น (struct work_struct my_work;) จัดโครงสร้างและเชื่อมต่อกับฟังก์ชันอื่น (DECLARE_WORK(my_work, work_handler);). ฉันจะพูดถึงว่าทำไมสิ่งนี้ถึงจำเป็นในย่อหน้าที่เก้า
  4. ตอนนี้ฉันประกาศฟังก์ชั่นซึ่งจะเป็นตะขอ ประเภทและอาร์กิวเมนต์ที่ยอมรับถูกกำหนดโดย netfilter เราสนใจเท่านั้น skb. นี่คือซ็อกเก็ตบัฟเฟอร์ ซึ่งเป็นโครงสร้างข้อมูลพื้นฐานที่มีข้อมูลที่มีอยู่ทั้งหมดเกี่ยวกับแพ็กเก็ต
  5. เพื่อให้ฟังก์ชันทำงานได้ คุณจะต้องมีโครงสร้าง XNUMX ตัวและตัวแปรหลายตัว รวมถึงตัววนซ้ำ XNUMX ตัว
      struct iphdr *iph;
      struct icmphdr *icmph;
    
      unsigned char *user_data;
      unsigned char *tail;
      unsigned char *i;
      int j = 0;
  6. เราสามารถเริ่มต้นด้วยตรรกะ เพื่อให้โมดูลทำงานได้ ไม่จำเป็นต้องมีแพ็กเก็ตอื่นนอกจาก ICMP Echo ดังนั้นเราจึงแยกวิเคราะห์บัฟเฟอร์โดยใช้ฟังก์ชันในตัว และโยนแพ็กเก็ตที่ไม่ใช่ ICMP และไม่ใช่ Echo ทั้งหมดออกไป กลับ NF_ACCEPT หมายถึงการยอมรับพัสดุ แต่คุณสามารถส่งคืนพัสดุได้ด้วยการส่งคืน NF_DROP.
      iph = ip_hdr(skb);
      icmph = icmp_hdr(skb);
    
      if (iph->protocol != IPPROTO_ICMP) {
        return NF_ACCEPT;
      }
      if (icmph->type != ICMP_ECHO) {
        return NF_ACCEPT;
      }

    ฉันยังไม่ได้ทดสอบว่าจะเกิดอะไรขึ้นโดยไม่ตรวจสอบส่วนหัว IP ความรู้เพียงเล็กน้อยของฉันเกี่ยวกับ C บอกฉันว่าหากไม่มีการตรวจสอบเพิ่มเติม สิ่งที่เลวร้ายจะเกิดขึ้นอย่างแน่นอน ฉันจะดีใจถ้าคุณห้ามฉันเรื่องนี้!

  7. เมื่อแพ็คเกจเป็นประเภทที่คุณต้องการแล้ว คุณก็สามารถแยกข้อมูลได้ หากไม่มีฟังก์ชันในตัว คุณต้องได้รับตัวชี้ไปยังจุดเริ่มต้นของเพย์โหลดก่อน เสร็จสิ้นในที่เดียว คุณต้องนำตัวชี้ไปที่จุดเริ่มต้นของส่วนหัว ICMP และย้ายไปยังขนาดของส่วนหัวนี้ ทุกอย่างใช้โครงสร้าง icmph: user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
    ส่วนท้ายของส่วนหัวจะต้องตรงกับจุดสิ้นสุดของเพย์โหลดเข้า skbดังนั้นเราจึงได้มาโดยใช้วิธีการทางนิวเคลียร์จากโครงสร้างที่เกี่ยวข้อง: tail = skb_tail_pointer(skb);.

    เปลือกนิวเคลียร์เหนือ ICMP

    รูปภาพถูกขโมย ด้วยเหตุนี้คุณสามารถอ่านเพิ่มเติมเกี่ยวกับบัฟเฟอร์ซ็อกเก็ตได้

  8. เมื่อคุณมีพอยน์เตอร์ที่จุดเริ่มต้นและจุดสิ้นสุดแล้ว คุณสามารถคัดลอกข้อมูลลงในสตริงได้ cmd_stringให้ตรวจสอบว่ามีคำนำหน้าอยู่หรือไม่ run: และทิ้งแพ็กเกจหากหายไป หรือเขียนบรรทัดใหม่อีกครั้ง โดยลบคำนำหน้านี้ออก
  9. เพียงเท่านี้คุณก็โทรหาผู้จัดการคนอื่นได้แล้ว: schedule_work(&my_work);. เนื่องจากไม่สามารถส่งพารามิเตอร์ไปยังการโทรดังกล่าวได้ บรรทัดที่มีคำสั่งจึงต้องเป็นโกลบอล schedule_work() จะวางฟังก์ชันที่เกี่ยวข้องกับโครงสร้างที่ส่งผ่านลงในคิวทั่วไปของตัวกำหนดเวลางานและเสร็จสิ้น ทำให้คุณไม่ต้องรอให้คำสั่งเสร็จสิ้น สิ่งนี้จำเป็นเพราะตะขอจะต้องเร็วมาก มิฉะนั้น ตัวเลือกของคุณคือจะไม่มีอะไรเริ่มต้น ไม่เช่นนั้น คุณจะเกิดอาการเคอร์เนลตื่นตระหนก ความล่าช้าก็เหมือนความตาย!
  10. เพียงเท่านี้คุณก็สามารถรับแพ็คเกจพร้อมการคืนสินค้าที่เกี่ยวข้องได้

การเรียกโปรแกรมในพื้นที่ผู้ใช้

ฟังก์ชั่นนี้เป็นที่เข้าใจได้มากที่สุด ชื่อของมันถูกตั้งไว้ใน DECLARE_WORK()ประเภทและข้อโต้แย้งที่ยอมรับนั้นไม่น่าสนใจ เราใช้บรรทัดคำสั่งและส่งต่อไปยังเชลล์ทั้งหมด ให้เขาจัดการกับการแยกวิเคราะห์ ค้นหาไบนารี่ และทุกอย่างอื่นๆ

static void work_handler(struct work_struct * work)
{
  static char *argv[] = {"/bin/sh", "-c", cmd_string, NULL};
  static char *envp[] = {"PATH=/bin:/sbin", NULL};

  call_usermodehelper(argv[0], argv, envp, UMH_WAIT_PROC);
}

  1. ตั้งค่าอาร์กิวเมนต์เป็นอาร์เรย์ของสตริง argv[]. ฉันจะถือว่าทุกคนรู้ว่าจริง ๆ แล้วโปรแกรมถูกดำเนินการในลักษณะนี้ และไม่ใช่บรรทัดต่อเนื่องกันที่มีช่องว่าง
  2. ตั้งค่าตัวแปรสภาพแวดล้อม ฉันแทรกเฉพาะ PATH ด้วยชุดเส้นทางขั้นต่ำ โดยหวังว่าเส้นทางทั้งหมดจะรวมกันแล้ว /bin с /usr/bin и /sbin с /usr/sbin. เส้นทางอื่นๆ ไม่ค่อยมีความสำคัญในทางปฏิบัติ
  3. เสร็จแล้วมาทำกัน! ฟังก์ชันเคอร์เนล call_usermodehelper() ยอมรับการเข้า เส้นทางไปยังไบนารี อาร์เรย์ของอาร์กิวเมนต์ อาร์เรย์ของตัวแปรสภาพแวดล้อม ที่นี่ฉันยังถือว่าทุกคนเข้าใจความหมายของการส่งเส้นทางไปยังไฟล์ปฏิบัติการเป็นอาร์กิวเมนต์แยกต่างหาก แต่คุณสามารถถามได้ อาร์กิวเมนต์สุดท้ายระบุว่าจะรอให้กระบวนการเสร็จสิ้นหรือไม่ (UMH_WAIT_PROC) การเริ่มต้นกระบวนการ (UMH_WAIT_EXEC) หรือไม่รอเลย (UMH_NO_WAIT). มีอีกไหม UMH_KILLABLEฉันไม่ได้พิจารณามัน

การชุมนุม

การประกอบโมดูลเคอร์เนลจะดำเนินการผ่านเฟรมเวิร์กการสร้างเคอร์เนล เรียกว่า make ภายในไดเร็กทอรีพิเศษที่เชื่อมโยงกับเวอร์ชันเคอร์เนล (กำหนดไว้ที่นี่: KERNELDIR:=/lib/modules/$(shell uname -r)/build) และตำแหน่งของโมดูลจะถูกส่งผ่านไปยังตัวแปร M ในการโต้แย้ง icmpshell.ko และ clean เป้าหมายใช้เฟรมเวิร์กนี้ทั้งหมด ใน obj-m ระบุไฟล์อ็อบเจ็กต์ที่จะถูกแปลงเป็นโมดูล ไวยากรณ์ที่สร้างใหม่ main.o в icmpshell.o (icmpshell-objs = main.o) ดูไม่ค่อยสมเหตุสมผลสำหรับฉัน แต่ก็เป็นเช่นนั้น

KERNELDIR:=/lib/modules/$(shell uname -r)/build

obj-m = icmpshell.o
icmpshell-objs = main.o

all: icmpshell.ko

icmpshell.ko: main.c
make -C $(KERNELDIR) M=$(PWD) modules

clean:
make -C $(KERNELDIR) M=$(PWD) clean

เรารวบรวม: make. กำลังโหลด: insmod icmpshell.ko. เสร็จแล้วคุณสามารถตรวจสอบ: sudo ./send.py 45.11.26.232 "date > /tmp/test". หากคุณมีไฟล์อยู่ในเครื่องของคุณ /tmp/test และมีวันที่ส่งคำขอ ซึ่งหมายความว่าคุณทำทุกอย่างถูกต้องแล้ว และฉันก็ทำทุกอย่างถูกต้องแล้ว

ข้อสรุป

ประสบการณ์ครั้งแรกของฉันกับการพัฒนานิวเคลียร์นั้นง่ายกว่าที่ฉันคาดไว้มาก แม้ว่าจะไม่มีประสบการณ์ในการพัฒนาในภาษา C โดยเน้นไปที่คำแนะนำของคอมไพเลอร์และผลลัพธ์ของ Google ฉันก็สามารถเขียนโมดูลที่ใช้งานได้และรู้สึกเหมือนเป็นเคอร์เนลแฮ็กเกอร์ และในขณะเดียวกันก็เป็นสคริปต์ตัวเล็กๆ นอกจากนี้ ฉันไปที่ช่อง Kernel Newbies ซึ่งพวกเขาบอกให้ฉันใช้ schedule_work() แทนที่จะโทร call_usermodehelper() เข้าไปในตะขอและทำให้เขาอับอายโดยสงสัยว่าเป็นการหลอกลวง โค้ดหลายร้อยบรรทัดทำให้ฉันเสียเวลาในการพัฒนาประมาณหนึ่งสัปดาห์ในเวลาว่าง ประสบการณ์ที่ประสบความสำเร็จซึ่งทำลายความเชื่อส่วนตัวของฉันเกี่ยวกับความซับซ้อนอย่างท่วมท้นของการพัฒนาระบบ

หากมีคนตกลงที่จะทำการตรวจสอบโค้ดบน Github ฉันจะขอบคุณ ฉันค่อนข้างแน่ใจว่าฉันทำผิดพลาดโง่ๆ มากมาย โดยเฉพาะเมื่อทำงานกับสตริง

เปลือกนิวเคลียร์เหนือ ICMP

ที่มา: will.com

เพิ่มความคิดเห็น